From 0d3ed1fdd35ba28b2f7e826e8dd7b992fe7c7936 Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Wed, 21 Oct 2026 07:11:55 -0700 Subject: [PATCH] Protect local Tailscale sign-in credentials --- cmd/byre/serve.go | 42 ++++++++++++++++++++++++++++++++++- docs/operations.md | 6 +++-- internal/server/server.go | 12 ++++++++-- mac/Sources/Byre/Server.swift | 6 ++++- 4 files changed, 60 insertions(+), 6 deletions(-) diff --git a/cmd/byre/serve.go b/cmd/byre/serve.go index d8a6837..1b2eed6 100644 --- a/cmd/byre/serve.go +++ b/cmd/byre/serve.go @@ -2,6 +2,7 @@ package main import ( "context" + "crypto/rand" "crypto/tls" "errors" "flag" @@ -50,6 +51,45 @@ func serveCmd(args []string) { fatal("--listen must be a loopback address", "addr", *listenAddr) } + // Protect local credentials before tsnet can write state or a login URL. + if err := os.MkdirAll(*dataDir, 0o700); err != nil { + fatal("create data directory", "err", err) + } + if err := os.Chmod(*dataDir, 0o700); err != nil { + fatal("protect data directory", "err", err) + } + // Keep ownership until shutdown; never unlink the lock file, since a new + // inode would let another process lock the same data directory concurrently. + lock, err := os.OpenFile(filepath.Join(*dataDir, "serve.lock"), os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + fatal("open service lock", "err", err) + } + defer lock.Close() + if err := syscall.Flock(int(lock.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { + fatal("acquire service lock", "data", *dataDir, "err", err) + } + + token := rand.Text() + if err := os.WriteFile(filepath.Join(*dataDir, "loopback-token"), []byte(token), 0o600); err != nil { + fatal("write loopback credential", "err", err) + } + home, err := os.UserHomeDir() + if err != nil { + fatal("home directory", "err", err) + } + logDir := filepath.Join(home, "Library", "Logs") + if err := os.MkdirAll(logDir, 0o755); err != nil { + fatal("create log directory", "err", err) + } + logFile, err := os.OpenFile(filepath.Join(logDir, "byre.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) + if err != nil { + fatal("open service log", "err", err) + } + if err := logFile.Chmod(0o600); err != nil { + fatal("protect service log", "err", err) + } + logFile.Close() + ts := &tsnet.Server{ Dir: filepath.Join(*dataDir, "tsnet"), Hostname: *hostname, @@ -75,7 +115,7 @@ func serveCmd(args []string) { go updateDaily(ctx) base := func(net.Listener) context.Context { return ctx } - loopback := &http.Server{Addr: *listenAddr, Handler: srv.LoopbackHandler(), BaseContext: base} + loopback := &http.Server{Addr: *listenAddr, Handler: srv.LoopbackHandler(token), BaseContext: base} tailnet := &http.Server{Handler: srv.Handler(), BaseContext: base} go func() { diff --git a/docs/operations.md b/docs/operations.md index 68e9e44..3e85f9d 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -16,7 +16,7 @@ byre runs nothing in a new worktree. Installing dependencies or copying untracke The service always runs from the app: `/Applications/Byre.app/Contents/Helpers/byre install`, which the app's Server tab and connection screen run. `byre install` takes `PATH` from the shell it is run in, since the service's `PATH` must find `claude`, `codex`, `agy`, `git`, `curl` and `npx`. When the service is already loaded, `install` hands `launchctl bootout` and `bootstrap` to a detached `sh` and exits: `bootout` kills every process in the service's process groups, which includes `byre install` itself when an agent byre runs is the one running it. -`byre serve` exits when its executable is replaced (logging `executable replaced, exiting`) and launchd starts the new one. A missing path counts as not yet replaced, so the moment in `install.sh` between removing the old bundle and moving the new one in doesn't trigger it. Logs go to `~/Library/Logs/byre.log`; `launchctl print gui/$(id -u)/run.fc.byre` shows the service. +`byre serve` exits when its executable is replaced (logging `executable replaced, exiting`) and launchd starts the new one. A missing path counts as not yet replaced, so the moment in `install.sh` between removing the old bundle and moving the new one in doesn't trigger it. The service log is owner-only (0600), including existing logs: Tailscale can put a sign-in URL there. Logs go to `~/Library/Logs/byre.log`; `launchctl print gui/$(id -u)/run.fc.byre` shows the service. ## Tailnet @@ -24,7 +24,9 @@ byre runs its own tailnet node with `tsnet`; it doesn't use the Tailscale app. T Tailscale identifies the connecting Mac, including requests from a web page opened there; network identity alone doesn't establish the user's intent. Cross-origin browser requests must not be allowed to change state through that identity. Native app and CLI access remains intentional. -A new node needs signing in: `curl /api/tailnet` (`byre check` prints the loopback address) shows `NeedsLogin` and the `auth_url`, and the app on the same Mac shows it as **Sign in to Tailscale**. The tailnet must have MagicDNS and HTTPS certificates turned on (the DNS page of Tailscale's admin console); without certificates byre logs `tailnet has no https certificates` and serves only the loopback address until it is restarted. +The local status endpoint requires the bearer credential in `/loopback-token`, regenerated on service startup. Loopback is reachable by other OS accounts, so it cannot protect the node’s sign-in URL by itself. The app reads the credential from the default data directory. + +A new node needs signing in: `curl -H "Authorization: Bearer $(cat "$HOME/Library/Application Support/byre/loopback-token")" /api/tailnet` (`byre check` prints the loopback address) shows `NeedsLogin` and the `auth_url`, and the app on the same Mac shows it as **Sign in to Tailscale**. The tailnet must have MagicDNS and HTTPS certificates turned on (the DNS page of Tailscale's admin console); without certificates byre logs `tailnet has no https certificates` and serves only the loopback address until it is restarted. Each preview gets its own port on byre's node rather than a path under byre's origin, since path-prefix proxying breaks apps that use absolute paths. Previews use the same user-only tailnet trust boundary as the API; tagged nodes are excluded. diff --git a/internal/server/server.go b/internal/server/server.go index 69ade9d..3c09e36 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -2,6 +2,7 @@ package server import ( "context" + "crypto/subtle" "encoding/json" "errors" "fmt" @@ -130,10 +131,17 @@ func (s *Server) Handler() http.Handler { return http.NewCrossOriginProtection().Handler(mux) } -func (s *Server) LoopbackHandler() http.Handler { +func (s *Server) LoopbackHandler(token string) http.Handler { mux := http.NewServeMux() mux.HandleFunc("GET /api/tailnet", s.GetTailnet) - return mux + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if subtle.ConstantTimeCompare([]byte(r.Header.Get("Authorization")), []byte("Bearer "+token)) != 1 { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + w.Header().Set("Cache-Control", "no-store") + mux.ServeHTTP(w, r) + }) } func (s *Server) GetTailnet(w http.ResponseWriter, r *http.Request) { diff --git a/mac/Sources/Byre/Server.swift b/mac/Sources/Byre/Server.swift index 940743f..3a93ae6 100644 --- a/mac/Sources/Byre/Server.swift +++ b/mac/Sources/Byre/Server.swift @@ -331,7 +331,11 @@ enum Server { } static func tailnet(_ loopback: URL) async throws -> TailnetInfo { - let (data, response) = try await URLSession.shared.data(from: loopback.appending(path: "api/tailnet")) + let credential = URL.applicationSupportDirectory.appending(path: "byre/loopback-token") + let token = try String(contentsOf: credential, encoding: .utf8) + var request = URLRequest(url: loopback.appending(path: "api/tailnet")) + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + let (data, response) = try await URLSession.shared.data(for: request) guard (response as? HTTPURLResponse)?.statusCode == 200 else { throw ProcessFailure(errorDescription: "\(loopback.absoluteString)/api/tailnet: \(String(decoding: data, as: UTF8.self))") }