Skip to content
Merged
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: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,9 @@ mkdir -p ~/.config/agentcookie
# 3. Write sink.yaml
cat > ~/.config/agentcookie/sink.yaml << 'EOF'
listen:
addr: 100.x.y.z:9999 # Your Linux box's Tailscale IP
# Use your current Tailscale IP. After Tailscale re-auth, if this IP
# becomes stale, the sink auto-rebinds to the new 100.x address.
addr: 100.x.y.z:9999

peer:
hostname: your-mac.tailnet # Mac's Tailscale hostname
Expand All @@ -156,7 +158,7 @@ agentcookie pair --as sink \
```

Replace:
- `100.x.y.z` with your Linux box's Tailscale IP (`tailscale ip -4`)
- `100.x.y.z` with your current Tailscale IP (`tailscale ip -4`). If Tailscale re-auth gives the sink a new IP, the sink auto-rebinds to it on next start.
- `your-mac.tailnet` with your Mac's Tailscale hostname (`tailscale status` on either machine)
- The pairing code and URL with the values printed by the Mac source wizard

Expand Down
4 changes: 2 additions & 2 deletions docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ cp agentcookie/examples/blocklist.yaml ~/.config/agentcookie/blocklist.yaml
```

Edit `source.yaml`:
- `sink.url`: the sink's tailnet URL, e.g. `http://my-mac-mini.tailnet.ts.net:9999/sync`
- `sink.url`: the sink's tailnet URL. **Use a hostname** (e.g., `http://my-mac-mini.tailnet.ts.net:9999/sync` or the short MagicDNS name `http://my-mac-mini:9999/sync`) rather than a frozen 100.x IP. The source resolves the hostname via `tailscale status --json` at sync time, preferring Online peers, so sync keeps working after the sink's Tailscale re-auth assigns a new IP.
- `peer.hostname`: the sink's tailnet hostname

Edit `blocklist.yaml` or run `agentcookie accounts off <domain>` for sites you do not want to sync. Empty blocklist means sync everything. For a stricter agent-runtime setup, set `policy: allowlist` in `blocklist.yaml` and list only the exact hosts/subdomains you want to sync; all other cookie hosts are dropped on both source and sink.
Expand All @@ -40,7 +40,7 @@ cp agentcookie/examples/blocklist.yaml ~/.config/agentcookie/blocklist.yaml
```

Edit `sink.yaml`:
- `listen.addr`: the sink's tailnet IP + port, e.g. `100.x.y.z:9999`
- `listen.addr`: the sink's current tailnet IP + port, e.g. `100.x.y.z:9999`. After Tailscale re-auth, if this IP becomes stale, the sink auto-rebinds to the new 100.x address on startup.
- `peer.hostname`: the source's tailnet hostname
- `cdp.enabled: true` if you want cookies to land in a running Chrome immediately (recommended)

Expand Down
9 changes: 5 additions & 4 deletions examples/sink.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,12 @@
# See examples/blocklist.yaml for the sync-all configuration.

listen:
# Address the sink's /sync HTTP listener binds to. MUST be your
# Tailscale 100.x address. The sink refuses to start on 0.0.0.0 or
# any non-tailnet routable address.
# Address the sink's /sync HTTP listener binds to. Use your current
# Tailscale 100.x address. After Tailscale re-auth, if this IP becomes
# stale (no longer on any local interface), the sink auto-rebinds to
# the new 100.x address on startup.
#
# Find your Tailscale IP: tailscale ip -4
# Find your current Tailscale IP: tailscale ip -4
addr: 100.x.y.z:9999

peer:
Expand Down
2 changes: 2 additions & 0 deletions examples/source.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@

sink:
# URL of the sink machine's /sync endpoint over your tailnet.
# Use the MagicDNS hostname (not a 100.x IP) so the source follows
# the sink after Tailscale re-auth gives it a new IP.
url: http://my-mac-mini.tailnet.ts.net:9999/sync

chrome:
Expand Down
4 changes: 2 additions & 2 deletions internal/cli/httpserver/httpserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,8 @@ func Defaults(p Profile) Settings {
case SinkSync:
return Settings{
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 60 * time.Second,
WriteTimeout: 60 * time.Second,
ReadTimeout: 5 * time.Minute, // Match SyncClient; first full sync (16k+ cookies) needs time over Tailscale
WriteTimeout: 5 * time.Minute, // Response can also be slow on congested links
Comment thread
greptile-apps[bot] marked this conversation as resolved.
IdleTimeout: 120 * time.Second,
MaxHeaderBytes: 16 * 1024,
MaxBodyBytes: 256 * 1024 * 1024,
Expand Down
10 changes: 6 additions & 4 deletions internal/cli/httpserver/httpserver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,13 @@ func TestConfigure_AppliesTimeouts(t *testing.T) {
if srv.ReadHeaderTimeout != 5*time.Second {
t.Errorf("ReadHeaderTimeout: got %v want 5s", srv.ReadHeaderTimeout)
}
if srv.ReadTimeout != 60*time.Second {
t.Errorf("ReadTimeout: got %v want 60s", srv.ReadTimeout)
// ReadTimeout and WriteTimeout are 5 minutes to match SyncClient and
// accommodate large first syncs (16k+ cookies) over slow Tailscale links.
if srv.ReadTimeout != 5*time.Minute {
t.Errorf("ReadTimeout: got %v want 5m", srv.ReadTimeout)
}
if srv.WriteTimeout != 60*time.Second {
t.Errorf("WriteTimeout: got %v want 60s", srv.WriteTimeout)
if srv.WriteTimeout != 5*time.Minute {
t.Errorf("WriteTimeout: got %v want 5m", srv.WriteTimeout)
}
if srv.MaxHeaderBytes != 16*1024 {
t.Errorf("MaxHeaderBytes: got %d want 16384", srv.MaxHeaderBytes)
Expand Down
88 changes: 88 additions & 0 deletions internal/cli/sink.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import (
"github.com/mvanhorn/agentcookie/internal/sinkpush"
"github.com/mvanhorn/agentcookie/internal/state"
"github.com/mvanhorn/agentcookie/internal/transport"
"github.com/mvanhorn/agentcookie/internal/tsclient"
)

var (
Expand Down Expand Up @@ -73,6 +74,13 @@ func runSink(cmd *cobra.Command, args []string) error {
return fmt.Errorf("sink listen %q: %w", cfg.Listen.Addr, err)
}

// Auto-rebind if configured listen.addr IP is stale (not on any local
// interface). This handles the case where Tailscale re-auth gave the
// machine a new 100.x IP but sink.yaml still has the old frozen IP.
// Keep the configured port, just swap the IP. Localhost bindings are
// excluded from rebind since they don't depend on Tailscale state.
cfg.Listen.Addr = maybeRebindListenAddr(cmd.Context(), cfg.Listen.Addr)

// Linux sink: require Tailscale 100.x in production. Localhost is
// allowed for tests but is not the documented Linux sink path.
if config.IsLinux() {
Expand Down Expand Up @@ -758,3 +766,83 @@ func unionCookiesWithExtraProfiles(envelopeCookies []chrome.Cookie, profileDir s
func cookieDedupeKey(c chrome.Cookie) string {
return c.HostKey + "\x00" + c.Name + "\x00" + c.Path
}

// maybeRebindListenAddr checks if the configured listen address IP is currently
// bound on a local interface. If not (e.g., Tailscale re-auth gave the machine
// a new 100.x IP), it rebinds to the current tailnet IP while keeping the
// configured port.
//
// This allows sink.yaml to have a frozen Tailscale IP that becomes stale after
// re-auth, without requiring manual edit. The sink will automatically find and
// bind to the new IP.
//
// Returns the original addr unchanged if:
// - The IP is currently bound locally
// - The IP is localhost/loopback (not subject to Tailscale churn)
// - RequireTailnetIP fails (Tailscale not running)
// - The address parsing fails
func maybeRebindListenAddr(ctx context.Context, addr string) string {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return addr
}

// Localhost bindings don't need rebind
switch host {
case "127.0.0.1", "::1", "localhost":
return addr
}

// Check if the configured IP is on a local interface
if isIPBoundLocally(host) {
return addr
}

// IP is not bound locally. If it's a tailnet IP, try to get the current one.
if !tsclient.IsTailnetIP(host) {
// Not a tailnet IP, can't auto-rebind
return addr
}

// Get the current tailnet IP
newIP, err := tsclient.RequireTailnetIP(ctx)
if err != nil {
// Tailscale not running or no IP available
fmt.Fprintf(os.Stderr, "agentcookie sink: configured listen IP %s not on any local interface, but cannot get current tailnet IP: %v\n", host, err)
return addr
}

if newIP == host {
// Same IP, no rebind needed (shouldn't happen since isIPBoundLocally failed)
return addr
}

newAddr := net.JoinHostPort(newIP, port)
fmt.Fprintf(os.Stderr, "agentcookie sink: configured listen IP %s is stale (not on any local interface); rebinding to current tailnet IP %s\n", host, newIP)
return newAddr
}

// isIPBoundLocally returns true if the given IP address is currently assigned
// to a local network interface. Used to detect stale Tailscale IPs after re-auth.
func isIPBoundLocally(ipStr string) bool {
targetIP := net.ParseIP(ipStr)
if targetIP == nil {
return false
}

addrs, err := net.InterfaceAddrs()
if err != nil {
return false
}

for _, a := range addrs {
ipnet, ok := a.(*net.IPNet)
if !ok {
continue
}
if ipnet.IP.Equal(targetIP) {
return true
}
}
return false
}
61 changes: 61 additions & 0 deletions internal/cli/sink_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -729,3 +729,64 @@ func TestSinkHandler_EmptyEnvelopeStillUnionsExtraProfiles(t *testing.T) {
t.Errorf("empty envelope on Linux should return empty after union; got %d", len(result))
}
}

// TestIsIPBoundLocally verifies the local IP detection used by sink rebind.
func TestIsIPBoundLocally(t *testing.T) {
// 127.0.0.1 should always be bound locally on any system.
if !isIPBoundLocally("127.0.0.1") {
t.Error("127.0.0.1 should be bound locally")
}

// An arbitrary private IP that's unlikely to be bound.
if isIPBoundLocally("10.99.99.99") {
t.Error("10.99.99.99 should not be bound locally (unless coincidentally assigned)")
}

// Invalid IP should return false.
if isIPBoundLocally("not-an-ip") {
t.Error("invalid IP should return false")
}

// Empty string should return false.
if isIPBoundLocally("") {
t.Error("empty string should return false")
}
}

// TestMaybeRebindListenAddr_LocalhostPassthrough verifies that localhost
// bindings are not subject to rebind.
func TestMaybeRebindListenAddr_LocalhostPassthrough(t *testing.T) {
cases := []string{
"127.0.0.1:9999",
"localhost:9999",
}
for _, addr := range cases {
t.Run(addr, func(t *testing.T) {
got := maybeRebindListenAddr(context.Background(), addr)
if got != addr {
t.Errorf("localhost should pass through unchanged: got %q, want %q", got, addr)
}
})
}
}

// TestMaybeRebindListenAddr_BoundIPPassthrough verifies that IPs currently
// bound locally are not subject to rebind.
func TestMaybeRebindListenAddr_BoundIPPassthrough(t *testing.T) {
// 127.0.0.1 is always bound locally.
addr := "127.0.0.1:9999"
got := maybeRebindListenAddr(context.Background(), addr)
if got != addr {
t.Errorf("bound IP should pass through unchanged: got %q, want %q", got, addr)
}
}

// TestMaybeRebindListenAddr_ParseError verifies that unparseable addresses
// are returned unchanged.
func TestMaybeRebindListenAddr_ParseError(t *testing.T) {
addr := "no-port-here"
got := maybeRebindListenAddr(context.Background(), addr)
if got != addr {
t.Errorf("unparseable address should pass through unchanged: got %q, want %q", got, addr)
}
}
43 changes: 41 additions & 2 deletions internal/cli/source.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
"github.com/mvanhorn/agentcookie/internal/secretsbus"
"github.com/mvanhorn/agentcookie/internal/state"
"github.com/mvanhorn/agentcookie/internal/transport"
"github.com/mvanhorn/agentcookie/internal/tsclient"
"github.com/mvanhorn/agentcookie/internal/watcher"
)

Expand All @@ -33,6 +34,19 @@ var (
sourceSkipDBSC bool
)

// resolveSinkURL is the sink URL resolver used by pushOnce. Production
// wires it to tsclient.ResolveSinkURL; tests can override it to inject
// specific resolution behaviors (e.g., ErrAmbiguousPeer).
var resolveSinkURL = tsclient.ResolveSinkURL

// SetResolveSinkURLForTesting replaces resolveSinkURL with the given
// function and returns a restore func. Test-only seam.
func SetResolveSinkURLForTesting(f func(ctx context.Context, rawURL string) (string, error)) func() {
prev := resolveSinkURL
resolveSinkURL = f
return func() { resolveSinkURL = prev }
}

// dbscSummary carries the DBSC-suspect tally from one push back to the caller
// so it can be recorded in SourceState for `doctor` / `status`.
type dbscSummary struct {
Expand Down Expand Up @@ -378,6 +392,31 @@ func pushOnce(
return 0, dbsc, fmt.Errorf("seal payload: %w", err)
}

// Resolve sink URL hostname to IP via Tailscale if needed. This allows
// sink.url to use MagicDNS hostnames (e.g., http://grok-bot:9999/sync)
// instead of frozen 100.x IPs that break after Tailscale re-auth.
//
// Fail-closed errors (ErrAmbiguousPeer) abort the push — falling back to
// the hostname URL would hand selection to MagicDNS and undo fail-closed.
// Soft failures (Tailscale CLI missing, peer not found, peer offline) fall
// back to the original URL so HTTP can report the connection error.
sinkURL := cfg.Sink.URL
if resolved, resolveErr := resolveSinkURL(ctx, sinkURL); resolveErr != nil {
if errors.Is(resolveErr, tsclient.ErrAmbiguousPeer) {
return 0, dbsc, fmt.Errorf("resolve sink URL: %w", resolveErr)
}
// Soft failure: Tailscale not available, peer offline, etc.
// Fall back to the original URL and let HTTP report the error.
if verbose {
fmt.Fprintf(os.Stderr, "agentcookie source: sink URL resolution failed (%v); using original %s\n", resolveErr, sinkURL)
}
} else if resolved != sinkURL {
if verbose {
fmt.Fprintf(os.Stderr, "agentcookie source: resolved sink URL %s -> %s\n", sinkURL, resolved)
}
sinkURL = resolved
}

// Bound the POST by the SyncClient profile's timeout (5 minutes
// in v0.12) so a heavy LocalStorage / IndexedDB payload over a
// slow tailnet link does not get cut off at the pre-v0.12 30s
Expand All @@ -386,14 +425,14 @@ func pushOnce(
// cancellation.
postCtx, cancel := context.WithTimeout(ctx, httpserver.Defaults(httpserver.SyncClient).ClientTimeout)
defer cancel()
req, err := http.NewRequestWithContext(postCtx, "POST", cfg.Sink.URL, bytes.NewReader(sealed))
req, err := http.NewRequestWithContext(postCtx, "POST", sinkURL, bytes.NewReader(sealed))
if err != nil {
return 0, dbsc, fmt.Errorf("new request: %w", err)
}
req.Header.Set("Content-Type", "application/octet-stream")
resp, err := httpserver.Client(httpserver.SyncClient).Do(req)
if err != nil {
return 0, dbsc, fmt.Errorf("POST to sink %s: %w", cfg.Sink.URL, err)
return 0, dbsc, fmt.Errorf("POST to sink %s: %w", sinkURL, err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
Expand Down
Loading
Loading