Protect local Tailscale sign-in credentials
This commit is contained in:
+41
-1
@@ -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() {
|
||||
|
||||
+4
-2
@@ -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 <loopback>/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 `<data>/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")" <loopback>/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.
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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))")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user