1// Command neferafk is the NeferAFK absence daemon and its private tools.
2package main
3
4import (
5 "context"
6 "errors"
7 "flag"
8 "fmt"
9 "io"
10 "io/fs"
11 "os"
12 "os/signal"
13 "syscall"
14
15 "github.com/bnema/neferafk/internal/adapters/auth"
16 "github.com/bnema/neferafk/internal/adapters/config"
17 "github.com/bnema/neferafk/internal/adapters/control"
18 "github.com/bnema/neferafk/internal/adapters/visual"
19 "github.com/bnema/neferafk/internal/app"
20 "github.com/bnema/zerowrap"
21)
22
23const (
24 exitOK = 0
25 exitFailure = 1
26 exitUsage = 2
27 exitUnsupported = 3
28)
29
30// version is set at build time with -ldflags "-X main.version=...".
31var version = "dev"
32
33const usage = `usage: neferafk <command> [flags]
34
35commands:
36 run [--config FILE] run the absence daemon
37 lock ask the running daemon to lock now
38 status print the running daemon's state
39 validate-config FILE check a configuration file and exit
40 version print the version
41 visual internal: fade/lock process (fd 3 commands in, fd 4 events out)
42`
43
44func main() { os.Exit(execute(os.Args[1:], os.Stdout, os.Stderr)) }
45
46func execute(args []string, stdout, stderr io.Writer) int {
47 if len(args) == 0 {
48 fmt.Fprint(stderr, usage)
49 return exitUsage
50 }
51 cmd, rest := args[0], args[1:]
52 switch cmd {
53 case "run":
54 return runDaemon(rest, stderr)
55 case "lock", "status":
56 return runControl(cmd, rest, stdout, stderr)
57 case "visual":
58 return runVisual(rest, os.NewFile(3, "visual-cmd"), os.NewFile(4, "visual-events"), stderr)
59 case "auth-worker": // hidden: spawned by the visual process only
60 return runAuthWorker(rest, os.NewFile(3, "auth-in"), os.NewFile(4, "auth-out"), stderr)
61 case "version":
62 if len(rest) != 0 {
63 fmt.Fprint(stderr, usage)
64 return exitUsage
65 }
66 fmt.Fprintln(stdout, version)
67 return exitOK
68 case "validate-config":
69 if len(rest) != 1 {
70 fmt.Fprint(stderr, usage)
71 return exitUsage
72 }
73 if _, err := config.Load(rest[0]); err != nil {
74 if errors.Is(err, fs.ErrNotExist) || errors.Is(err, fs.ErrPermission) {
75 fmt.Fprintf(stderr, "neferafk: %v\n", err) // already names the file
76 } else {
77 fmt.Fprintf(stderr, "neferafk: %s: %v\n", rest[0], err)
78 }
79 return exitFailure
80 }
81 return exitOK
82 case "-h", "--help", "help":
83 fmt.Fprint(stdout, usage)
84 return exitOK
85 }
86 fmt.Fprintf(stderr, "neferafk: unknown command %q\n%s", cmd, usage)
87 return exitUsage
88}
89
90func runDaemon(args []string, stderr io.Writer) int {
91 fs := flag.NewFlagSet("run", flag.ContinueOnError)
92 fs.SetOutput(stderr)
93 cfg := fs.String("config", "", "configuration file (default $XDG_CONFIG_HOME/neferafk/config)")
94 if err := fs.Parse(args); err != nil {
95 return exitUsage
96 }
97 if fs.NArg() != 0 {
98 fmt.Fprintln(stderr, "neferafk run: unexpected arguments")
99 return exitUsage
100 }
101 exe, err := os.Executable()
102 if err != nil {
103 fmt.Fprintf(stderr, "neferafk: %v\n", err)
104 return exitFailure
105 }
106 ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
107 defer stop()
108 // Compositors usually start the daemon with stderr discarded, so it also
109 // logs to $XDG_STATE_HOME/neferafk/logs/daemon.log (never secrets).
110 log, closeLog, err := zerowrap.NewWithFile(zerowrap.Config{Output: stderr},
111 zerowrap.FileConfig{Enabled: true, AppName: "neferafk", Name: "daemon", MaxSize: 5, MaxBackups: 1})
112 if err != nil {
113 log = zerowrap.New(zerowrap.Config{Output: stderr})
114 closeLog = func() {}
115 log.Warn().Str("component", "main").Err(err).Msg("file logging disabled")
116 }
117 defer closeLog()
118 err = app.RunDaemon(ctx, app.DaemonOptions{ConfigPath: *cfg, Executable: exe, Log: log})
119 var unsup *app.UnsupportedError
120 switch {
121 case err == nil:
122 return exitOK
123 case errors.As(err, &unsup):
124 log.Error().Str("component", "main").Strs("missing", unsup.Missing).Msg("unsupported environment")
125 fmt.Fprintf(stderr, "neferafk: %v\n", unsup)
126 return exitUnsupported
127 default:
128 log.Error().Str("component", "main").Err(err).Msg("daemon failed")
129 fmt.Fprintf(stderr, "neferafk: %v\n", err)
130 return exitFailure
131 }
132}
133
134func runControl(cmd string, args []string, stdout, stderr io.Writer) int {
135 if len(args) != 0 {
136 fmt.Fprintf(stderr, "neferafk %s: unexpected arguments\n", cmd)
137 return exitUsage
138 }
139 path, err := control.DefaultPath()
140 if err != nil {
141 fmt.Fprintf(stderr, "neferafk: %v\n", err)
142 return exitFailure
143 }
144 reply, err := control.Do(context.Background(), path, cmd)
145 if err != nil {
146 fmt.Fprintf(stderr, "neferafk %s: %v\n", cmd, err)
147 return exitFailure
148 }
149 fmt.Fprintln(stdout, reply)
150 return exitOK
151}
152
153// Descriptors the daemon hands down by exec ExtraFiles: fd 3 in, fd 4 out.
154// Go clears close-on-exec on them in the child, so anything we spawn later
155// (`pass`, gpg) would inherit the secret input pipe and the result output pipe
156// and could read secrets or forge results. Mark them before doing anything.
157const (
158 inheritedIn = 3
159 inheritedOut = 4
160)
161
162func markInheritedCloexec() {
163 syscall.CloseOnExec(inheritedIn)
164 syscall.CloseOnExec(inheritedOut)
165}
166
167// runAuthWorker serves one lock's authentication on the private pipes handed
168// down by the visual process (fd 3: bootstrap + frames in, fd 4: frames out).
169// Nothing typed ever passes through the policy daemon.
170func runAuthWorker(args []string, in, out *os.File, stderr io.Writer) int {
171 if len(args) != 0 || in == nil || out == nil {
172 fmt.Fprintln(stderr, "neferafk auth-worker: internal command")
173 return exitUsage
174 }
175 markInheritedCloexec() // before anything is read or spawned
176 if err := auth.HardenProcess(); err != nil {
177 fmt.Fprintln(stderr, "neferafk auth-worker: hardening failed")
178 return exitFailure
179 }
180 ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
181 defer stop()
182 return serveAuth(ctx, in, out, stderr)
183}
184
185// serveAuth reads the bootstrap record and runs the worker loop.
186func serveAuth(ctx context.Context, in io.Reader, out io.Writer, stderr io.Writer) int {
187 bootstrap, err := auth.ReadBootstrap(in)
188 if err != nil {
189 fmt.Fprintln(stderr, "neferafk auth-worker: invalid bootstrap")
190 return exitFailure
191 }
192 if err := auth.Run(ctx, in, out, bootstrap); err != nil {
193 fmt.Fprintln(stderr, "neferafk auth-worker: session ended without success")
194 return exitFailure
195 }
196 return exitOK
197}
198
199// runVisual serves the fade/lock process spawned by the daemon: commands on fd
200// 3, lifecycle events on fd 4. An active lock outlives the daemon and SIGTERM;
201// it ends only after a successful unlock.
202func runVisual(args []string, in, out *os.File, stderr io.Writer) int {
203 if len(args) != 0 || in == nil || out == nil {
204 fmt.Fprintln(stderr, "neferafk visual: internal command")
205 return exitUsage
206 }
207 markInheritedCloexec() // the auth worker is spawned with its own pipes only
208 // The visual process holds the typed secret and the PIN bootstrap: no core
209 // dumps and no ptrace/proc-mem access from same-uid processes.
210 if err := auth.HardenProcess(); err != nil {
211 fmt.Fprintln(stderr, "neferafk visual: hardening failed")
212 return exitFailure
213 }
214 exe, err := visual.ExecutablePath()
215 if err != nil {
216 fmt.Fprintf(stderr, "neferafk visual: %v\n", err)
217 return exitFailure
218 }
219 ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
220 defer stop()
221 log := zerowrap.New(zerowrap.Config{Output: stderr})
222 if err := visual.Run(ctx, visual.Config{Commands: in, Events: out, Executable: exe, Log: log}); err != nil {
223 fmt.Fprintf(stderr, "neferafk visual: %v\n", err)
224 return exitFailure
225 }
226 return exitOK
227}