Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ All install paths provide `subrouter`, `sr`, and `cx`. The npm and Python wrappe

### Local macOS daemon

On macOS, install Subrouter as a localhost-only LaunchAgent:
On macOS, install Subrouter as a loopback-only LaunchAgent:

```bash
make build
Expand All @@ -136,6 +136,8 @@ Transcript recording is off by default. Enable it explicitly with `subrouter ins

The 10 minute `sr` auto-switch interval is the default. Override it with `subrouter install-daemon --sr-switch-interval 5m`, or disable it with `--sr-switch-interval 0`. The old `--cx-switch-interval` flag remains a compatibility alias.

To serve other machines from this Mac instead, pass a tailnet address and an admin token — `install-daemon` refuses a non-loopback bind without one. See [docs/tailscale.md](docs/tailscale.md).

### Linux systemd service

On a Linux server, install the binary and service:
Expand Down Expand Up @@ -183,6 +185,8 @@ sudo sr install-systemd --addr 0.0.0.0:31415 --admin-token "$TOKEN"
sr server add team --url http://100.64.0.1:31415 --admin-token "$TOKEN" --default
```

The macOS equivalent is `install-daemon --addr <tailnet-ip>:31415 --admin-token-file <path>`; [docs/tailscale.md](docs/tailscale.md) covers that topology, including why tailnet membership is not itself authorization.

When `SUBROUTER_ADMIN_TOKEN` or `--admin-token` is set, non-loopback requests to sensitive `/_subrouter/*` endpoints must send `Authorization: Bearer <token>` or `X-Subrouter-Admin-Token: <token>`. Loopback stays trusted for ordinary admin endpoints. Account onboarding uses a distinct `SUBROUTER_ACCOUNT_IMPORT_TOKEN`; that token authorizes only `GET` and `POST /_subrouter/account-import` and cannot access admin APIs or proxy traffic.

A server with neither credential configured rejects every account import, including `sr add`. That state is reported as `"account_import": "disabled"` by `/_subrouter/health` and logged as a warning at startup, and `sr doctor` runs the same preflight `sr add` runs against the selected server.
Expand Down
167 changes: 136 additions & 31 deletions cmd/subrouter/install_daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ const defaultDaemonLabel = "ai.manaflow.subrouter"
type daemonConfig struct {
Label string
Addr string
AdminToken string
AdminTokenFile string
InstallPath string
TranscriptsDir string
LogDir string
Expand Down Expand Up @@ -51,7 +53,9 @@ func installDaemon(args []string) error {
flags := flag.NewFlagSet("install-daemon", flag.ContinueOnError)
config := daemonConfig{}
flags.StringVar(&config.Label, "label", defaultDaemonLabel, "launchd label")
flags.StringVar(&config.Addr, "addr", "127.0.0.1:31415", "daemon listen address")
flags.StringVar(&config.Addr, "addr", "127.0.0.1:31415", "daemon listen address; a non-loopback address (e.g. a tailnet IP or MagicDNS name) requires --admin-token or --admin-token-file")
flags.StringVar(&config.AdminToken, "admin-token", "", "admin token for a non-loopback bind, written into the plist as SUBROUTER_ADMIN_TOKEN; the plist is then chmod 0600. Prefer --admin-token-file")
flags.StringVar(&config.AdminTokenFile, "admin-token-file", "", "path to a file holding the admin token, written into the plist as SUBROUTER_ADMIN_TOKEN_FILE; keeps the secret out of the plist entirely")
flags.StringVar(&config.InstallPath, "install-path", defaultInstallPath, "subrouter binary install path")
flags.StringVar(&config.TranscriptsDir, "transcripts", "", "local transcript directory; empty disables transcript recording")
flags.StringVar(&config.LogDir, "log-dir", filepath.Join(home, "Library", "Logs"), "daemon log directory")
Expand All @@ -78,6 +82,20 @@ func installDaemonWithConfig(config daemonConfig, home string, runner commandRun
if err := validateDaemonConfig(config); err != nil {
return err
}
// The plist must carry the absolute path: launchd resolves relative paths
// against WorkingDirectory, not the installing shell's cwd.
resolvedTokenFile, err := resolveAdminTokenFile(config.AdminTokenFile)
if err != nil {
return err
}
if resolvedTokenFile != "" {
config.AdminTokenFile = resolvedTokenFile
if info, statErr := os.Stat(resolvedTokenFile); statErr == nil {
if warning := adminTokenFileWarning(resolvedTokenFile, info.Mode()); warning != "" {
fmt.Fprintln(os.Stderr, warning)
}
}
}
plist, err := launchAgentPlist(config, home)
if err != nil {
return err
Expand Down Expand Up @@ -116,7 +134,7 @@ func installDaemonWithConfig(config daemonConfig, home string, runner commandRun
}

plistPath := launchAgentPath(home, config.Label)
if err := os.WriteFile(plistPath, []byte(plist), 0o644); err != nil {
if err := os.WriteFile(plistPath, []byte(plist), launchAgentMode(config)); err != nil {
return err
}
if config.Start {
Expand All @@ -139,19 +157,94 @@ func installDaemonWithConfig(config daemonConfig, home string, runner commandRun
return nil
}

// launchAgentMode mirrors systemdDefaultsMode: a plist carrying the admin token
// inline is readable by every local user at the default 0644, which would hand
// the credential to exactly the audience the token exists to gate. --admin-token-file
// keeps the secret in a separate file, so the plist itself stays 0644.
// resolveAdminTokenFile turns --admin-token-file into the absolute path the
// plist should carry, and proves the daemon will be able to read it.
//
// Both halves have to happen at install time rather than at boot. The plist
// sets KeepAlive, so a token file the daemon cannot read is not one clear
// failure: serve exits, launchd restarts it, and the only signal an operator
// gets is churn in subrouter.err.log. And a relative path would be resolved
// against the LaunchAgent's WorkingDirectory rather than the shell the
// installer ran in, so it would quietly find a different file, or none.
//
// The readability check runs through the same readSecretFile the daemon uses,
// so "install succeeded" implies "serve can start".
func resolveAdminTokenFile(path string) (string, error) {
trimmed := strings.TrimSpace(path)
if trimmed == "" {
return "", nil
}
absolute, err := filepath.Abs(trimmed)
if err != nil {
return "", fmt.Errorf("admin-token-file %q: %w", trimmed, err)
}
info, err := os.Stat(absolute)
if err != nil {
return "", fmt.Errorf("admin-token-file %s: %w", absolute, err)
}
if info.IsDir() {
return "", fmt.Errorf("admin-token-file %s is a directory", absolute)
}
if _, err := readSecretFile(absolute, "admin-token-file"); err != nil {
return "", err
}
return absolute, nil
}

// adminTokenFileWarning reports a token file other local users can read. That
// is not fatal -- the operator may have deliberate reasons -- but it defeats
// the only thing --admin-token-file buys over --admin-token, so it should not
// pass silently.
func adminTokenFileWarning(path string, mode os.FileMode) string {
if mode.Perm()&0o077 == 0 {
return ""
}
return fmt.Sprintf(
"warning: %s is mode %04o, so other local users can read the admin token; chmod 600 it",
path, mode.Perm(),
)
}

func launchAgentMode(config daemonConfig) os.FileMode {
if strings.TrimSpace(config.AdminToken) != "" {
return 0o600
}
return 0o644
}

func validateDaemonConfig(config daemonConfig) error {
if strings.TrimSpace(config.Label) == "" {
return errors.New("label is required")
}
if strings.TrimSpace(config.Addr) == "" {
return errors.New("addr is required")
}
host, _, err := net.SplitHostPort(config.Addr)
if err != nil {
if _, _, err := net.SplitHostPort(config.Addr); err != nil {
return fmt.Errorf("addr must include host and port: %w", err)
}
if host != "127.0.0.1" && host != "localhost" && host != "::1" {
return fmt.Errorf("install-daemon only supports localhost addresses, got %q", config.Addr)
// A non-loopback bind is allowed — it is what tailnet sharing needs — but it
// carries the same exposure `serve` refuses to arm silently: every host the
// ACLs admit reaches the admin surface (accounts, transcripts,
// account-import, drain). Reuse serve's predicate rather than a second
// spelling of "is this loopback", so the installed daemon can never be armed
// in a state serve itself would reject at startup.
if bindAddrRequiresAdminToken(config.Addr) &&
strings.TrimSpace(config.AdminToken) == "" &&
strings.TrimSpace(config.AdminTokenFile) == "" {
return fmt.Errorf(
"refusing to install a daemon bound to %s without an admin token: every host that can reach this address would get the admin endpoints (accounts, transcripts, account-import, drain). Pass --admin-token-file (preferred; keeps the secret out of the plist), --admin-token, or bind a loopback address such as 127.0.0.1:31415",
config.Addr,
)
}
if strings.TrimSpace(config.AdminToken) != "" && strings.TrimSpace(config.AdminTokenFile) != "" {
return errors.New("pass only one of --admin-token or --admin-token-file")
}
if _, err := resolveAdminTokenFile(config.AdminTokenFile); err != nil {
return err
}
if strings.TrimSpace(config.InstallPath) == "" {
return errors.New("install-path is required")
Expand Down Expand Up @@ -309,31 +402,39 @@ func launchAgentPlist(config daemonConfig, home string) (string, error) {
return "", err
}
data := struct {
Label string
Home string
Path string
InstallPath string
Addr string
TranscriptsDir string
HasTranscripts bool
SRSwitchInterval string
LogPath string
ErrorLogPath string
WorkingDirectory string
CloudConfigPath string
Label string
Home string
Path string
InstallPath string
Addr string
AdminToken string
HasAdminToken bool
AdminTokenFile string
HasAdminTokenFile bool
TranscriptsDir string
HasTranscripts bool
SRSwitchInterval string
LogPath string
ErrorLogPath string
WorkingDirectory string
CloudConfigPath string
}{
Label: escapeXMLString(config.Label),
Home: escapeXMLString(home),
Path: escapeXMLString(config.Path),
InstallPath: escapeXMLString(config.InstallPath),
Addr: escapeXMLString(config.Addr),
TranscriptsDir: escapeXMLString(config.TranscriptsDir),
HasTranscripts: strings.TrimSpace(config.TranscriptsDir) != "",
SRSwitchInterval: escapeXMLString(config.SRSwitchInterval),
LogPath: escapeXMLString(filepath.Join(config.LogDir, "subrouter.log")),
ErrorLogPath: escapeXMLString(filepath.Join(config.LogDir, "subrouter.err.log")),
WorkingDirectory: escapeXMLString(config.WorkingDirectory),
CloudConfigPath: escapeXMLString(cloudConfigPath),
Label: escapeXMLString(config.Label),
Home: escapeXMLString(home),
Path: escapeXMLString(config.Path),
InstallPath: escapeXMLString(config.InstallPath),
Addr: escapeXMLString(config.Addr),
AdminToken: escapeXMLString(config.AdminToken),
HasAdminToken: strings.TrimSpace(config.AdminToken) != "",
AdminTokenFile: escapeXMLString(config.AdminTokenFile),
HasAdminTokenFile: strings.TrimSpace(config.AdminTokenFile) != "",
TranscriptsDir: escapeXMLString(config.TranscriptsDir),
HasTranscripts: strings.TrimSpace(config.TranscriptsDir) != "",
SRSwitchInterval: escapeXMLString(config.SRSwitchInterval),
LogPath: escapeXMLString(filepath.Join(config.LogDir, "subrouter.log")),
ErrorLogPath: escapeXMLString(filepath.Join(config.LogDir, "subrouter.err.log")),
WorkingDirectory: escapeXMLString(config.WorkingDirectory),
CloudConfigPath: escapeXMLString(cloudConfigPath),
}

var out bytes.Buffer
Expand All @@ -359,7 +460,11 @@ var launchAgentTemplate = template.Must(template.New("launch-agent").Parse(`<?xm
<string>{{.Home}}</string>
<key>PATH</key>
<string>{{.Path}}</string>
</dict>
{{if .HasAdminTokenFile}}<key>SUBROUTER_ADMIN_TOKEN_FILE</key>
<string>{{.AdminTokenFile}}</string>
{{end}}{{if .HasAdminToken}}<key>SUBROUTER_ADMIN_TOKEN</key>
<string>{{.AdminToken}}</string>
{{end}}</dict>
<key>KeepAlive</key>
<true/>
<key>Label</key>
Expand Down
Loading