{{ message }}
Repository navigation
Expand file tree
/
Copy pathservice.go
More file actions
484 lines (451 loc) · 13.9 KB
/
Copy pathservice.go
File metadata and controls
484 lines (451 loc) · 13.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
package main
import (
"fmt"
"io"
"log"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
// launchd, not a Swift agent. This has to run as the logged-in user — it reads
// the Xirp app's process environment for the daemon token, which `ps -E` only
// permits for the same user — so it is a LaunchAgent in the GUI domain, not a
// LaunchDaemon. A LaunchDaemon runs as root in a different session and would see
// neither the app nor its environment.
const (
serviceLabel = "dev.xirp.remote"
defaultAddr = "0.0.0.0:8790"
)
func plistPath() string {
home, _ := os.UserHomeDir()
return filepath.Join(home, "Library", "LaunchAgents", serviceLabel+".plist")
}
func logPath() string {
home, _ := os.UserHomeDir()
return filepath.Join(home, "Library", "Logs", "xirp-remote.log")
}
func installedBinPath() string {
home, _ := os.UserHomeDir()
return filepath.Join(home, ".local", "bin", "xirp-remote")
}
func guiTarget() string { return fmt.Sprintf("gui/%d/%s", os.Getuid(), serviceLabel) }
func launchctl(args ...string) (string, error) {
out, err := exec.Command("launchctl", args...).CombinedOutput()
return strings.TrimSpace(string(out)), err
}
// portHolder reports what is listening on the address, so install can refuse to
// fight another supervisor for the port instead of flapping against it.
func portHolder(addr string) string {
_, port, err := net.SplitHostPort(addr)
if err != nil {
return ""
}
out, err := exec.Command("lsof", "-nP", "-iTCP:"+port, "-sTCP:LISTEN", "-Fcp").Output()
if err != nil || len(out) == 0 {
return ""
}
var pid, cmd string
for _, line := range strings.Split(string(out), "\n") {
if strings.HasPrefix(line, "p") {
pid = line[1:]
}
if strings.HasPrefix(line, "c") {
cmd = line[1:]
}
}
if pid == "" {
return ""
}
return fmt.Sprintf("%s (pid %s)", cmd, pid)
}
func writePlist(bin, addr, key, publicURL string) error {
env := fmt.Sprintf("\n <key>XIRP_REMOTE_ADDR</key>\n <string>%s</string>", addr)
if key != "" {
env += fmt.Sprintf("\n <key>XIRP_REMOTE_KEY</key>\n <string>%s</string>", key)
}
if publicURL != "" {
env += fmt.Sprintf("\n <key>XIRP_REMOTE_URL</key>\n <string>%s</string>", publicURL)
}
// PATH matters: transcripts come from `squab`, which is run with `node`. A
// LaunchAgent inherits a minimal PATH that does not include Homebrew, so node
// would not be found and every transcript would come back empty.
plist := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>%s</string>
<key>ProgramArguments</key>
<array>
<string>%s</string>
<string>serve</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>%s
</dict>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>ProcessType</key>
<string>Background</string>
<key>StandardOutPath</key>
<string>%s</string>
<key>StandardErrorPath</key>
<string>%s</string>
</dict>
</plist>
`, serviceLabel, bin, env, logPath(), logPath())
if err := os.MkdirAll(filepath.Dir(plistPath()), 0o755); err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(logPath()), 0o755); err != nil {
return err
}
return os.WriteFile(plistPath(), []byte(plist), 0o644)
}
func cmdInstall(args []string) error {
// Re-running install is the upgrade path, so it must not lose configuration.
// Reading the flags over a blank slate meant a plain `xirp-remote install`
// silently removed the access key and turned authentication off — an upgrade
// that quietly unlocks the door. Existing values are the defaults; flags and
// environment override them, and --no-key removes a key on purpose.
addr, copyBin := defaultAddr, true
key := firstNonEmpty(os.Getenv("XIRP_REMOTE_KEY"), plistEnv("XIRP_REMOTE_KEY"))
publicURL := firstNonEmpty(os.Getenv("XIRP_REMOTE_URL"), plistEnv("XIRP_REMOTE_URL"))
if existing := plistEnv("XIRP_REMOTE_ADDR"); existing != "" {
addr = existing
}
for i := 0; i < len(args); i++ {
switch args[i] {
case "--addr":
if i+1 < len(args) {
addr = args[i+1]
i++
}
case "--key":
if i+1 < len(args) {
key = args[i+1]
i++
}
case "--interface":
if i+1 < len(args) {
addr2, err := resolveInterface(args[i+1])
if err != nil {
return err
}
_, port, _ := net.SplitHostPort(addr)
if port == "" {
port = "8790"
}
addr = net.JoinHostPort(addr2, port)
i++
}
case "--bind":
if i+1 < len(args) {
host := args[i+1]
_, port, _ := net.SplitHostPort(addr)
if port == "" {
port = "8790"
}
if strings.Contains(host, ":") {
addr = host
} else {
addr = net.JoinHostPort(host, port)
}
i++
}
case "--url":
if i+1 < len(args) {
publicURL = strings.TrimRight(args[i+1], "/")
i++
}
case "--no-copy":
copyBin = false
case "--no-key":
key = ""
case "--generate-key":
k, err := generateKey()
if err != nil {
return err
}
key = k
}
}
if holder := portHolder(addr); holder != "" && !serviceRunning() {
return fmt.Errorf("%s is already held by %s.\n"+
"Stop that first, or pass --addr to use a different port, then run install again", addr, holder)
}
self, err := os.Executable()
if err != nil {
return err
}
self, _ = filepath.EvalSymlinks(self)
bin := self
if copyBin {
// Copy out of the build directory so a `git clean` or a rebuild cannot
// leave launchd pointing at a binary that no longer exists.
bin = installedBinPath()
if err := os.MkdirAll(filepath.Dir(bin), 0o755); err != nil {
return err
}
if bin != self {
data, err := os.ReadFile(self)
if err != nil {
return err
}
if err := os.WriteFile(bin+".new", data, 0o755); err != nil {
return err
}
if err := os.Rename(bin+".new", bin); err != nil {
return err
}
}
}
if err := writePlist(bin, addr, key, publicURL); err != nil {
return err
}
// bootout first so install doubles as upgrade; a failure here just means it
// was not loaded.
//
// bootout is asynchronous. Bootstrapping while the old job is still on its way
// out fails with "service already loaded", and because that left the job booted
// out, the result was a service that was installed, enabled, and not running.
// So wait for it to actually be gone.
_, _ = launchctl("bootout", guiTarget())
for i := 0; i < 40; i++ {
if _, err := launchctl("print", guiTarget()); err != nil {
break // print fails once the job is unloaded, which is what we want
}
time.Sleep(100 * time.Millisecond)
}
if out, err := launchctl("bootstrap", fmt.Sprintf("gui/%d", os.Getuid()), plistPath()); err != nil {
return fmt.Errorf("launchctl bootstrap: %v: %s", err, out)
}
_, _ = launchctl("enable", guiTarget())
fmt.Printf("installed %s\n binary %s\n plist %s\n log %s\n addr %s\n auth %s\n",
serviceLabel, bin, plistPath(), logPath(), addr,
map[bool]string{true: "key required", false: "OPEN, no authentication"}[key != ""])
if publicURL != "" {
fmt.Printf(" url %s\n", publicURL)
}
if err := waitHealthy(addr, 15*time.Second); err != nil {
return fmt.Errorf("service installed but not answering: %w (see `xirp-remote logs`)", err)
}
fmt.Println("service is up and answering /healthz")
if publicURL != "" {
os.Setenv("XIRP_REMOTE_URL", publicURL)
}
if key != "" {
remoteKey = key
fmt.Println("\nScan to pair a phone:")
if err := cmdQR(); err != nil {
fmt.Println("(could not render the QR code:", err, ")")
}
}
return nil
}
func cmdUninstall() error {
out, err := launchctl("bootout", guiTarget())
if err != nil && !strings.Contains(out, "No such process") {
fmt.Printf("bootout: %s\n", out)
}
if err := os.Remove(plistPath()); err != nil && !os.IsNotExist(err) {
return err
}
fmt.Printf("uninstalled %s\n(binary at %s and log at %s were left in place)\n",
serviceLabel, installedBinPath(), logPath())
return nil
}
func serviceRunning() bool {
out, err := launchctl("print", guiTarget())
return err == nil && strings.Contains(out, "state = running")
}
func servicePID() string {
out, err := launchctl("print", guiTarget())
if err != nil {
return ""
}
for _, line := range strings.Split(out, "\n") {
if strings.Contains(line, "pid =") {
return strings.TrimSpace(strings.SplitN(line, "=", 2)[1])
}
}
return ""
}
func waitHealthy(addr string, d time.Duration) error {
_, port, err := net.SplitHostPort(addr)
if err != nil {
return err
}
url := "http://127.0.0.1:" + port + "/healthz"
deadline := time.Now().Add(d)
var last error
for time.Now().Before(deadline) {
resp, err := http.Get(url) //nolint:gosec // fixed loopback URL
if err == nil {
resp.Body.Close()
if resp.StatusCode == 200 {
return nil
}
last = fmt.Errorf("HTTP %d", resp.StatusCode)
} else {
last = err
}
time.Sleep(500 * time.Millisecond)
}
return last
}
// cmdStatus answers the question the user actually has, which is not "is the
// process alive" but "can I use it right now". Those differ: the service can be
// running perfectly while Xirp is quit, in which case there is nothing to control.
func cmdStatus() error {
installed := true
if _, err := os.Stat(plistPath()); os.IsNotExist(err) {
installed = false
}
fmt.Printf("service %s\n", map[bool]string{true: "installed", false: "not installed"}[installed])
if installed {
state := "stopped"
if serviceRunning() {
state = "running"
if pid := servicePID(); pid != "" {
state += " (pid " + pid + ")"
}
}
fmt.Printf("launchd %s\n", state)
}
addr := os.Getenv("XIRP_REMOTE_ADDR")
if addr == "" {
addr = defaultAddr
}
if err := waitHealthy(addr, 2*time.Second); err != nil {
fmt.Printf("http not answering on %s (%v)\n", addr, err)
} else {
fmt.Printf("http answering on %s\n", addr)
}
if creds, err := discover(); err != nil {
fmt.Printf("xirp not reachable: %v\n", err)
} else {
fmt.Printf("xirp daemon on 127.0.0.1:%s, token discovered\n", creds.Port)
if res, err := client.Call(map[string]any{"type": "sessions:list"}, "sessions:list", 10*time.Second); err == nil {
list, _ := res["sessions"].([]any)
fmt.Printf("sessions %d\n", len(list))
}
}
return nil
}
func cmdLogs(args []string) error {
tailArgs := []string{"-n", "50"}
for _, a := range args {
if a == "-f" || a == "--follow" {
tailArgs = append(tailArgs, "-f")
}
}
tailArgs = append(tailArgs, logPath())
cmd := exec.Command("tail", tailArgs...)
cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
return cmd.Run()
}
func cmdRestart() error {
if out, err := launchctl("kickstart", "-k", guiTarget()); err != nil {
return fmt.Errorf("kickstart: %v: %s", err, out)
}
fmt.Println("restarted")
return nil
}
func usage() {
fmt.Print(`xirp-remote — mobile web control for the Xirp agent daemon
USAGE
xirp-remote [serve] Run in the foreground (default)
xirp-remote install [flags] Install and start as a launchd user agent
xirp-remote uninstall Stop and remove the launchd agent
xirp-remote start | stop | restart
xirp-remote status Service, HTTP and Xirp daemon state
xirp-remote logs [-f] Show the service log
xirp-remote qr Print a pairing QR code for a phone
xirp-remote interfaces List addresses this can serve on
xirp-remote version Print the version
INSTALL FLAGS
--addr <host:port> Listen address (default 0.0.0.0:8790)
--key <key> Require this access key (default: open, no auth)
--generate-key Generate a random access key and show its pairing QR
--no-key Remove the access key (run open, no authentication)
--interface <name> Serve on one interface only (see: xirp-remote interfaces)
--bind <address> Serve on one address only (default 0.0.0.0, every interface)
--url <base> Public base URL to encode in pairing QR codes
--no-copy Run from the current binary path instead of ~/.local/bin
ENVIRONMENT
XIRP_REMOTE_ADDR Listen address for ` + "`serve`" + `
XIRP_REMOTE_KEY Access key; unset means open, no authentication
XIRP_REMOTE_URL Public base URL to put in the pairing QR (e.g. your proxy)
The agent runs in your GUI login session on purpose: it reads the Xirp app's
process environment for the daemon token, which only works as the same user.
`)
}
func runCLI(args []string) (handled bool, err error) {
if len(args) == 0 {
return false, nil
}
if args[0] != "serve" {
// CLI output is for reading. The daemon client logs its connection on
// stdout, which belongs in the service log, not interleaved with a status
// report someone is trying to scan.
log.SetOutput(io.Discard)
}
switch args[0] {
case "serve":
return false, nil
case "install":
return true, cmdInstall(args[1:])
case "uninstall":
return true, cmdUninstall()
case "start":
out, e := launchctl("kickstart", guiTarget())
if e != nil {
return true, fmt.Errorf("start: %v: %s", e, out)
}
fmt.Println("started")
return true, nil
case "stop":
out, e := launchctl("kill", "SIGTERM", guiTarget())
if e != nil {
return true, fmt.Errorf("stop: %v: %s", e, out)
}
fmt.Println("stopped (KeepAlive will restart it; use `uninstall` to stop for good)")
return true, nil
case "restart":
return true, cmdRestart()
case "status":
return true, cmdStatus()
case "logs":
return true, cmdLogs(args[1:])
case "qr", "pair":
return true, cmdQR()
case "interfaces", "ifaces":
return true, cmdInterfaces()
case "version", "--version", "-v":
fmt.Printf("xirp-remote %s\n", version)
return true, nil
case "-h", "--help", "help":
usage()
return true, nil
default:
usage()
return true, fmt.Errorf("unknown command %q", args[0])
}
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if v != "" {
return v
}
}
return ""
}
You can’t perform that action at this time.
