Tailscale-native MCP gateway with identity-based access control, audit logging, and session recording
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Turnscale — Tailscale-native MCP gateway

Single Go binary that proxies MCP requests to multiple backends with
identity-based access control, audit logging, and session recording.

Uses Tailscale tsnet for automatic TLS and WhoIs-based identity.
Policy via tailnet ACL grants or YAML fallback. Per-tool deny globs,
session recording, and a web dashboard with request charts and
server management.

Scott Lanoue 9f7679b4

+6198
+27
.gitea/workflows/ci.yml
··· 1 + name: CI 2 + 3 + on: 4 + push: 5 + branches: [main] 6 + pull_request: 7 + branches: [main] 8 + 9 + jobs: 10 + build-and-test: 11 + runs-on: general 12 + container: 13 + image: golang:1.23 14 + steps: 15 + - name: Checkout 16 + run: | 17 + git clone --depth 1 --branch ${GITHUB_REF_NAME:-main} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git . 18 + git checkout ${GITHUB_SHA} 19 + 20 + - name: Build 21 + run: go build ./... 22 + 23 + - name: Test 24 + run: go test -race -count=1 ./... 25 + 26 + - name: Vet 27 + run: go vet ./...
+5
.gitignore
··· 1 + /turnscale 2 + *.db 3 + *.db-wal 4 + *.db-shm 5 + gateway.yaml
+23
.tangled/workflows/ci.yml
··· 1 + when: 2 + - event: ["push", "pull_request"] 3 + branch: main 4 + 5 + engine: nixery 6 + 7 + dependencies: 8 + nixpkgs: 9 + - go 10 + - gcc 11 + 12 + environment: 13 + CGO_ENABLED: 1 14 + 15 + steps: 16 + - name: build 17 + command: go build ./... 18 + 19 + - name: test 20 + command: go test -race -count=1 ./... 21 + 22 + - name: vet 23 + command: go vet ./...
+48
CLAUDE.md
··· 1 + # turnscale 2 + 3 + Tailscale-native MCP gateway with identity-based access control and SQLite audit logging. 4 + 5 + ## Architecture 6 + 7 + - **Language**: Go 8 + - **Transport**: Streamable HTTP (proxies JSON-RPC POST and SSE GET) 9 + - **Auth**: Tailscale identity via tsnet (zero credential management) 10 + - **Access Control**: YAML policy engine (user login + node tags, first-match-wins) 11 + - **Audit**: SQLite with server-rendered HTML UI at `/ui/audit` 12 + - **Dashboard**: Web UI at `/ui/` showing identity, server health, policies, audit 13 + 14 + ## Project Layout 15 + 16 + ``` 17 + cmd/turnscale/main.go # Entry point, tsnet setup, signal handling 18 + internal/config/ # YAML config parsing 19 + internal/identity/ # Tailscale WhoIs identity extraction 20 + internal/policy/ # Access control evaluation 21 + internal/audit/ # SQLite audit logger + web UI 22 + internal/gateway/ # HTTP handler, JSON-RPC proxy, SSE proxy 23 + internal/ui/ # Web dashboard (server health, policies, audit) 24 + ``` 25 + 26 + ## Build & Test 27 + 28 + ```bash 29 + go build ./cmd/turnscale 30 + go test ./... 31 + ``` 32 + 33 + ## Config 34 + 35 + `gateway.yaml` — defines servers (upstream MCP backends) and policies (ACL rules). 36 + Policies are evaluated top-to-bottom, first match wins. Default is deny. 37 + 38 + ## Key Dependencies 39 + 40 + - `tailscale.com/tsnet` — embedded Tailscale node 41 + - `modernc.org/sqlite` — pure-Go SQLite (no CGO) 42 + - `gopkg.in/yaml.v3` — config parsing 43 + 44 + ## Conventions 45 + 46 + - Conventional commits: `feat:`, `fix:`, `refactor:`, `test:`, `chore:` 47 + - Tests live next to their code (`*_test.go`) 48 + - No CGO — pure Go build for easy cross-compilation
+12
Dockerfile
··· 1 + FROM golang:1.23-alpine AS build 2 + WORKDIR /src 3 + COPY go.mod go.sum ./ 4 + RUN go mod download 5 + COPY . . 6 + RUN CGO_ENABLED=0 go build -o /turnscale ./cmd/turnscale 7 + 8 + FROM alpine:3.20 9 + RUN apk add --no-cache ca-certificates 10 + COPY --from=build /turnscale /usr/local/bin/turnscale 11 + ENTRYPOINT ["turnscale"] 12 + CMD ["-config", "/etc/turnscale/gateway.yaml"]
+21
LICENSE
··· 1 + MIT License 2 + 3 + Copyright (c) 2026 Scott Lanoue 4 + 5 + Permission is hereby granted, free of charge, to any person obtaining a copy 6 + of this software and associated documentation files (the "Software"), to deal 7 + in the Software without restriction, including without limitation the rights 8 + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 + copies of the Software, and to permit persons to whom the Software is 10 + furnished to do so, subject to the following conditions: 11 + 12 + The above copyright notice and this permission notice shall be included in all 13 + copies or substantial portions of the Software. 14 + 15 + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 + SOFTWARE.
+173
README.md
··· 1 + # Turnscale 2 + 3 + [![tangled.sh](https://img.shields.io/badge/tangled.sh-scottlanoue.com%2Fturnscale-blue)](https://tangled.sh/scottlanoue.com/turnscale) 4 + 5 + MCP gateway for Tailscale. Proxies [Model Context Protocol](https://modelcontextprotocol.io) requests to multiple backends, using Tailscale identity for access control. Single Go binary, no credentials to manage. 6 + 7 + - Identity from Tailscale `WhoIs` — no API keys or OAuth 8 + - Policy via tailnet ACL grants (or YAML fallback) 9 + - Per-tool deny globs (e.g. allow Gitea but deny `delete_*`) 10 + - Audit log and optional session recording 11 + - Dashboard with server health, request chart, tool discovery 12 + - Single binary, no CGO 13 + 14 + ![Turnscale dashboard](demo.png) 15 + 16 + ## Quick Start 17 + 18 + ```bash 19 + go build ./cmd/turnscale 20 + cp gateway.example.yaml gateway.yaml # edit with your servers + policies 21 + ./turnscale -config gateway.yaml 22 + ``` 23 + 24 + The gateway joins your tailnet as a node. Point MCP clients at `https://{hostname}.{tailnet}.ts.net/{server}/mcp`. 25 + 26 + ## Configuration 27 + 28 + ```yaml 29 + hostname: "mcp" 30 + tailnet: "your-tailnet.ts.net" 31 + state_dir: "~/.local/share/turnscale" 32 + 33 + servers: 34 + github: 35 + url: "http://localhost:8091/mcp" 36 + transport: "streamable-http" 37 + slack: 38 + url: "http://localhost:8092/mcp" 39 + transport: "streamable-http" 40 + 41 + policies: 42 + - name: "admin" 43 + match: 44 + identity: ["you@github"] 45 + allow: ["*"] 46 + 47 + - name: "ai-agents" 48 + match: 49 + tags: ["tag:ai-agent"] 50 + allow: ["github", "slack"] 51 + deny_tools: ["mcp__github__delete_*"] 52 + 53 + - name: "default-deny" 54 + match: 55 + identity: ["*"] 56 + deny: ["*"] 57 + ``` 58 + 59 + Servers can also be added, edited, and removed from the dashboard UI. 60 + 61 + ## Connecting Clients 62 + 63 + ### Claude Code 64 + 65 + Add to `~/.claude/settings.local.json`: 66 + 67 + ```json 68 + { 69 + "mcpServers": { 70 + "github": { 71 + "type": "http", 72 + "url": "https://mcp.your-tailnet.ts.net/github/mcp" 73 + }, 74 + "slack": { 75 + "type": "http", 76 + "url": "https://mcp.your-tailnet.ts.net/slack/mcp" 77 + } 78 + } 79 + } 80 + ``` 81 + 82 + ### Any MCP Client (Streamable HTTP) 83 + 84 + ``` 85 + POST https://mcp.your-tailnet.ts.net/{server}/mcp # JSON-RPC 86 + GET https://mcp.your-tailnet.ts.net/{server}/mcp # SSE stream 87 + DELETE https://mcp.your-tailnet.ts.net/{server}/mcp # Session termination 88 + ``` 89 + 90 + No auth headers — identity comes from Tailscale. 91 + 92 + ## Tailscale ACL Grants 93 + 94 + The recommended way to manage access. Add grants to your tailnet policy — the gateway reads them from `WhoIs().CapMap` at request time: 95 + 96 + ```json 97 + { 98 + "grants": [ 99 + { 100 + "src": ["autogroup:member"], 101 + "dst": ["tag:server"], 102 + "app": { 103 + "your-tailnet.ts.net/cap/mcp": [{ 104 + "servers": ["*"], 105 + "admin": true 106 + }] 107 + } 108 + }, 109 + { 110 + "src": ["tag:ai-agent"], 111 + "dst": ["tag:server"], 112 + "app": { 113 + "your-tailnet.ts.net/cap/mcp": [{ 114 + "servers": ["github", "slack"], 115 + "denyTools": ["mcp__github__delete_*"], 116 + "record": true 117 + }] 118 + } 119 + } 120 + ] 121 + } 122 + ``` 123 + 124 + | Field | Description | 125 + |-------|-------------| 126 + | `servers` | Server names or `["*"]` for all | 127 + | `denyTools` | Glob patterns for denied tools | 128 + | `admin` | Access to audit logs, server management, session recordings | 129 + | `record` | Capture full request/response bodies for this caller | 130 + 131 + Grants take priority over YAML policies. Multiple grants per caller are unioned. 132 + 133 + ## Web UI 134 + 135 + All pages live under `/ui/`: 136 + 137 + - `/ui/` — dashboard: request chart, server health, policies, recent activity 138 + - `/ui/audit` — full audit log with filtering by caller/server/tool/status 139 + - `/ui/session/{id}` — session recording viewer 140 + 141 + ## Architecture 142 + 143 + ``` 144 + ┌──────────────┐ ┌──────────────────┐ ┌─────────────┐ 145 + │ Claude Code │────>│ Turnscale │────>│ github-mcp │ 146 + │ │ │ (tsnet node) │────>│ slack-mcp │ 147 + ├──────────────┤ │ │────>│ jira-mcp │ 148 + │ AI Agents │────>│ - WhoIs identity│────>│ ... │ 149 + │ (tag:agent) │ │ - ACL grants │ └─────────────┘ 150 + └──────────────┘ │ - Audit + Record│ 151 + │ - Tool discovery│ 152 + │ - Dashboard │ 153 + └──────────────────┘ 154 + ``` 155 + 156 + ## Development 157 + 158 + ```bash 159 + go build ./... # Build 160 + go test -race ./... # Test (85 tests, race detector) 161 + go test -cover ./... # Coverage 162 + go vet ./... # Static analysis 163 + ``` 164 + 165 + ## Dependencies 166 + 167 + - [`tailscale.com/tsnet`](https://pkg.go.dev/tailscale.com/tsnet) — embedded Tailscale node 168 + - [`modernc.org/sqlite`](https://pkg.go.dev/modernc.org/sqlite) — pure-Go SQLite (no CGO) 169 + - [`gopkg.in/yaml.v3`](https://pkg.go.dev/gopkg.in/yaml.v3) — config parsing 170 + 171 + ## License 172 + 173 + [MIT](LICENSE)
+219
cmd/turnscale/main.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "flag" 6 + "fmt" 7 + "log/slog" 8 + "math/rand" 9 + "net/http" 10 + "os" 11 + "os/signal" 12 + "syscall" 13 + "time" 14 + 15 + "tailscale.com/tsnet" 16 + 17 + "github.com/slanos/turnscale/internal/audit" 18 + "github.com/slanos/turnscale/internal/config" 19 + "github.com/slanos/turnscale/internal/gateway" 20 + "github.com/slanos/turnscale/internal/identity" 21 + "github.com/slanos/turnscale/internal/policy" 22 + ) 23 + 24 + func main() { 25 + configPath := flag.String("config", "gateway.yaml", "path to gateway config file") 26 + demo := flag.Bool("demo", false, "seed database with demo data for showcase") 27 + flag.Parse() 28 + 29 + slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))) 30 + 31 + cfg, err := config.Load(*configPath) 32 + if err != nil { 33 + slog.Error("failed to load config", "error", err) 34 + os.Exit(1) 35 + } 36 + slog.Info("config loaded", "hostname", cfg.Hostname, "servers", len(cfg.Servers), "policies", len(cfg.Policies)) 37 + 38 + // Initialize audit logger 39 + auditor, err := audit.NewLogger(cfg.StateDir) 40 + if err != nil { 41 + slog.Error("failed to initialize audit logger", "error", err) 42 + os.Exit(1) 43 + } 44 + defer auditor.Close() 45 + 46 + if *demo { 47 + seedDemoData(auditor) 48 + } 49 + 50 + // Start daily pruning 51 + go pruneLoop(auditor) 52 + 53 + // Set up tsnet 54 + ts := &tsnet.Server{ 55 + Hostname: cfg.Hostname, 56 + Dir: cfg.StateDir, 57 + Logf: func(format string, args ...any) { slog.Debug(fmt.Sprintf(format, args...)) }, 58 + } 59 + if key := os.Getenv("TS_AUTHKEY"); key != "" { 60 + ts.AuthKey = key 61 + } 62 + defer ts.Close() 63 + 64 + slog.Info("starting tsnet", "hostname", cfg.Hostname) 65 + ln, err := ts.ListenTLS("tcp", ":443") 66 + if err != nil { 67 + slog.Error("tsnet listen failed", "error", err) 68 + os.Exit(1) 69 + } 70 + defer ln.Close() 71 + 72 + lc, err := ts.LocalClient() 73 + if err != nil { 74 + slog.Error("tsnet local client failed", "error", err) 75 + os.Exit(1) 76 + } 77 + 78 + var ident identity.Identifier = &identity.TailscaleIdentifier{LC: lc} 79 + if *demo { 80 + ident = &identity.DemoIdentifier{Inner: ident} 81 + } 82 + pol := policy.NewEngine(cfg.Policies) 83 + gw := gateway.New(cfg, ident, pol, auditor) 84 + 85 + server := &http.Server{Handler: gw.Handler()} 86 + 87 + // HTTP→HTTPS redirect on :80 88 + httpLn, err := ts.Listen("tcp", ":80") 89 + if err != nil { 90 + slog.Warn("could not listen on :80 for redirect", "error", err) 91 + } else { 92 + go func() { 93 + redirect := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 94 + target := "https://" + r.Host + r.URL.RequestURI() 95 + http.Redirect(w, r, target, http.StatusMovedPermanently) 96 + })} 97 + redirect.Serve(httpLn) 98 + }() 99 + } 100 + 101 + // Graceful shutdown 102 + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) 103 + defer stop() 104 + 105 + go func() { 106 + slog.Info("gateway listening", "addr", ln.Addr().String()) 107 + if err := server.Serve(ln); err != nil && err != http.ErrServerClosed { 108 + slog.Error("server error", "error", err) 109 + } 110 + }() 111 + 112 + <-ctx.Done() 113 + slog.Info("shutting down...") 114 + 115 + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 116 + defer cancel() 117 + server.Shutdown(shutdownCtx) 118 + } 119 + 120 + func pruneLoop(auditor *audit.Logger) { 121 + ticker := time.NewTicker(24 * time.Hour) 122 + defer ticker.Stop() 123 + for range ticker.C { 124 + n, err := auditor.Prune(30 * 24 * time.Hour) 125 + if err != nil { 126 + slog.Error("audit prune failed", "error", err) 127 + } else if n > 0 { 128 + slog.Info("pruned audit entries", "count", n) 129 + } 130 + } 131 + } 132 + 133 + func seedDemoData(auditor *audit.Logger) { 134 + callers := []struct { 135 + name string 136 + ip string 137 + node string 138 + tags string 139 + }{ 140 + {"alice@corp.dev", "100.64.1.10", "alice-laptop", ""}, 141 + {"bob@corp.dev", "100.64.1.20", "bob-desktop", ""}, 142 + {"claude-agent (tag:ai-agent)", "100.64.2.1", "claude-agent", "tag:ai-agent"}, 143 + {"deploy-bot (tag:ai-agent)", "100.64.2.2", "deploy-bot", "tag:ai-agent"}, 144 + } 145 + 146 + servers := []string{"gitea", "nomad", "matrix", "letta", "omada"} 147 + 148 + tools := map[string][]string{ 149 + "gitea": {"list_repos", "create_issue", "get_file_content", "create_pull_request", "merge_pull_request", "list_branches", "search_repos", "get_my_user_info", "edit_issue"}, 150 + "nomad": {"list_jobs", "get_job", "get_allocation_logs", "list_nodes", "get_cluster_leader", "stop_job", "run_job"}, 151 + "matrix": {"list_rooms", "send_message", "read_messages", "search_messages", "get_room_info"}, 152 + "letta": {"list_agents", "prompt_agent", "read_memory_block", "create_agent", "list_memory_blocks"}, 153 + "omada": {"listDevices", "listClients", "getSwitchPorts", "getInternetInfo", "listEvents"}, 154 + } 155 + 156 + methods := []string{"tools/call", "tools/call", "tools/call", "tools/list", "initialize"} 157 + statuses := []string{"ok", "ok", "ok", "ok", "ok", "ok", "ok", "ok", "error", "denied"} 158 + 159 + now := time.Now().UTC() 160 + total := 0 161 + 162 + for h := 23; h >= 0; h-- { 163 + // More activity during work hours, taper off at night 164 + count := 5 + rand.Intn(15) 165 + if h >= 9 && h <= 17 { 166 + count = 20 + rand.Intn(40) 167 + } else if h >= 1 && h <= 5 { 168 + count = 1 + rand.Intn(4) 169 + } 170 + 171 + for i := 0; i < count; i++ { 172 + c := callers[rand.Intn(len(callers))] 173 + srv := servers[rand.Intn(len(servers))] 174 + method := methods[rand.Intn(len(methods))] 175 + status := statuses[rand.Intn(len(statuses))] 176 + tool := "" 177 + if method == "tools/call" { 178 + srvTools := tools[srv] 179 + tool = srvTools[rand.Intn(len(srvTools))] 180 + } 181 + latency := int64(5 + rand.Intn(200)) 182 + errMsg := "" 183 + if status == "error" { 184 + errMsg = "backend timeout after " + fmt.Sprintf("%dms", latency) 185 + } 186 + 187 + hourStart := now.Add(-time.Duration(h) * time.Hour).Truncate(time.Hour) 188 + ts := hourStart.Add(time.Duration(rand.Intn(3600)) * time.Second) 189 + 190 + entry := audit.Entry{ 191 + Caller: c.name, 192 + CallerIP: c.ip, 193 + Node: c.node, 194 + Tags: c.tags, 195 + Server: srv, 196 + Method: method, 197 + Tool: tool, 198 + LatencyMs: latency, 199 + Status: status, 200 + Error: errMsg, 201 + } 202 + 203 + // Some entries get session recordings 204 + if c.tags != "" && method == "tools/call" && rand.Intn(3) == 0 { 205 + req := fmt.Sprintf(`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"%s"}}`, tool) 206 + resp := fmt.Sprintf(`{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"ok: %s completed in %dms"}]}}`, tool, latency) 207 + auditor.LogWithRecording(entry, []byte(req), []byte(resp)) 208 + } else { 209 + auditor.Log(entry) 210 + } 211 + 212 + // Backdate the timestamp 213 + auditor.BackdateLastEntry(ts) 214 + total++ 215 + } 216 + } 217 + 218 + slog.Info("demo data seeded", "entries", total) 219 + }
demo.png

This is a binary file and will not be displayed.

+29
gateway.example.yaml
··· 1 + hostname: "mcp" 2 + tailnet: "your-tailnet.ts.net" 3 + state_dir: "~/.local/share/turnscale" 4 + 5 + servers: 6 + github: 7 + url: "http://localhost:8091/mcp" 8 + transport: "streamable-http" 9 + slack: 10 + url: "http://localhost:8092/mcp" 11 + transport: "streamable-http" 12 + 13 + # Access policies — evaluated top-to-bottom, first match wins 14 + policies: 15 + - name: "admin" 16 + match: 17 + identity: ["you@github"] 18 + allow: ["*"] 19 + 20 + - name: "ai-agents" 21 + match: 22 + tags: ["tag:ai-agent"] 23 + allow: ["github", "slack"] 24 + deny_tools: ["mcp__github__delete_*"] 25 + 26 + - name: "default-deny" 27 + match: 28 + identity: ["*"] 29 + deny: ["*"]
+77
go.mod
··· 1 + module github.com/slanos/turnscale 2 + 3 + go 1.26.1 4 + 5 + require ( 6 + gopkg.in/yaml.v3 v3.0.1 7 + modernc.org/sqlite v1.46.1 8 + tailscale.com v1.96.1 9 + ) 10 + 11 + require ( 12 + filippo.io/edwards25519 v1.2.0 // indirect 13 + github.com/akutz/memconn v0.1.0 // indirect 14 + github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa // indirect 15 + github.com/aws/aws-sdk-go-v2 v1.41.0 // indirect 16 + github.com/aws/aws-sdk-go-v2/config v1.29.5 // indirect 17 + github.com/aws/aws-sdk-go-v2/credentials v1.17.58 // indirect 18 + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.27 // indirect 19 + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 // indirect 20 + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 // indirect 21 + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.2 // indirect 22 + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect 23 + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 // indirect 24 + github.com/aws/aws-sdk-go-v2/service/sso v1.24.14 // indirect 25 + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.13 // indirect 26 + github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 // indirect 27 + github.com/aws/smithy-go v1.24.0 // indirect 28 + github.com/coder/websocket v1.8.12 // indirect 29 + github.com/creachadair/msync v0.7.1 // indirect 30 + github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa // indirect 31 + github.com/dustin/go-humanize v1.0.1 // indirect 32 + github.com/fxamacker/cbor/v2 v2.9.0 // indirect 33 + github.com/gaissmai/bart v0.26.1 // indirect 34 + github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced // indirect 35 + github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 // indirect 36 + github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect 37 + github.com/google/btree v1.1.3 // indirect 38 + github.com/google/go-cmp v0.7.0 // indirect 39 + github.com/google/uuid v1.6.0 // indirect 40 + github.com/hdevalence/ed25519consensus v0.2.0 // indirect 41 + github.com/huin/goupnp v1.3.0 // indirect 42 + github.com/jsimonetti/rtnetlink v1.4.0 // indirect 43 + github.com/klauspost/compress v1.18.2 // indirect 44 + github.com/mattn/go-isatty v0.0.20 // indirect 45 + github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect 46 + github.com/mdlayher/socket v0.5.0 // indirect 47 + github.com/mitchellh/go-ps v1.0.0 // indirect 48 + github.com/ncruces/go-strftime v1.0.0 // indirect 49 + github.com/pires/go-proxyproto v0.8.1 // indirect 50 + github.com/prometheus-community/pro-bing v0.4.0 // indirect 51 + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect 52 + github.com/safchain/ethtool v0.3.0 // indirect 53 + github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e // indirect 54 + github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect 55 + github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a // indirect 56 + github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc // indirect 57 + github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 // indirect 58 + github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da // indirect 59 + github.com/x448/float16 v0.8.4 // indirect 60 + go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect 61 + go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect 62 + golang.org/x/crypto v0.46.0 // indirect 63 + golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect 64 + golang.org/x/net v0.48.0 // indirect 65 + golang.org/x/oauth2 v0.33.0 // indirect 66 + golang.org/x/sync v0.19.0 // indirect 67 + golang.org/x/sys v0.40.0 // indirect 68 + golang.org/x/term v0.38.0 // indirect 69 + golang.org/x/text v0.32.0 // indirect 70 + golang.org/x/time v0.12.0 // indirect 71 + golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect 72 + golang.zx2c4.com/wireguard/windows v0.5.3 // indirect 73 + gvisor.dev/gvisor v0.0.0-20260224225140-573d5e7127a8 // indirect 74 + modernc.org/libc v1.67.6 // indirect 75 + modernc.org/mathutil v1.7.1 // indirect 76 + modernc.org/memory v1.11.0 // indirect 77 + )
+274
go.sum
··· 1 + 9fans.net/go v0.0.8-0.20250307142834-96bdba94b63f h1:1C7nZuxUMNz7eiQALRfiqNOm04+m3edWlRff/BYHf0Q= 2 + 9fans.net/go v0.0.8-0.20250307142834-96bdba94b63f/go.mod h1:hHyrZRryGqVdqrknjq5OWDLGCTJ2NeEvtrpR96mjraM= 3 + filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= 4 + filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= 5 + filippo.io/mkcert v1.4.4 h1:8eVbbwfVlaqUM7OwuftKc2nuYOoTDQWqsoXmzoXZdbc= 6 + filippo.io/mkcert v1.4.4/go.mod h1:VyvOchVuAye3BoUsPUOOofKygVwLV2KQMVFJNRq+1dA= 7 + github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= 8 + github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= 9 + github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A= 10 + github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukDjH+Fw= 11 + github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI= 12 + github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= 13 + github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= 14 + github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= 15 + github.com/aws/aws-sdk-go-v2 v1.41.0 h1:tNvqh1s+v0vFYdA1xq0aOJH+Y5cRyZ5upu6roPgPKd4= 16 + github.com/aws/aws-sdk-go-v2 v1.41.0/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= 17 + github.com/aws/aws-sdk-go-v2/config v1.29.5 h1:4lS2IB+wwkj5J43Tq/AwvnscBerBJtQQ6YS7puzCI1k= 18 + github.com/aws/aws-sdk-go-v2/config v1.29.5/go.mod h1:SNzldMlDVbN6nWxM7XsUiNXPSa1LWlqiXtvh/1PrJGg= 19 + github.com/aws/aws-sdk-go-v2/credentials v1.17.58 h1:/d7FUpAPU8Lf2KUdjniQvfNdlMID0Sd9pS23FJ3SS9Y= 20 + github.com/aws/aws-sdk-go-v2/credentials v1.17.58/go.mod h1:aVYW33Ow10CyMQGFgC0ptMRIqJWvJ4nxZb0sUiuQT/A= 21 + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.27 h1:7lOW8NUwE9UZekS1DYoiPdVAqZ6A+LheHWb+mHbNOq8= 22 + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.27/go.mod h1:w1BASFIPOPUae7AgaH4SbjNbfdkxuggLyGfNFTn8ITY= 23 + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 h1:rgGwPzb82iBYSvHMHXc8h9mRoOUBZIGFgKb9qniaZZc= 24 + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16/go.mod h1:L/UxsGeKpGoIj6DxfhOWHWQ/kGKcd4I1VncE4++IyKA= 25 + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 h1:1jtGzuV7c82xnqOVfx2F0xmJcOw5374L7N6juGW6x6U= 26 + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16/go.mod h1:M2E5OQf+XLe+SZGmmpaI2yy+J326aFf6/+54PoxSANc= 27 + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.2 h1:Pg9URiobXy85kgFev3og2CuOZ8JZUBENF+dcgWBaYNk= 28 + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.2/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc= 29 + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E= 30 + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow= 31 + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 h1:oHjJHeUy0ImIV0bsrX0X91GkV5nJAyv1l1CC9lnO0TI= 32 + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16/go.mod h1:iRSNGgOYmiYwSCXxXaKb9HfOEj40+oTKn8pTxMlYkRM= 33 + github.com/aws/aws-sdk-go-v2/service/ssm v1.44.7 h1:a8HvP/+ew3tKwSXqL3BCSjiuicr+XTU2eFYeogV9GJE= 34 + github.com/aws/aws-sdk-go-v2/service/ssm v1.44.7/go.mod h1:Q7XIWsMo0JcMpI/6TGD6XXcXcV1DbTj6e9BKNntIMIM= 35 + github.com/aws/aws-sdk-go-v2/service/sso v1.24.14 h1:c5WJ3iHz7rLIgArznb3JCSQT3uUMiz9DLZhIX+1G8ok= 36 + github.com/aws/aws-sdk-go-v2/service/sso v1.24.14/go.mod h1:+JJQTxB6N4niArC14YNtxcQtwEqzS3o9Z32n7q33Rfs= 37 + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.13 h1:f1L/JtUkVODD+k1+IiSJUUv8A++2qVr+Xvb3xWXETMU= 38 + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.13/go.mod h1:tvqlFoja8/s0o+UruA1Nrezo/df0PzdunMDDurUfg6U= 39 + github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 h1:SciGFVNZ4mHdm7gpD1dgZYnCuVdX1s+lFTg4+4DOy70= 40 + github.com/aws/aws-sdk-go-v2/service/sts v1.41.5/go.mod h1:iW40X4QBmUxdP+fZNOpfmkdMZqsovezbAeO+Ubiv2pk= 41 + github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk= 42 + github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= 43 + github.com/axiomhq/hyperloglog v0.0.0-20240319100328-84253e514e02 h1:bXAPYSbdYbS5VTy92NIUbeDI1qyggi+JYh5op9IFlcQ= 44 + github.com/axiomhq/hyperloglog v0.0.0-20240319100328-84253e514e02/go.mod h1:k08r+Yj1PRAmuayFiRK6MYuR5Ve4IuZtTfxErMIh0+c= 45 + github.com/cilium/ebpf v0.16.0 h1:+BiEnHL6Z7lXnlGUsXQPPAE7+kenAd4ES8MQ5min0Ok= 46 + github.com/cilium/ebpf v0.16.0/go.mod h1:L7u2Blt2jMM/vLAVgjxluxtBKlz3/GWjB0dMOEngfwE= 47 + github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo= 48 + github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= 49 + github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 h1:8h5+bWd7R6AYUslN6c6iuZWTKsKxUFDlpnmilO6R2n0= 50 + github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q= 51 + github.com/creachadair/mds v0.25.9 h1:080Hr8laN2h+l3NeVCGMBpXtIPnl9mz8e4HLraGPqtA= 52 + github.com/creachadair/mds v0.25.9/go.mod h1:4hatI3hRM+qhzuAmqPRFvaBM8mONkS7nsLxkcuTYUIs= 53 + github.com/creachadair/msync v0.7.1 h1:SeZmuEBXQPe5GqV/C94ER7QIZPwtvFbeQiykzt/7uho= 54 + github.com/creachadair/msync v0.7.1/go.mod h1:8CcFlLsSujfHE5wWm19uUBLHIPDAUr6LXDwneVMO008= 55 + github.com/creachadair/taskgroup v0.13.2 h1:3KyqakBuFsm3KkXi/9XIb0QcA8tEzLHLgaoidf0MdVc= 56 + github.com/creachadair/taskgroup v0.13.2/go.mod h1:i3V1Zx7H8RjwljUEeUWYT30Lmb9poewSb2XI1yTwD0g= 57 + github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= 58 + github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= 59 + github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa h1:h8TfIT1xc8FWbwwpmHn1J5i43Y0uZP97GqasGCzSRJk= 60 + github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa/go.mod h1:Nx87SkVqTKd8UtT+xu7sM/l+LgXs6c0aHrlKusR+2EQ= 61 + github.com/dgryski/go-metro v0.0.0-20180109044635-280f6062b5bc h1:8WFBn63wegobsYAX0YjD+8suexZDga5CctH4CCTx2+8= 62 + github.com/dgryski/go-metro v0.0.0-20180109044635-280f6062b5bc/go.mod h1:c9O8+fpSOX1DM8cPNSkX/qsBWdkD4yd2dpciOWQjpBw= 63 + github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e h1:vUmf0yezR0y7jJ5pceLHthLaYf4bA5T14B6q39S4q2Q= 64 + github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e/go.mod h1:YTIHhz/QFSYnu/EhlF2SpU2Uk+32abacUYA5ZPljz1A= 65 + github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c= 66 + github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0= 67 + github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 68 + github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 69 + github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 70 + github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 71 + github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= 72 + github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= 73 + github.com/gaissmai/bart v0.26.1 h1:+w4rnLGNlA2GDVn382Tfe3jOsK5vOr5n4KmigJ9lbTo= 74 + github.com/gaissmai/bart v0.26.1/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c= 75 + github.com/github/fakeca v0.1.0 h1:Km/MVOFvclqxPM9dZBC4+QE564nU4gz4iZ0D9pMw28I= 76 + github.com/github/fakeca v0.1.0/go.mod h1:+bormgoGMMuamOscx7N91aOuUST7wdaJ2rNjeohylyo= 77 + github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced h1:Q311OHjMh/u5E2TITc++WlTP5We0xNseRMkHDyvhW7I= 78 + github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M= 79 + github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= 80 + github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= 81 + github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737 h1:cf60tHxREO3g1nroKr2osU3JWZsJzkfi7rEg+oAB0Lo= 82 + github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737/go.mod h1:MIS0jDzbU/vuM9MC4YnBITCv+RYuTRq8dJzmCrFsK9g= 83 + github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 h1:sQspH8M4niEijh3PFscJRLDnkL547IeP7kpPe3uUhEg= 84 + github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466/go.mod h1:ZiQxhyQ+bbbfxUKVvjfO498oPYvtYhZzycal3G/NHmU= 85 + github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= 86 + github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= 87 + github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= 88 + github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= 89 + github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 90 + github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 91 + github.com/google/go-tpm v0.9.4 h1:awZRf9FwOeTunQmHoDYSHJps3ie6f1UlhS1fOdPEt1I= 92 + github.com/google/go-tpm v0.9.4/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= 93 + github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 h1:wG8RYIyctLhdFk6Vl1yPGtSRtwGpVkWyZww1OCil2MI= 94 + github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806/go.mod h1:Beg6V6zZ3oEn0JuiUQ4wqwuyqqzasOltcoXPtgLbFp4= 95 + github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= 96 + github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= 97 + github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 98 + github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 99 + github.com/hashicorp/golang-lru v0.6.0 h1:uL2shRDx7RTrOrTCUZEGP/wJUFiUI8QT6E7z5o8jga4= 100 + github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= 101 + github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= 102 + github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU= 103 + github.com/hdevalence/ed25519consensus v0.2.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo= 104 + github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc= 105 + github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8= 106 + github.com/illarion/gonotify/v3 v3.0.2 h1:O7S6vcopHexutmpObkeWsnzMJt/r1hONIEogeVNmJMk= 107 + github.com/illarion/gonotify/v3 v3.0.2/go.mod h1:HWGPdPe817GfvY3w7cx6zkbzNZfi3QjcBm/wgVvEL1U= 108 + github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2 h1:9K06NfxkBh25x56yVhWWlKFE8YpicaSfHwoV8SFbueA= 109 + github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2/go.mod h1:3A9PQ1cunSDF/1rbTq99Ts4pVnycWg+vlPkfeD2NLFI= 110 + github.com/jellydator/ttlcache/v3 v3.1.0 h1:0gPFG0IHHP6xyUyXq+JaD8fwkDCqgqwohXNJBcYE71g= 111 + github.com/jellydator/ttlcache/v3 v3.1.0/go.mod h1:hi7MGFdMAwZna5n2tuvh63DvFLzVKySzCVW6+0gA2n4= 112 + github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= 113 + github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= 114 + github.com/jsimonetti/rtnetlink v1.4.0 h1:Z1BF0fRgcETPEa0Kt0MRk3yV5+kF1FWTni6KUFKrq2I= 115 + github.com/jsimonetti/rtnetlink v1.4.0/go.mod h1:5W1jDvWdnthFJ7fxYX1GMK07BUpI4oskfOqvPteYS6E= 116 + github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= 117 + github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= 118 + github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a h1:+RR6SqnTkDLWyICxS1xpjCi/3dhyV+TgZwA6Ww3KncQ= 119 + github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a/go.mod h1:YTtCCM3ryyfiu4F7t8HQ1mxvp1UBdWM2r6Xa+nGWvDk= 120 + github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= 121 + github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= 122 + github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 123 + github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 124 + github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 125 + github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 126 + github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 127 + github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 128 + github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw= 129 + github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o= 130 + github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 h1:A1Cq6Ysb0GM0tpKMbdCXCIfBclan4oHk1Jb+Hrejirg= 131 + github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42/go.mod h1:BB4YCPDOzfy7FniQ/lxuYQ3dgmM2cZumHbK8RpTjN2o= 132 + github.com/mdlayher/sdnotify v1.0.0 h1:Ma9XeLVN/l0qpyx1tNeMSeTjCPH6NtuD6/N9XdTlQ3c= 133 + github.com/mdlayher/sdnotify v1.0.0/go.mod h1:HQUmpM4XgYkhDLtd+Uad8ZFK1T9D5+pNxnXQjCeJlGE= 134 + github.com/mdlayher/socket v0.5.0 h1:ilICZmJcQz70vrWVes1MFera4jGiWNocSkykwwoy3XI= 135 + github.com/mdlayher/socket v0.5.0/go.mod h1:WkcBFfvyG8QENs5+hfQPl1X6Jpd2yeLIYgrGFmJiJxI= 136 + github.com/miekg/dns v1.1.58 h1:ca2Hdkz+cDg/7eNF6V56jjzuZ4aCAE+DbVkILdQWG/4= 137 + github.com/miekg/dns v1.1.58/go.mod h1:Ypv+3b/KadlvW9vJfXOTf300O4UqaHFzFCuHz+rPkBY= 138 + github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc= 139 + github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg= 140 + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= 141 + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 142 + github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= 143 + github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= 144 + github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= 145 + github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= 146 + github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0= 147 + github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= 148 + github.com/pires/go-proxyproto v0.8.1 h1:9KEixbdJfhrbtjpz/ZwCdWDD2Xem0NZ38qMYaASJgp0= 149 + github.com/pires/go-proxyproto v0.8.1/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU= 150 + github.com/pkg/sftp v1.13.6 h1:JFZT4XbOU7l77xGSpOdW+pwIMqP044IyjXX6FGyEKFo= 151 + github.com/pkg/sftp v1.13.6/go.mod h1:tz1ryNURKu77RL+GuCzmoJYxQczL3wLNNpPWagdg4Qk= 152 + github.com/prometheus-community/pro-bing v0.4.0 h1:YMbv+i08gQz97OZZBwLyvmmQEEzyfyrrjEaAchdy3R4= 153 + github.com/prometheus-community/pro-bing v0.4.0/go.mod h1:b7wRYZtCcPmt4Sz319BykUU241rWLe1VFXyiyWK/dH4= 154 + github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= 155 + github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= 156 + github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE= 157 + github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= 158 + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= 159 + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 160 + github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= 161 + github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= 162 + github.com/safchain/ethtool v0.3.0 h1:gimQJpsI6sc1yIqP/y8GYgiXn/NjgvpM0RNoWLVVmP0= 163 + github.com/safchain/ethtool v0.3.0/go.mod h1:SA9BwrgyAqNo7M+uaL6IYbxpm5wk3L7Mm6ocLW+CJUs= 164 + github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e h1:PtWT87weP5LWHEY//SWsYkSO3RWRZo4OSWagh3YD2vQ= 165 + github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e/go.mod h1:XrBNfAFN+pwoWuksbFS9Ccxnopa15zJGgXRFN90l3K4= 166 + github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 h1:Gzfnfk2TWrk8Jj4P4c1a3CtQyMaTVCznlkLZI++hok4= 167 + github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55/go.mod h1:4k4QO+dQ3R5FofL+SanAUZe+/QfeK0+OIuwDIRu2vSg= 168 + github.com/tailscale/golang-x-crypto v0.0.0-20250404221719-a5573b049869 h1:SRL6irQkKGQKKLzvQP/ke/2ZuB7Py5+XuqtOgSj+iMM= 169 + github.com/tailscale/golang-x-crypto v0.0.0-20250404221719-a5573b049869/go.mod h1:ikbF+YT089eInTp9f2vmvy4+ZVnW5hzX1q2WknxSprQ= 170 + github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a h1:SJy1Pu0eH1C29XwJucQo73FrleVK6t4kYz4NVhp34Yw= 171 + github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a/go.mod h1:DFSS3NAGHthKo1gTlmEcSBiZrRJXi28rLNd/1udP1c8= 172 + github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 h1:uFsXVBE9Qr4ZoF094vE6iYTLDl0qCiKzYXlL6UeWObU= 173 + github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7/go.mod h1:NzVQi3Mleb+qzq8VmcWpSkcSYxXIg0DkI6XDzpVkhJ0= 174 + github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc h1:24heQPtnFR+yfntqhI3oAu9i27nEojcQ4NuBQOo5ZFA= 175 + github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc/go.mod h1:f93CXfllFsO9ZQVq+Zocb1Gp4G5Fz0b0rXHLOzt/Djc= 176 + github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 h1:UBPHPtv8+nEAy2PD8RyAhOYvau1ek0HDJqLS/Pysi14= 177 + github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ= 178 + github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6 h1:l10Gi6w9jxvinoiq15g8OToDdASBni4CyJOdHY1Hr8M= 179 + github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6/go.mod h1:ZXRML051h7o4OcI0d3AaILDIad/Xw0IkXaHM17dic1Y= 180 + github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da h1:jVRUZPRs9sqyKlYHHzHjAqKN+6e/Vog6NpHYeNPJqOw= 181 + github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4= 182 + github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e h1:zOGKqN5D5hHhiYUp091JqK7DPCqSARyUfduhGUY8Bek= 183 + github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e/go.mod h1:orPd6JZXXRyuDusYilywte7k094d7dycXXU5YnWsrwg= 184 + github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA= 185 + github.com/tc-hib/winres v0.2.1/go.mod h1:C/JaNhH3KBvhNKVbvdlDWkbMDO9H4fKKDaN7/07SSuk= 186 + github.com/u-root/u-root v0.14.0 h1:Ka4T10EEML7dQ5XDvO9c3MBN8z4nuSnGjcd1jmU2ivg= 187 + github.com/u-root/u-root v0.14.0/go.mod h1:hAyZorapJe4qzbLWlAkmSVCJGbfoU9Pu4jpJ1WMluqE= 188 + github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 h1:pyC9PaHYZFgEKFdlp3G8RaCKgVpHZnecvArXvPXcFkM= 189 + github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701/go.mod h1:P3a5rG4X7tI17Nn3aOIAYr5HbIMukwXG0urG0WuL8OA= 190 + github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY= 191 + github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= 192 + github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= 193 + github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= 194 + go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4hOxG5YpKCzkek= 195 + go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g= 196 + go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M= 197 + go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y= 198 + golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= 199 + golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= 200 + golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= 201 + golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= 202 + golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f h1:phY1HzDcf18Aq9A8KkmRtY9WvOFIxN8wgfvy6Zm1DV8= 203 + golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= 204 + golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w= 205 + golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g= 206 + golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= 207 + golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= 208 + golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= 209 + golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= 210 + golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo= 211 + golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= 212 + golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 213 + golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= 214 + golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 215 + golang.org/x/sys v0.0.0-20220817070843-5a390386f1f2/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 216 + golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 217 + golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= 218 + golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 219 + golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= 220 + golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= 221 + golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= 222 + golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= 223 + golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= 224 + golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= 225 + golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= 226 + golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= 227 + golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= 228 + golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= 229 + golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus8eIuExIE= 230 + golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI= 231 + google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= 232 + google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= 233 + gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 234 + gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 235 + gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 236 + gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 237 + gvisor.dev/gvisor v0.0.0-20260224225140-573d5e7127a8 h1:Zy8IV/+FMLxy6j6p87vk/vQGKcdnbprwjTxc8UiUtsA= 238 + gvisor.dev/gvisor v0.0.0-20260224225140-573d5e7127a8/go.mod h1:QkHjoMIBaYtpVufgwv3keYAbln78mBoCuShZrPrer1Q= 239 + honnef.co/go/tools v0.7.0-0.dev.0.20251022135355-8273271481d0 h1:5SXjd4ET5dYijLaf0O3aOenC0Z4ZafIWSpjUzsQaNho= 240 + honnef.co/go/tools v0.7.0-0.dev.0.20251022135355-8273271481d0/go.mod h1:EPDDhEZqVHhWuPI5zPAsjU0U7v9xNIWjoOVyZ5ZcniQ= 241 + howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM= 242 + howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= 243 + modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= 244 + modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= 245 + modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc= 246 + modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM= 247 + modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= 248 + modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= 249 + modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= 250 + modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= 251 + modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE= 252 + modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= 253 + modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= 254 + modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= 255 + modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI= 256 + modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE= 257 + modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= 258 + modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= 259 + modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= 260 + modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= 261 + modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= 262 + modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= 263 + modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= 264 + modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= 265 + modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU= 266 + modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= 267 + modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= 268 + modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= 269 + modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= 270 + modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= 271 + software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k= 272 + software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= 273 + tailscale.com v1.96.1 h1:9+0JuyK9SSnbKSumGRQhrOdNtgZ5SgJINXgJoVpyf0Y= 274 + tailscale.com v1.96.1/go.mod h1:/3lnZBYb2UEwnN0MNu2SDXUtT06AGd5k0s+OWx3WmcY=
+447
internal/audit/audit.go
··· 1 + package audit 2 + 3 + import ( 4 + "database/sql" 5 + "fmt" 6 + "log/slog" 7 + "os" 8 + "path/filepath" 9 + "strings" 10 + "time" 11 + 12 + _ "modernc.org/sqlite" 13 + ) 14 + 15 + const schema = ` 16 + CREATE TABLE IF NOT EXISTS audit_log ( 17 + id INTEGER PRIMARY KEY AUTOINCREMENT, 18 + ts TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), 19 + caller TEXT NOT NULL, 20 + caller_ip TEXT NOT NULL DEFAULT '', 21 + node TEXT NOT NULL DEFAULT '', 22 + tags TEXT NOT NULL DEFAULT '', 23 + server TEXT NOT NULL, 24 + method TEXT NOT NULL, 25 + tool TEXT NOT NULL DEFAULT '', 26 + latency_ms INTEGER NOT NULL DEFAULT 0, 27 + status TEXT NOT NULL DEFAULT 'ok', 28 + error_msg TEXT NOT NULL DEFAULT '' 29 + ); 30 + 31 + CREATE TABLE IF NOT EXISTS session_recordings ( 32 + id INTEGER PRIMARY KEY AUTOINCREMENT, 33 + audit_id INTEGER NOT NULL, 34 + request TEXT NOT NULL DEFAULT '', 35 + response TEXT NOT NULL DEFAULT '', 36 + UNIQUE(audit_id) 37 + ); 38 + 39 + CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit_log(ts); 40 + CREATE INDEX IF NOT EXISTS idx_audit_caller ON audit_log(caller); 41 + CREATE INDEX IF NOT EXISTS idx_audit_server ON audit_log(server); 42 + ` 43 + 44 + const maxRecordingSize = 64 * 1024 // 64KB cap per body 45 + 46 + // Entry represents a single audit log entry. 47 + type Entry struct { 48 + Caller string 49 + CallerIP string 50 + Node string 51 + Tags string 52 + Server string 53 + Method string 54 + Tool string 55 + LatencyMs int64 56 + Status string 57 + Error string 58 + } 59 + 60 + // Row represents a stored audit log row (Entry + ID and timestamp). 61 + type Row struct { 62 + ID int64 63 + Timestamp string 64 + Entry 65 + } 66 + 67 + // Logger writes audit entries to SQLite. 68 + type Logger struct { 69 + db *sql.DB 70 + } 71 + 72 + // NewLogger opens or creates a SQLite database at the given directory. 73 + func NewLogger(stateDir string) (*Logger, error) { 74 + dbPath := filepath.Join(stateDir, "audit.db") 75 + 76 + if err := os.MkdirAll(stateDir, 0755); err != nil { 77 + return nil, fmt.Errorf("creating state dir: %w", err) 78 + } 79 + 80 + db, err := sql.Open("sqlite", dbPath) 81 + if err != nil { 82 + return nil, fmt.Errorf("opening audit db: %w", err) 83 + } 84 + 85 + // SQLite only supports one concurrent writer. Capping the pool to a single 86 + // connection eliminates SQLITE_BUSY contention between goroutines while WAL 87 + // mode still allows concurrent readers alongside that one writer. 88 + db.SetMaxOpenConns(1) 89 + 90 + // modernc.org/sqlite ignores DSN query params like ?_journal_mode=WAL — 91 + // apply pragmas explicitly after open instead. 92 + for _, pragma := range []string{ 93 + "PRAGMA journal_mode=WAL", 94 + "PRAGMA busy_timeout=5000", 95 + "PRAGMA synchronous=NORMAL", 96 + } { 97 + if _, err := db.Exec(pragma); err != nil { 98 + db.Close() 99 + return nil, fmt.Errorf("setting pragma %q: %w", pragma, err) 100 + } 101 + } 102 + 103 + if _, err := db.Exec(schema); err != nil { 104 + db.Close() 105 + return nil, fmt.Errorf("creating audit schema: %w", err) 106 + } 107 + 108 + return &Logger{db: db}, nil 109 + } 110 + 111 + // Log writes an audit entry to the database. 112 + func (l *Logger) Log(e Entry) { 113 + _, err := l.db.Exec( 114 + `INSERT INTO audit_log (caller, caller_ip, node, tags, server, method, tool, latency_ms, status, error_msg) 115 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, 116 + e.Caller, e.CallerIP, e.Node, e.Tags, e.Server, e.Method, e.Tool, e.LatencyMs, e.Status, e.Error, 117 + ) 118 + if err != nil { 119 + slog.Error("audit log write failed", "error", err) 120 + } 121 + } 122 + 123 + // QueryParams defines filters for querying audit logs. 124 + type QueryParams struct { 125 + Caller string 126 + Server string 127 + Tool string 128 + Status string // ok, error, denied, or empty for all 129 + Since string // RFC3339 timestamp 130 + Limit int 131 + Offset int 132 + } 133 + 134 + // Query retrieves audit log entries matching the given filters. 135 + func (l *Logger) Query(p QueryParams) ([]Row, error) { 136 + if p.Limit <= 0 { 137 + p.Limit = 100 138 + } 139 + 140 + query := `SELECT id, ts, caller, caller_ip, node, tags, server, method, tool, latency_ms, status, error_msg 141 + FROM audit_log WHERE 1=1` 142 + args := []any{} 143 + 144 + if p.Caller != "" { 145 + query += " AND caller LIKE ?" 146 + args = append(args, "%"+p.Caller+"%") 147 + } 148 + if p.Server != "" { 149 + query += " AND server = ?" 150 + args = append(args, p.Server) 151 + } 152 + if p.Status != "" { 153 + query += " AND status = ?" 154 + args = append(args, p.Status) 155 + } 156 + if p.Tool != "" { 157 + query += " AND tool LIKE ?" 158 + args = append(args, "%"+p.Tool+"%") 159 + } 160 + if p.Since != "" { 161 + query += " AND ts >= ?" 162 + args = append(args, p.Since) 163 + } 164 + 165 + query += " ORDER BY id DESC LIMIT ? OFFSET ?" 166 + args = append(args, p.Limit, p.Offset) 167 + 168 + rows, err := l.db.Query(query, args...) 169 + if err != nil { 170 + return nil, err 171 + } 172 + defer rows.Close() 173 + 174 + var result []Row 175 + for rows.Next() { 176 + var r Row 177 + if err := rows.Scan( 178 + &r.ID, &r.Timestamp, &r.Caller, &r.CallerIP, &r.Node, &r.Tags, 179 + &r.Server, &r.Method, &r.Tool, &r.LatencyMs, &r.Status, &r.Error, 180 + ); err != nil { 181 + return nil, err 182 + } 183 + result = append(result, r) 184 + } 185 + return result, rows.Err() 186 + } 187 + 188 + // Prune removes entries older than the given duration, including orphaned session recordings. 189 + func (l *Logger) Prune(maxAge time.Duration) (int64, error) { 190 + cutoff := time.Now().UTC().Add(-maxAge).Format("2006-01-02T15:04:05.000Z") 191 + // Delete orphaned session recordings first 192 + l.db.Exec("DELETE FROM session_recordings WHERE audit_id IN (SELECT id FROM audit_log WHERE ts < ?)", cutoff) 193 + res, err := l.db.Exec("DELETE FROM audit_log WHERE ts < ?", cutoff) 194 + if err != nil { 195 + return 0, err 196 + } 197 + return res.RowsAffected() 198 + } 199 + 200 + // BackdateLastEntry updates the timestamp of the most recent audit entry (for demo data seeding). 201 + func (l *Logger) BackdateLastEntry(t time.Time) { 202 + l.db.Exec("UPDATE audit_log SET ts = ? WHERE id = (SELECT MAX(id) FROM audit_log)", 203 + t.Format("2006-01-02T15:04:05.000Z")) 204 + } 205 + 206 + // TotalRequests returns the total number of audit entries. 207 + func (l *Logger) TotalRequests() int { 208 + var count int 209 + l.db.QueryRow("SELECT COUNT(*) FROM audit_log").Scan(&count) 210 + return count 211 + } 212 + 213 + // Count returns the total number of entries matching the given filters. 214 + func (l *Logger) Count(p QueryParams) int { 215 + query := "SELECT COUNT(*) FROM audit_log WHERE 1=1" 216 + args := []any{} 217 + if p.Caller != "" { 218 + query += " AND caller LIKE ?" 219 + args = append(args, "%"+p.Caller+"%") 220 + } 221 + if p.Server != "" { 222 + query += " AND server = ?" 223 + args = append(args, p.Server) 224 + } 225 + if p.Status != "" { 226 + query += " AND status = ?" 227 + args = append(args, p.Status) 228 + } 229 + if p.Tool != "" { 230 + query += " AND tool LIKE ?" 231 + args = append(args, "%"+p.Tool+"%") 232 + } 233 + if p.Since != "" { 234 + query += " AND ts >= ?" 235 + args = append(args, p.Since) 236 + } 237 + var count int 238 + l.db.QueryRow(query, args...).Scan(&count) 239 + return count 240 + } 241 + 242 + // LogWithRecording writes an audit entry and stores the request/response bodies. 243 + // Returns the audit log ID. 244 + func (l *Logger) LogWithRecording(e Entry, reqBody, respBody []byte) int64 { 245 + res, err := l.db.Exec( 246 + `INSERT INTO audit_log (caller, caller_ip, node, tags, server, method, tool, latency_ms, status, error_msg) 247 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, 248 + e.Caller, e.CallerIP, e.Node, e.Tags, e.Server, e.Method, e.Tool, e.LatencyMs, e.Status, e.Error, 249 + ) 250 + if err != nil { 251 + slog.Error("audit log write failed", "error", err) 252 + return 0 253 + } 254 + id, _ := res.LastInsertId() 255 + 256 + // Store recording (truncate if too large) 257 + req := truncate(reqBody, maxRecordingSize) 258 + resp := truncate(respBody, maxRecordingSize) 259 + _, err = l.db.Exec( 260 + `INSERT INTO session_recordings (audit_id, request, response) VALUES (?, ?, ?)`, 261 + id, string(req), string(resp), 262 + ) 263 + if err != nil { 264 + slog.Error("session recording write failed", "error", err) 265 + } 266 + return id 267 + } 268 + 269 + // Recording holds a session recording for one audit entry. 270 + type Recording struct { 271 + AuditID int64 272 + Request string 273 + Response string 274 + Row Row // the associated audit entry 275 + } 276 + 277 + // GetRecording retrieves the session recording for an audit entry. 278 + func (l *Logger) GetRecording(auditID int64) (*Recording, error) { 279 + var rec Recording 280 + rec.AuditID = auditID 281 + err := l.db.QueryRow( 282 + `SELECT sr.request, sr.response, 283 + al.id, al.ts, al.caller, al.caller_ip, al.node, al.tags, 284 + al.server, al.method, al.tool, al.latency_ms, al.status, al.error_msg 285 + FROM session_recordings sr 286 + JOIN audit_log al ON al.id = sr.audit_id 287 + WHERE sr.audit_id = ?`, auditID, 288 + ).Scan( 289 + &rec.Request, &rec.Response, 290 + &rec.Row.ID, &rec.Row.Timestamp, &rec.Row.Caller, &rec.Row.CallerIP, 291 + &rec.Row.Node, &rec.Row.Tags, &rec.Row.Server, &rec.Row.Method, 292 + &rec.Row.Tool, &rec.Row.LatencyMs, &rec.Row.Status, &rec.Row.Error, 293 + ) 294 + if err != nil { 295 + return nil, err 296 + } 297 + return &rec, nil 298 + } 299 + 300 + // HasRecording checks if an audit entry has a session recording. 301 + func (l *Logger) HasRecording(auditID int64) bool { 302 + var count int 303 + l.db.QueryRow("SELECT COUNT(*) FROM session_recordings WHERE audit_id = ?", auditID).Scan(&count) 304 + return count > 0 305 + } 306 + 307 + func truncate(b []byte, max int) []byte { 308 + if len(b) > max { 309 + return b[:max] 310 + } 311 + return b 312 + } 313 + 314 + // HourlyCount represents request volume for one hour. 315 + type HourlyCount struct { 316 + Hour string 317 + Total int 318 + Errors int 319 + Denied int 320 + } 321 + 322 + // HourlyCounts returns request counts per hour for the last N hours. 323 + func (l *Logger) HourlyCounts(hours int) ([]HourlyCount, error) { 324 + cutoff := time.Now().UTC().Add(-time.Duration(hours) * time.Hour).Format("2006-01-02T15:04:05.000Z") 325 + rows, err := l.db.Query(` 326 + SELECT strftime('%Y-%m-%dT%H:00:00Z', ts) as hour, 327 + COUNT(*) as total, 328 + SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END) as errors, 329 + SUM(CASE WHEN status = 'denied' THEN 1 ELSE 0 END) as denied 330 + FROM audit_log WHERE ts >= ? 331 + GROUP BY hour ORDER BY hour`, cutoff) 332 + if err != nil { 333 + return nil, err 334 + } 335 + defer rows.Close() 336 + 337 + var result []HourlyCount 338 + for rows.Next() { 339 + var h HourlyCount 340 + if err := rows.Scan(&h.Hour, &h.Total, &h.Errors, &h.Denied); err != nil { 341 + return nil, err 342 + } 343 + result = append(result, h) 344 + } 345 + return result, rows.Err() 346 + } 347 + 348 + // RecordingIDs returns the set of audit IDs that have session recordings. 349 + func (l *Logger) RecordingIDs(auditIDs []int64) map[int64]bool { 350 + if len(auditIDs) == 0 { 351 + return nil 352 + } 353 + result := make(map[int64]bool) 354 + // Build IN clause 355 + placeholders := make([]string, len(auditIDs)) 356 + args := make([]any, len(auditIDs)) 357 + for i, id := range auditIDs { 358 + placeholders[i] = "?" 359 + args[i] = id 360 + } 361 + query := "SELECT audit_id FROM session_recordings WHERE audit_id IN (" + strings.Join(placeholders, ",") + ")" 362 + rows, err := l.db.Query(query, args...) 363 + if err != nil { 364 + return result 365 + } 366 + defer rows.Close() 367 + for rows.Next() { 368 + var id int64 369 + rows.Scan(&id) 370 + result[id] = true 371 + } 372 + return result 373 + } 374 + 375 + // HourlyDetail holds a caller+server count for one hour. 376 + type HourlyDetail struct { 377 + Hour string 378 + Caller string 379 + Server string 380 + Count int 381 + } 382 + 383 + // HourlyBreakdown returns per-caller, per-server counts for each hour in the last N hours. 384 + func (l *Logger) HourlyBreakdown(hours int) ([]HourlyDetail, error) { 385 + cutoff := time.Now().UTC().Add(-time.Duration(hours) * time.Hour).Format("2006-01-02T15:04:05.000Z") 386 + rows, err := l.db.Query(` 387 + SELECT strftime('%Y-%m-%dT%H:00:00Z', ts) as hour, 388 + caller, server, COUNT(*) as cnt 389 + FROM audit_log WHERE ts >= ? 390 + GROUP BY hour, caller, server 391 + ORDER BY hour, cnt DESC`, cutoff) 392 + if err != nil { 393 + return nil, err 394 + } 395 + defer rows.Close() 396 + 397 + var result []HourlyDetail 398 + for rows.Next() { 399 + var d HourlyDetail 400 + if err := rows.Scan(&d.Hour, &d.Caller, &d.Server, &d.Count); err != nil { 401 + return nil, err 402 + } 403 + result = append(result, d) 404 + } 405 + return result, rows.Err() 406 + } 407 + 408 + // ServerStat holds aggregate stats for one server. 409 + type ServerStat struct { 410 + Server string 411 + Total int 412 + Errors int 413 + AvgLatency float64 414 + MaxLatency int64 415 + } 416 + 417 + // ServerStats returns aggregate stats per server for the last N hours. 418 + func (l *Logger) ServerStats(hours int) ([]ServerStat, error) { 419 + cutoff := time.Now().UTC().Add(-time.Duration(hours) * time.Hour).Format("2006-01-02T15:04:05.000Z") 420 + rows, err := l.db.Query(` 421 + SELECT server, 422 + COUNT(*) as total, 423 + SUM(CASE WHEN status != 'ok' THEN 1 ELSE 0 END) as errors, 424 + ROUND(AVG(latency_ms), 1) as avg_latency, 425 + MAX(latency_ms) as max_latency 426 + FROM audit_log WHERE ts >= ? 427 + GROUP BY server ORDER BY total DESC`, cutoff) 428 + if err != nil { 429 + return nil, err 430 + } 431 + defer rows.Close() 432 + 433 + var result []ServerStat 434 + for rows.Next() { 435 + var s ServerStat 436 + if err := rows.Scan(&s.Server, &s.Total, &s.Errors, &s.AvgLatency, &s.MaxLatency); err != nil { 437 + return nil, err 438 + } 439 + result = append(result, s) 440 + } 441 + return result, rows.Err() 442 + } 443 + 444 + // Close closes the database connection. 445 + func (l *Logger) Close() error { 446 + return l.db.Close() 447 + }
+391
internal/audit/audit_test.go
··· 1 + package audit 2 + 3 + import ( 4 + "testing" 5 + "time" 6 + ) 7 + 8 + func TestLogAndQuery(t *testing.T) { 9 + dir := t.TempDir() 10 + l, err := NewLogger(dir) 11 + if err != nil { 12 + t.Fatalf("NewLogger: %v", err) 13 + } 14 + defer l.Close() 15 + 16 + l.Log(Entry{ 17 + Caller: "scott@github", 18 + CallerIP: "100.64.0.1", 19 + Node: "little-mac", 20 + Server: "gitea", 21 + Method: "tools/call", 22 + Tool: "mcp__gitea__list_repos", 23 + LatencyMs: 42, 24 + Status: "ok", 25 + }) 26 + l.Log(Entry{ 27 + Caller: "owl", 28 + CallerIP: "100.100.100.1", 29 + Node: "owl", 30 + Tags: "tag:ai-agent", 31 + Server: "matrix", 32 + Method: "tools/call", 33 + Tool: "send_message", 34 + LatencyMs: 15, 35 + Status: "ok", 36 + }) 37 + 38 + // Query all 39 + rows, err := l.Query(QueryParams{}) 40 + if err != nil { 41 + t.Fatalf("Query: %v", err) 42 + } 43 + if len(rows) != 2 { 44 + t.Fatalf("got %d rows, want 2", len(rows)) 45 + } 46 + // Most recent first 47 + if rows[0].Tool != "send_message" { 48 + t.Errorf("first row tool = %q, want send_message", rows[0].Tool) 49 + } 50 + 51 + // Filter by caller 52 + rows, err = l.Query(QueryParams{Caller: "scott@github"}) 53 + if err != nil { 54 + t.Fatalf("Query by caller: %v", err) 55 + } 56 + if len(rows) != 1 { 57 + t.Fatalf("got %d rows for scott, want 1", len(rows)) 58 + } 59 + 60 + // Filter by server 61 + rows, err = l.Query(QueryParams{Server: "matrix"}) 62 + if err != nil { 63 + t.Fatalf("Query by server: %v", err) 64 + } 65 + if len(rows) != 1 { 66 + t.Fatalf("got %d rows for matrix, want 1", len(rows)) 67 + } 68 + } 69 + 70 + func TestPrune(t *testing.T) { 71 + dir := t.TempDir() 72 + l, err := NewLogger(dir) 73 + if err != nil { 74 + t.Fatalf("NewLogger: %v", err) 75 + } 76 + defer l.Close() 77 + 78 + l.Log(Entry{ 79 + Caller: "test", 80 + Server: "test", 81 + Method: "test", 82 + Status: "ok", 83 + }) 84 + 85 + // Prune with -1s (cutoff in the future) should remove everything 86 + n, err := l.Prune(-time.Second) 87 + if err != nil { 88 + t.Fatalf("Prune: %v", err) 89 + } 90 + if n != 1 { 91 + t.Errorf("pruned %d rows, want 1", n) 92 + } 93 + 94 + // Add another and prune with 1 hour — should keep it 95 + l.Log(Entry{ 96 + Caller: "test", 97 + Server: "test", 98 + Method: "test", 99 + Status: "ok", 100 + }) 101 + n, err = l.Prune(time.Hour) 102 + if err != nil { 103 + t.Fatalf("Prune: %v", err) 104 + } 105 + if n != 0 { 106 + t.Errorf("pruned %d rows, want 0", n) 107 + } 108 + } 109 + 110 + func TestLogWithRecordingAndGetRecording(t *testing.T) { 111 + l, err := NewLogger(t.TempDir()) 112 + if err != nil { 113 + t.Fatal(err) 114 + } 115 + defer l.Close() 116 + 117 + entry := Entry{ 118 + Caller: "test@user", Server: "gitea", Method: "tools/call", 119 + Tool: "list_repos", LatencyMs: 10, Status: "ok", 120 + } 121 + reqBody := []byte(`{"jsonrpc":"2.0","method":"tools/call","params":{"name":"list_repos"}}`) 122 + respBody := []byte(`{"jsonrpc":"2.0","result":{"content":[{"text":"ok"}]}}`) 123 + 124 + id := l.LogWithRecording(entry, reqBody, respBody) 125 + if id == 0 { 126 + t.Fatal("LogWithRecording returned 0") 127 + } 128 + 129 + rec, err := l.GetRecording(id) 130 + if err != nil { 131 + t.Fatalf("GetRecording: %v", err) 132 + } 133 + if rec.AuditID != id { 134 + t.Errorf("AuditID = %d, want %d", rec.AuditID, id) 135 + } 136 + if rec.Request == "" { 137 + t.Error("Request should not be empty") 138 + } 139 + if rec.Response == "" { 140 + t.Error("Response should not be empty") 141 + } 142 + if rec.Row.Caller != "test@user" { 143 + t.Errorf("Row.Caller = %q", rec.Row.Caller) 144 + } 145 + if rec.Row.Tool != "list_repos" { 146 + t.Errorf("Row.Tool = %q", rec.Row.Tool) 147 + } 148 + } 149 + 150 + func TestGetRecordingNotFound(t *testing.T) { 151 + l, _ := NewLogger(t.TempDir()) 152 + defer l.Close() 153 + 154 + _, err := l.GetRecording(99999) 155 + if err == nil { 156 + t.Error("expected error for nonexistent recording") 157 + } 158 + } 159 + 160 + func TestHasRecording(t *testing.T) { 161 + l, _ := NewLogger(t.TempDir()) 162 + defer l.Close() 163 + 164 + id := l.LogWithRecording( 165 + Entry{Caller: "t", Server: "t", Method: "t", Status: "ok"}, 166 + []byte("req"), []byte("resp"), 167 + ) 168 + 169 + if !l.HasRecording(id) { 170 + t.Error("should have recording") 171 + } 172 + if l.HasRecording(99999) { 173 + t.Error("should not have recording for nonexistent ID") 174 + } 175 + } 176 + 177 + func TestHourlyCounts(t *testing.T) { 178 + l, _ := NewLogger(t.TempDir()) 179 + defer l.Close() 180 + 181 + l.Log(Entry{Caller: "a", Server: "gitea", Method: "tools/call", Status: "ok"}) 182 + l.Log(Entry{Caller: "b", Server: "gitea", Method: "tools/call", Status: "error", Error: "fail"}) 183 + l.Log(Entry{Caller: "c", Server: "nomad", Method: "tools/call", Status: "ok"}) 184 + 185 + counts, err := l.HourlyCounts(1) 186 + if err != nil { 187 + t.Fatal(err) 188 + } 189 + if len(counts) == 0 { 190 + t.Fatal("expected at least 1 hourly count") 191 + } 192 + // Should have 3 total, 1 error 193 + total, errors := 0, 0 194 + for _, c := range counts { 195 + total += c.Total 196 + errors += c.Errors 197 + } 198 + if total != 3 { 199 + t.Errorf("total = %d, want 3", total) 200 + } 201 + if errors != 1 { 202 + t.Errorf("errors = %d, want 1", errors) 203 + } 204 + } 205 + 206 + func TestServerStats(t *testing.T) { 207 + l, _ := NewLogger(t.TempDir()) 208 + defer l.Close() 209 + 210 + l.Log(Entry{Caller: "a", Server: "gitea", Method: "tools/call", Status: "ok", LatencyMs: 10}) 211 + l.Log(Entry{Caller: "b", Server: "gitea", Method: "tools/call", Status: "ok", LatencyMs: 20}) 212 + l.Log(Entry{Caller: "c", Server: "nomad", Method: "tools/call", Status: "error", Error: "x", LatencyMs: 100}) 213 + 214 + stats, err := l.ServerStats(1) 215 + if err != nil { 216 + t.Fatal(err) 217 + } 218 + if len(stats) != 2 { 219 + t.Fatalf("expected 2 servers, got %d", len(stats)) 220 + } 221 + 222 + // gitea should be first (most requests) 223 + if stats[0].Server != "gitea" { 224 + t.Errorf("first server = %q, want gitea", stats[0].Server) 225 + } 226 + if stats[0].Total != 2 { 227 + t.Errorf("gitea total = %d, want 2", stats[0].Total) 228 + } 229 + if stats[0].AvgLatency != 15 { 230 + t.Errorf("gitea avg latency = %f, want 15", stats[0].AvgLatency) 231 + } 232 + if stats[1].Errors != 1 { 233 + t.Errorf("nomad errors = %d, want 1", stats[1].Errors) 234 + } 235 + } 236 + 237 + func TestTotalRequests(t *testing.T) { 238 + l, _ := NewLogger(t.TempDir()) 239 + defer l.Close() 240 + 241 + if n := l.TotalRequests(); n != 0 { 242 + t.Errorf("empty db: got %d, want 0", n) 243 + } 244 + l.Log(Entry{Caller: "a", Server: "s", Method: "m", Status: "ok"}) 245 + l.Log(Entry{Caller: "b", Server: "s", Method: "m", Status: "ok"}) 246 + if n := l.TotalRequests(); n != 2 { 247 + t.Errorf("after 2 logs: got %d, want 2", n) 248 + } 249 + } 250 + 251 + func TestCount(t *testing.T) { 252 + l, _ := NewLogger(t.TempDir()) 253 + defer l.Close() 254 + 255 + l.Log(Entry{Caller: "alice", Server: "gitea", Method: "tools/call", Status: "ok"}) 256 + l.Log(Entry{Caller: "alice", Server: "gitea", Method: "tools/call", Status: "error", Error: "fail"}) 257 + l.Log(Entry{Caller: "bob", Server: "nomad", Method: "tools/call", Status: "ok"}) 258 + 259 + if n := l.Count(QueryParams{}); n != 3 { 260 + t.Errorf("all: got %d, want 3", n) 261 + } 262 + if n := l.Count(QueryParams{Server: "gitea"}); n != 2 { 263 + t.Errorf("gitea: got %d, want 2", n) 264 + } 265 + if n := l.Count(QueryParams{Status: "error"}); n != 1 { 266 + t.Errorf("errors: got %d, want 1", n) 267 + } 268 + if n := l.Count(QueryParams{Caller: "bob"}); n != 1 { 269 + t.Errorf("bob: got %d, want 1", n) 270 + } 271 + } 272 + 273 + func TestRecordingIDs(t *testing.T) { 274 + l, _ := NewLogger(t.TempDir()) 275 + defer l.Close() 276 + 277 + id1 := l.LogWithRecording( 278 + Entry{Caller: "a", Server: "s", Method: "m", Status: "ok"}, 279 + []byte("req"), []byte("resp"), 280 + ) 281 + l.Log(Entry{Caller: "b", Server: "s", Method: "m", Status: "ok"}) // no recording 282 + 283 + ids := l.RecordingIDs([]int64{id1, id1 + 1, 99999}) 284 + if !ids[id1] { 285 + t.Error("id1 should have recording") 286 + } 287 + if ids[id1+1] { 288 + t.Error("id2 should not have recording") 289 + } 290 + if ids[99999] { 291 + t.Error("nonexistent should not have recording") 292 + } 293 + 294 + // Empty input 295 + empty := l.RecordingIDs(nil) 296 + if empty != nil { 297 + t.Error("nil input should return nil") 298 + } 299 + } 300 + 301 + func TestHourlyBreakdown(t *testing.T) { 302 + l, _ := NewLogger(t.TempDir()) 303 + defer l.Close() 304 + 305 + l.Log(Entry{Caller: "alice", Server: "gitea", Method: "tools/call", Status: "ok"}) 306 + l.Log(Entry{Caller: "alice", Server: "gitea", Method: "tools/call", Status: "ok"}) 307 + l.Log(Entry{Caller: "bob", Server: "nomad", Method: "tools/call", Status: "ok"}) 308 + 309 + details, err := l.HourlyBreakdown(1) 310 + if err != nil { 311 + t.Fatal(err) 312 + } 313 + if len(details) < 2 { 314 + t.Fatalf("expected at least 2 breakdown entries, got %d", len(details)) 315 + } 316 + 317 + // alice→gitea should have count 2 318 + found := false 319 + for _, d := range details { 320 + if d.Caller == "alice" && d.Server == "gitea" && d.Count == 2 { 321 + found = true 322 + } 323 + } 324 + if !found { 325 + t.Error("expected alice→gitea with count 2") 326 + } 327 + } 328 + 329 + func TestQueryWithStatusFilter(t *testing.T) { 330 + l, _ := NewLogger(t.TempDir()) 331 + defer l.Close() 332 + 333 + l.Log(Entry{Caller: "a", Server: "s", Method: "m", Status: "ok"}) 334 + l.Log(Entry{Caller: "b", Server: "s", Method: "m", Status: "error", Error: "fail"}) 335 + l.Log(Entry{Caller: "c", Server: "s", Method: "m", Status: "denied"}) 336 + 337 + rows, _ := l.Query(QueryParams{Status: "ok"}) 338 + if len(rows) != 1 { 339 + t.Errorf("status=ok: got %d, want 1", len(rows)) 340 + } 341 + rows, _ = l.Query(QueryParams{Status: "error"}) 342 + if len(rows) != 1 { 343 + t.Errorf("status=error: got %d, want 1", len(rows)) 344 + } 345 + } 346 + 347 + func TestQueryWithOffset(t *testing.T) { 348 + l, _ := NewLogger(t.TempDir()) 349 + defer l.Close() 350 + 351 + for i := 0; i < 5; i++ { 352 + l.Log(Entry{Caller: "a", Server: "s", Method: "m", Status: "ok"}) 353 + } 354 + 355 + rows, _ := l.Query(QueryParams{Limit: 2, Offset: 0}) 356 + if len(rows) != 2 { 357 + t.Errorf("page 1: got %d, want 2", len(rows)) 358 + } 359 + rows, _ = l.Query(QueryParams{Limit: 2, Offset: 2}) 360 + if len(rows) != 2 { 361 + t.Errorf("page 2: got %d, want 2", len(rows)) 362 + } 363 + rows, _ = l.Query(QueryParams{Limit: 2, Offset: 4}) 364 + if len(rows) != 1 { 365 + t.Errorf("page 3: got %d, want 1", len(rows)) 366 + } 367 + } 368 + 369 + func TestRecordingTruncation(t *testing.T) { 370 + l, _ := NewLogger(t.TempDir()) 371 + defer l.Close() 372 + 373 + // Create a body larger than maxRecordingSize 374 + bigBody := make([]byte, maxRecordingSize+1000) 375 + for i := range bigBody { 376 + bigBody[i] = 'x' 377 + } 378 + 379 + id := l.LogWithRecording( 380 + Entry{Caller: "t", Server: "t", Method: "t", Status: "ok"}, 381 + bigBody, []byte("small"), 382 + ) 383 + 384 + rec, err := l.GetRecording(id) 385 + if err != nil { 386 + t.Fatal(err) 387 + } 388 + if len(rec.Request) > maxRecordingSize { 389 + t.Errorf("request should be truncated to %d, got %d", maxRecordingSize, len(rec.Request)) 390 + } 391 + }
+318
internal/audit/ui.go
··· 1 + package audit 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "html/template" 7 + "net/http" 8 + "net/url" 9 + "strconv" 10 + ) 11 + 12 + var auditTemplate = template.Must(template.New("audit").Parse(`<!DOCTYPE html> 13 + <html lang="en"> 14 + <head> 15 + <meta charset="UTF-8"> 16 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 17 + <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><circle cx='16' cy='16' r='14' fill='%239ece6a'/></svg>"> 18 + <title>Turnscale — Audit Log</title> 19 + <style> 20 + :root { 21 + --bg: #131315; --bg-raised: #1c1c1f; --bg-hover: #202024; 22 + --text: #d4d4d8; --text-dim: #a1a1a8; --text-muted: #7e7e88; 23 + --border: #27272a; --border-hover: #3f3f46; 24 + --accent: #7aa2f7; --ok: #9ece6a; --err: #f7768e; --warn: #e0af68; 25 + } 26 + * { margin: 0; padding: 0; box-sizing: border-box; } 27 + body { 28 + font-family: -apple-system, system-ui, sans-serif; 29 + background: var(--bg); color: var(--text); line-height: 1.5; font-size: 14px; 30 + } 31 + a { color: var(--accent); text-decoration: none; } 32 + a:hover { text-decoration: underline; } 33 + .wrap { max-width: 1000px; margin: 0 auto; padding: 24px; } 34 + .hdr { 35 + display: flex; justify-content: space-between; align-items: baseline; 36 + margin-bottom: 24px; padding-bottom: 16px; border-bottom: 1px solid var(--border); 37 + } 38 + .hdr h1 { font-size: 16px; font-weight: 600; color: var(--text); } 39 + .hdr h1 span { font-weight: 400; color: var(--text-muted); } 40 + .hdr nav { display: flex; gap: 16px; font-size: 13px; } 41 + .hdr nav a { color: var(--text-muted); } 42 + .hdr nav a.on { color: var(--text); } 43 + 44 + .filters { 45 + display: flex; gap: 8px; margin-bottom: 16px; flex-wrap: wrap; align-items: center; 46 + } 47 + .filters input, .filters select { 48 + font-family: inherit; font-size: 13px; padding: 6px 10px; 49 + background: var(--bg-raised); color: var(--text); border: 1px solid var(--border-hover); border-radius: 4px; 50 + } 51 + .filters input:focus, .filters select:focus { outline: none; border-color: var(--accent); } 52 + .filters button { 53 + font-family: inherit; font-size: 13px; padding: 6px 14px; 54 + background: var(--bg-hover); color: var(--text); border: 1px solid var(--border-hover); border-radius: 4px; cursor: pointer; 55 + } 56 + .filters button:hover { background: var(--bg-raised); border-color: var(--text-muted); } 57 + 58 + table { width: 100%; border-collapse: collapse; font-size: 12px; } 59 + th { 60 + text-align: left; padding: 6px 8px; color: var(--text-muted); font-weight: 600; 61 + font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; 62 + border-bottom: 1px solid var(--border); position: sticky; top: 0; background: var(--bg); 63 + } 64 + td { padding: 5px 8px; border-bottom: 1px solid var(--bg-raised); color: var(--text-dim); } 65 + tbody tr { cursor: pointer; } 66 + tbody tr:hover td { color: var(--text); background: var(--bg-hover); } 67 + td.ts { color: var(--text-muted); white-space: nowrap; font-size: 11px; } 68 + td.mono { font-family: ui-monospace, monospace; font-size: 11px; } 69 + .s-ok { color: var(--ok); } 70 + .s-error { color: var(--err); } 71 + .s-denied { color: var(--warn); } 72 + .empty { text-align: center; padding: 24px 8px; color: var(--text-muted); } 73 + .err-cell { max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--err); font-size: 11px; } 74 + 75 + .pager { 76 + display: flex; gap: 8px; align-items: center; margin-top: 16px; font-size: 13px; color: var(--text-muted); 77 + } 78 + .pager a { 79 + padding: 4px 10px; border: 1px solid var(--border-hover); border-radius: 4px; color: var(--text-dim); 80 + text-decoration: none; 81 + } 82 + .pager a:hover { background: var(--bg-hover); color: var(--text); } 83 + .pager a.disabled { pointer-events: none; opacity: 0.3; } 84 + .pager .current { color: var(--text); } 85 + 86 + .modal-bg { 87 + display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.7); 88 + z-index: 100; justify-content: center; align-items: center; 89 + } 90 + .modal-bg.open { display: flex; } 91 + .modal { 92 + background: var(--bg-raised); border: 1px solid var(--border-hover); border-radius: 6px; 93 + max-width: 700px; width: 90%; max-height: 80vh; overflow-y: auto; padding: 20px; 94 + } 95 + .modal-title { font-size: 14px; font-weight: 600; color: var(--text); margin-bottom: 12px; display: flex; justify-content: space-between; } 96 + .modal-close { background: none; border: none; color: var(--text-muted); font-size: 18px; cursor: pointer; padding: 0 4px; } 97 + .modal-close:hover { color: var(--text); } 98 + .modal-row { display: flex; gap: 8px; padding: 4px 0; font-size: 12px; border-bottom: 1px solid var(--border); } 99 + .modal-row:last-child { border-bottom: none; } 100 + .modal-lbl { color: var(--text-muted); min-width: 80px; font-weight: 500; text-transform: uppercase; font-size: 10px; letter-spacing: 0.04em; padding-top: 2px; flex-shrink: 0; } 101 + .modal-val { color: var(--text); word-break: break-all; } 102 + .modal-val.mono { font-family: ui-monospace, monospace; font-size: 11px; } 103 + .modal-err { 104 + background: #1a1111; border: 1px solid #332222; border-radius: 4px; 105 + padding: 8px 10px; font-family: ui-monospace, monospace; font-size: 11px; 106 + color: var(--err); white-space: pre-wrap; word-break: break-all; max-height: 200px; 107 + overflow-y: auto; 108 + } 109 + 110 + footer { margin-top: 48px; font-size: 11px; color: var(--text-muted); text-align: center; } 111 + </style> 112 + </head> 113 + <body> 114 + <div class="wrap"> 115 + <div class="hdr"> 116 + <h1>Turnscale <span>/ audit</span></h1> 117 + <nav> 118 + <a href="/ui/">dashboard</a> 119 + <a href="/ui/audit" class="on">audit</a> 120 + </nav> 121 + </div> 122 + 123 + <form class="filters" method="GET" action="/ui/audit"> 124 + <input name="caller" placeholder="caller" value="{{.Params.Caller}}" style="width:140px"> 125 + <input name="server" placeholder="server" value="{{.Params.Server}}" style="width:100px"> 126 + <input name="tool" placeholder="tool" value="{{.Params.Tool}}" style="width:120px"> 127 + <select name="status"> 128 + <option value="">all status</option> 129 + <option value="ok"{{if eq .Params.Status "ok"}} selected{{end}}>ok</option> 130 + <option value="error"{{if eq .Params.Status "error"}} selected{{end}}>error</option> 131 + <option value="denied"{{if eq .Params.Status "denied"}} selected{{end}}>denied</option> 132 + </select> 133 + <input name="limit" value="{{.Params.Limit}}" type="number" min="1" max="500" style="width:60px" placeholder="50"> 134 + <input type="hidden" name="page" value="1"> 135 + <button type="submit">filter</button> 136 + {{if or .Params.Caller .Params.Server .Params.Tool .Params.Status}}<a href="/ui/audit" style="font-size:12px;color:var(--text-muted)">clear</a>{{end}} 137 + <a href="/ui/audit?{{.QueryString}}&format=json" style="font-size:12px;color:#555;margin-left:auto">export json</a> 138 + </form> 139 + 140 + <table> 141 + <thead> 142 + <tr> 143 + <th>Time</th> 144 + <th>Caller</th> 145 + <th>Server</th> 146 + <th>Method</th> 147 + <th>Tool</th> 148 + <th>ms</th> 149 + <th>Status</th> 150 + <th>Error</th> 151 + </tr> 152 + </thead> 153 + <tbody> 154 + {{if not .Rows}}<tr><td colspan="8" class="empty">No audit entries found.</td></tr>{{end}} 155 + {{range .Rows}} 156 + <tr onclick="showModal(this)" data-ts="{{.Timestamp}}" data-caller="{{.Caller}}" data-ip="{{.CallerIP}}" data-node="{{.Node}}" data-tags="{{.Tags}}" data-server="{{.Server}}" data-method="{{.Method}}" data-tool="{{.Tool}}" data-latency="{{.LatencyMs}}" data-status="{{.Status}}" data-error="{{.Error}}" data-id="{{.ID}}"> 157 + <td class="ts">{{.Timestamp}}</td> 158 + <td>{{.Caller}}</td> 159 + <td>{{.Server}}</td> 160 + <td>{{.Method}}</td> 161 + <td class="mono">{{.Tool}}</td> 162 + <td>{{.LatencyMs}}</td> 163 + <td class="s-{{.Status}}">{{.Status}}</td> 164 + <td class="err-cell">{{.Error}}</td> 165 + </tr> 166 + {{end}} 167 + </tbody> 168 + </table> 169 + 170 + {{if gt .TotalPages 1}} 171 + <div class="pager"> 172 + <a href="/ui/audit?{{.QueryString}}&page={{.PrevPage}}" class="{{if le .Page 1}}disabled{{end}}">&larr; prev</a> 173 + <span class="current">page {{.Page}} of {{.TotalPages}}</span> 174 + <a href="/ui/audit?{{.QueryString}}&page={{.NextPage}}" class="{{if ge .Page .TotalPages}}disabled{{end}}">next &rarr;</a> 175 + <span style="color:var(--text-muted)">({{.Total}} total)</span> 176 + </div> 177 + {{else if .Total}} 178 + <div class="pager"><span style="color:var(--text-muted)">{{.Total}} entries</span></div> 179 + {{end}} 180 + 181 + <footer>Full audit log &middot; filterable by caller, server, tool, status</footer> 182 + </div> 183 + <div class="modal-bg" id="modal" onclick="if(event.target===this)this.classList.remove('open')"> 184 + <div class="modal"> 185 + <div class="modal-title"> 186 + <span id="modal-title">Request Detail</span> 187 + <button class="modal-close" onclick="document.getElementById('modal').classList.remove('open')">&times;</button> 188 + </div> 189 + <div id="modal-body"></div> 190 + </div> 191 + </div> 192 + <script> 193 + function esc(s){return s?s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'):''} 194 + function showModal(tr) { 195 + var d = tr.dataset; 196 + var rows = [ 197 + ['Time', esc(d.ts)], 198 + ['Caller', esc(d.caller)], 199 + ['Node', esc(d.node)], 200 + ['IP', esc(d.ip)], 201 + ['Server', esc(d.server)], 202 + ['Method', esc(d.method)], 203 + ]; 204 + if (d.tool) rows.push(['Tool', esc(d.tool)]); 205 + rows.push(['Latency', esc(d.latency) + 'ms']); 206 + rows.push(['Status', '<span class="s-' + esc(d.status) + '">' + esc(d.status) + '</span>']); 207 + if (d.tags) rows.push(['Tags', esc(d.tags)]); 208 + var html = ''; 209 + for (var i = 0; i < rows.length; i++) { 210 + var cls = (rows[i][0] === 'Tool') ? ' mono' : ''; 211 + html += '<div class="modal-row"><span class="modal-lbl">' + rows[i][0] + '</span><span class="modal-val' + cls + '">' + rows[i][1] + '</span></div>'; 212 + } 213 + if (d.error) { 214 + html += '<div class="modal-row"><span class="modal-lbl">Error</span><div class="modal-err">' + esc(d.error) + '</div></div>'; 215 + } 216 + document.getElementById('modal-title').textContent = d.method + (d.tool ? ' / ' + d.tool : '') + ' \u2192 ' + d.server; 217 + document.getElementById('modal-body').innerHTML = html; 218 + document.getElementById('modal').classList.add('open'); 219 + } 220 + document.addEventListener('keydown', function(e) { 221 + if (e.key === 'Escape') document.getElementById('modal').classList.remove('open'); 222 + }); 223 + </script> 224 + </body> 225 + </html> 226 + `)) 227 + 228 + // UIHandler returns an HTTP handler that renders the audit log web UI. 229 + func UIHandler(logger *Logger) http.HandlerFunc { 230 + type pageData struct { 231 + Params QueryParams 232 + Rows []Row 233 + Page int 234 + TotalPages int 235 + Total int 236 + PrevPage int 237 + NextPage int 238 + QueryString string 239 + } 240 + return func(w http.ResponseWriter, r *http.Request) { 241 + q := r.URL.Query() 242 + limit, _ := strconv.Atoi(q.Get("limit")) 243 + if limit <= 0 { 244 + limit = 50 245 + } 246 + page, _ := strconv.Atoi(q.Get("page")) 247 + if page <= 0 { 248 + page = 1 249 + } 250 + 251 + params := QueryParams{ 252 + Caller: q.Get("caller"), 253 + Server: q.Get("server"), 254 + Tool: q.Get("tool"), 255 + Status: q.Get("status"), 256 + Since: q.Get("since"), 257 + Limit: limit, 258 + Offset: (page - 1) * limit, 259 + } 260 + 261 + total := logger.Count(params) 262 + totalPages := (total + limit - 1) / limit 263 + if totalPages < 1 { 264 + totalPages = 1 265 + } 266 + 267 + rows, err := logger.Query(params) 268 + if err != nil { 269 + http.Error(w, "query error: "+err.Error(), http.StatusInternalServerError) 270 + return 271 + } 272 + 273 + // JSON export 274 + if q.Get("format") == "json" { 275 + w.Header().Set("Content-Type", "application/json") 276 + w.Header().Set("Content-Disposition", "attachment; filename=audit.json") 277 + json.NewEncoder(w).Encode(rows) 278 + return 279 + } 280 + 281 + // Build query string for pagination links (without page) 282 + qs := "" 283 + if params.Caller != "" { 284 + qs += "caller=" + url.QueryEscape(params.Caller) + "&" 285 + } 286 + if params.Server != "" { 287 + qs += "server=" + url.QueryEscape(params.Server) + "&" 288 + } 289 + if params.Tool != "" { 290 + qs += "tool=" + url.QueryEscape(params.Tool) + "&" 291 + } 292 + if params.Status != "" { 293 + qs += "status=" + url.QueryEscape(params.Status) + "&" 294 + } 295 + qs += fmt.Sprintf("limit=%d", limit) 296 + 297 + prev := page - 1 298 + if prev < 1 { 299 + prev = 1 300 + } 301 + next := page + 1 302 + if next > totalPages { 303 + next = totalPages 304 + } 305 + 306 + w.Header().Set("Content-Type", "text/html; charset=utf-8") 307 + auditTemplate.Execute(w, pageData{ 308 + Params: params, 309 + Rows: rows, 310 + Page: page, 311 + TotalPages: totalPages, 312 + Total: total, 313 + PrevPage: prev, 314 + NextPage: next, 315 + QueryString: qs, 316 + }) 317 + } 318 + }
+116
internal/config/config.go
··· 1 + package config 2 + 3 + import ( 4 + "fmt" 5 + "os" 6 + "path/filepath" 7 + "strings" 8 + 9 + "gopkg.in/yaml.v3" 10 + ) 11 + 12 + // Config is the top-level gateway configuration. 13 + type Config struct { 14 + Hostname string `yaml:"hostname"` 15 + Tailnet string `yaml:"tailnet"` 16 + StateDir string `yaml:"state_dir"` 17 + Servers map[string]Server `yaml:"servers"` 18 + Policies []Policy `yaml:"policies"` 19 + 20 + path string `yaml:"-"` // file path for Save() 21 + } 22 + 23 + // Server defines an upstream MCP backend. 24 + type Server struct { 25 + URL string `yaml:"url"` 26 + Transport string `yaml:"transport"` 27 + } 28 + 29 + // Policy defines an access control rule. 30 + type Policy struct { 31 + Name string `yaml:"name"` 32 + Match Match `yaml:"match"` 33 + Allow []string `yaml:"allow"` 34 + Deny []string `yaml:"deny"` 35 + DenyTools []string `yaml:"deny_tools"` 36 + } 37 + 38 + // Match defines the identity criteria for a policy. 39 + type Match struct { 40 + Identity []string `yaml:"identity"` 41 + Tags []string `yaml:"tags"` 42 + } 43 + 44 + // Load reads and parses a gateway config file. The path is stored for Save(). 45 + func Load(path string) (*Config, error) { 46 + data, err := os.ReadFile(path) 47 + if err != nil { 48 + return nil, fmt.Errorf("reading config: %w", err) 49 + } 50 + 51 + var cfg Config 52 + if err := yaml.Unmarshal(data, &cfg); err != nil { 53 + return nil, fmt.Errorf("parsing config: %w", err) 54 + } 55 + 56 + if err := cfg.validate(); err != nil { 57 + return nil, fmt.Errorf("invalid config: %w", err) 58 + } 59 + 60 + cfg.expandStateDir() 61 + cfg.path = path 62 + 63 + return &cfg, nil 64 + } 65 + 66 + // Save writes the current config back to the file it was loaded from. 67 + // Creates a .bak backup before overwriting. 68 + func (c *Config) Save() error { 69 + if c.path == "" { 70 + return fmt.Errorf("no config path set") 71 + } 72 + // Backup existing file 73 + if existing, err := os.ReadFile(c.path); err == nil { 74 + os.WriteFile(c.path+".bak", existing, 0644) 75 + } 76 + data, err := yaml.Marshal(c) 77 + if err != nil { 78 + return fmt.Errorf("marshaling config: %w", err) 79 + } 80 + return os.WriteFile(c.path, data, 0644) 81 + } 82 + 83 + // Path returns the file path this config was loaded from. 84 + func (c *Config) Path() string { 85 + return c.path 86 + } 87 + 88 + func (c *Config) validate() error { 89 + if c.Hostname == "" { 90 + return fmt.Errorf("hostname is required") 91 + } 92 + if len(c.Servers) == 0 { 93 + return fmt.Errorf("at least one server is required") 94 + } 95 + for name, srv := range c.Servers { 96 + if srv.URL == "" { 97 + return fmt.Errorf("server %q: url is required", name) 98 + } 99 + if srv.Transport == "" { 100 + c.Servers[name] = Server{URL: srv.URL, Transport: "streamable-http"} 101 + } 102 + } 103 + return nil 104 + } 105 + 106 + func (c *Config) expandStateDir() { 107 + if c.StateDir == "" { 108 + c.StateDir = "~/.local/share/turnscale" 109 + } 110 + if strings.HasPrefix(c.StateDir, "~/") { 111 + home, err := os.UserHomeDir() 112 + if err == nil { 113 + c.StateDir = filepath.Join(home, c.StateDir[2:]) 114 + } 115 + } 116 + }
+188
internal/config/config_test.go
··· 1 + package config 2 + 3 + import ( 4 + "os" 5 + "path/filepath" 6 + "testing" 7 + ) 8 + 9 + func TestLoad(t *testing.T) { 10 + yaml := ` 11 + hostname: "mcp" 12 + state_dir: "/tmp/mcp-gw-test" 13 + servers: 14 + gitea: 15 + url: "http://localhost:8091/mcp" 16 + transport: "streamable-http" 17 + nomad: 18 + url: "http://localhost:8090/mcp" 19 + policies: 20 + - name: "admin" 21 + match: 22 + identity: ["scott@github"] 23 + allow: ["*"] 24 + - name: "agents" 25 + match: 26 + tags: ["tag:ai-agent"] 27 + allow: ["gitea", "nomad"] 28 + deny_tools: ["mcp__gitea__delete_*"] 29 + - name: "deny-all" 30 + match: 31 + identity: ["*"] 32 + deny: ["*"] 33 + ` 34 + dir := t.TempDir() 35 + path := filepath.Join(dir, "gateway.yaml") 36 + if err := os.WriteFile(path, []byte(yaml), 0644); err != nil { 37 + t.Fatal(err) 38 + } 39 + 40 + cfg, err := Load(path) 41 + if err != nil { 42 + t.Fatalf("Load failed: %v", err) 43 + } 44 + 45 + if cfg.Hostname != "mcp" { 46 + t.Errorf("hostname = %q, want %q", cfg.Hostname, "mcp") 47 + } 48 + if cfg.StateDir != "/tmp/mcp-gw-test" { 49 + t.Errorf("state_dir = %q, want %q", cfg.StateDir, "/tmp/mcp-gw-test") 50 + } 51 + if len(cfg.Servers) != 2 { 52 + t.Fatalf("servers count = %d, want 2", len(cfg.Servers)) 53 + } 54 + if cfg.Servers["gitea"].URL != "http://localhost:8091/mcp" { 55 + t.Errorf("gitea url = %q", cfg.Servers["gitea"].URL) 56 + } 57 + // nomad should get default transport 58 + if cfg.Servers["nomad"].Transport != "streamable-http" { 59 + t.Errorf("nomad transport = %q, want streamable-http", cfg.Servers["nomad"].Transport) 60 + } 61 + if len(cfg.Policies) != 3 { 62 + t.Fatalf("policies count = %d, want 3", len(cfg.Policies)) 63 + } 64 + if cfg.Policies[1].DenyTools[0] != "mcp__gitea__delete_*" { 65 + t.Errorf("deny_tools[0] = %q", cfg.Policies[1].DenyTools[0]) 66 + } 67 + } 68 + 69 + func TestLoadMissingHostname(t *testing.T) { 70 + yaml := ` 71 + servers: 72 + test: 73 + url: "http://localhost:8080" 74 + ` 75 + dir := t.TempDir() 76 + path := filepath.Join(dir, "gateway.yaml") 77 + if err := os.WriteFile(path, []byte(yaml), 0644); err != nil { 78 + t.Fatal(err) 79 + } 80 + 81 + _, err := Load(path) 82 + if err == nil { 83 + t.Fatal("expected error for missing hostname") 84 + } 85 + } 86 + 87 + func TestLoadNoServers(t *testing.T) { 88 + yaml := ` 89 + hostname: "mcp" 90 + servers: {} 91 + ` 92 + dir := t.TempDir() 93 + path := filepath.Join(dir, "gateway.yaml") 94 + if err := os.WriteFile(path, []byte(yaml), 0644); err != nil { 95 + t.Fatal(err) 96 + } 97 + 98 + _, err := Load(path) 99 + if err == nil { 100 + t.Fatal("expected error for no servers") 101 + } 102 + } 103 + 104 + func TestExpandTilde(t *testing.T) { 105 + yaml := ` 106 + hostname: "mcp" 107 + state_dir: "~/test-state" 108 + servers: 109 + test: 110 + url: "http://localhost:8080" 111 + ` 112 + dir := t.TempDir() 113 + path := filepath.Join(dir, "gateway.yaml") 114 + if err := os.WriteFile(path, []byte(yaml), 0644); err != nil { 115 + t.Fatal(err) 116 + } 117 + 118 + cfg, err := Load(path) 119 + if err != nil { 120 + t.Fatalf("Load failed: %v", err) 121 + } 122 + 123 + home, _ := os.UserHomeDir() 124 + want := filepath.Join(home, "test-state") 125 + if cfg.StateDir != want { 126 + t.Errorf("state_dir = %q, want %q", cfg.StateDir, want) 127 + } 128 + } 129 + 130 + func TestSave(t *testing.T) { 131 + yaml := ` 132 + hostname: "mcp" 133 + state_dir: "/tmp/test" 134 + servers: 135 + gitea: 136 + url: "http://localhost:8091/mcp" 137 + transport: "streamable-http" 138 + policies: 139 + - name: "deny" 140 + match: 141 + identity: ["*"] 142 + deny: ["*"] 143 + ` 144 + dir := t.TempDir() 145 + path := filepath.Join(dir, "gateway.yaml") 146 + os.WriteFile(path, []byte(yaml), 0644) 147 + 148 + cfg, err := Load(path) 149 + if err != nil { 150 + t.Fatal(err) 151 + } 152 + 153 + // Modify and save 154 + cfg.Servers["nomad"] = Server{URL: "http://localhost:8090/mcp", Transport: "streamable-http"} 155 + if err := cfg.Save(); err != nil { 156 + t.Fatalf("Save: %v", err) 157 + } 158 + 159 + // Reload and verify 160 + cfg2, err := Load(path) 161 + if err != nil { 162 + t.Fatalf("reload: %v", err) 163 + } 164 + if len(cfg2.Servers) != 2 { 165 + t.Errorf("expected 2 servers after save, got %d", len(cfg2.Servers)) 166 + } 167 + if cfg2.Servers["nomad"].URL != "http://localhost:8090/mcp" { 168 + t.Errorf("nomad url = %q", cfg2.Servers["nomad"].URL) 169 + } 170 + } 171 + 172 + func TestSaveNoPath(t *testing.T) { 173 + cfg := &Config{Hostname: "test", Servers: map[string]Server{"x": {URL: "http://x"}}} 174 + if err := cfg.Save(); err == nil { 175 + t.Error("expected error when saving with no path") 176 + } 177 + } 178 + 179 + func TestPath(t *testing.T) { 180 + dir := t.TempDir() 181 + path := filepath.Join(dir, "test.yaml") 182 + os.WriteFile(path, []byte("hostname: test\nservers:\n x:\n url: http://x\n"), 0644) 183 + 184 + cfg, _ := Load(path) 185 + if cfg.Path() != path { 186 + t.Errorf("Path() = %q, want %q", cfg.Path(), path) 187 + } 188 + }
+550
internal/gateway/gateway.go
··· 1 + package gateway 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "io" 7 + "log/slog" 8 + "net/http" 9 + "sort" 10 + "strings" 11 + "time" 12 + 13 + "github.com/slanos/turnscale/internal/audit" 14 + "github.com/slanos/turnscale/internal/config" 15 + "github.com/slanos/turnscale/internal/identity" 16 + "github.com/slanos/turnscale/internal/policy" 17 + "github.com/slanos/turnscale/internal/ui" 18 + ) 19 + 20 + // Gateway is the core HTTP handler that proxies MCP requests to backends. 21 + type Gateway struct { 22 + cfg *config.Config 23 + ident identity.Identifier 24 + policy *policy.Engine 25 + audit *audit.Logger 26 + client *http.Client 27 + adminIDs map[string]bool // identities that can access /ui/audit 28 + ui *ui.UI 29 + } 30 + 31 + // New creates a new Gateway. 32 + func New(cfg *config.Config, ident identity.Identifier, pol *policy.Engine, aud *audit.Logger) *Gateway { 33 + // Build admin identity set from the first policy that allows "*" 34 + adminIDs := map[string]bool{} 35 + for _, p := range cfg.Policies { 36 + for _, a := range p.Allow { 37 + if a == "*" { 38 + for _, id := range p.Match.Identity { 39 + if id != "*" { 40 + adminIDs[id] = true 41 + } 42 + } 43 + } 44 + } 45 + } 46 + 47 + return &Gateway{ 48 + cfg: cfg, 49 + ident: ident, 50 + policy: pol, 51 + audit: aud, 52 + client: &http.Client{Timeout: 120 * time.Second}, 53 + adminIDs: adminIDs, 54 + ui: ui.New(cfg, ident, pol, aud, adminIDs), 55 + } 56 + } 57 + 58 + // Handler returns the top-level HTTP handler. 59 + func (g *Gateway) Handler() http.Handler { 60 + mux := http.NewServeMux() 61 + mux.HandleFunc("GET /healthz", g.handleHealthz) 62 + mux.HandleFunc("GET /discover", g.handleDiscover) 63 + mux.HandleFunc("GET /servers", g.handleServers) 64 + mux.HandleFunc("GET /ui/audit", g.handleAudit) 65 + mux.HandleFunc("GET /ui/{$}", g.ui.HandleDashboard) 66 + mux.HandleFunc("POST /ui/servers", g.ui.HandleAddServer) 67 + mux.HandleFunc("POST /ui/servers/delete", g.ui.HandleDeleteServer) 68 + mux.HandleFunc("POST /ui/servers/edit", g.ui.HandleEditServer) 69 + mux.HandleFunc("GET /ui/session/{id}", g.ui.HandleSession) 70 + mux.HandleFunc("GET /{server}/mcp", g.handleMCP) 71 + mux.HandleFunc("POST /{server}/mcp", g.handleMCP) 72 + mux.HandleFunc("DELETE /{server}/mcp", g.handleMCP) 73 + mux.HandleFunc("GET /", g.handleIndex) 74 + return mux 75 + } 76 + 77 + func (g *Gateway) handleIndex(w http.ResponseWriter, r *http.Request) { 78 + if r.URL.Path != "/" { 79 + http.NotFound(w, r) 80 + return 81 + } 82 + http.Redirect(w, r, "/ui/", http.StatusFound) 83 + } 84 + 85 + func (g *Gateway) handleHealthz(w http.ResponseWriter, r *http.Request) { 86 + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) 87 + } 88 + 89 + func (g *Gateway) handleDiscover(w http.ResponseWriter, r *http.Request) { 90 + caller := g.identifyOrReject(w, r) 91 + if caller == nil { 92 + return 93 + } 94 + grant := policy.ParseGrants(caller, g.cfg.Tailnet) 95 + srvs := g.ui.Servers() 96 + var servers []string 97 + for name := range srvs { 98 + allowed := false 99 + if grant != nil { 100 + allowed = grant.EvalServer(name) == policy.Allow 101 + } else { 102 + allowed = g.policy.EvalServer(caller, name) == policy.Allow 103 + } 104 + if allowed { 105 + servers = append(servers, name) 106 + } 107 + } 108 + sort.Strings(servers) 109 + writeJSON(w, http.StatusOK, map[string]any{ 110 + "servers": servers, 111 + "base": fmt.Sprintf("http://%s.%s", g.ui.Hostname(), g.cfg.Tailnet), 112 + }) 113 + } 114 + 115 + func (g *Gateway) handleServers(w http.ResponseWriter, r *http.Request) { 116 + caller := g.identifyOrReject(w, r) 117 + if caller == nil { 118 + return 119 + } 120 + 121 + grant := policy.ParseGrants(caller, g.cfg.Tailnet) 122 + type serverInfo struct { 123 + Name string `json:"name"` 124 + URL string `json:"url"` 125 + Transport string `json:"transport"` 126 + Allowed bool `json:"allowed"` 127 + } 128 + srvs := g.ui.Servers() 129 + var servers []serverInfo 130 + for name, srv := range srvs { 131 + allowed := false 132 + if grant != nil { 133 + allowed = grant.EvalServer(name) == policy.Allow 134 + } else { 135 + allowed = g.policy.EvalServer(caller, name) == policy.Allow 136 + } 137 + servers = append(servers, serverInfo{ 138 + Name: name, 139 + URL: fmt.Sprintf("/%s/mcp", name), 140 + Transport: srv.Transport, 141 + Allowed: allowed, 142 + }) 143 + } 144 + writeJSON(w, http.StatusOK, servers) 145 + } 146 + 147 + func (g *Gateway) handleAudit(w http.ResponseWriter, r *http.Request) { 148 + caller := g.identifyOrReject(w, r) 149 + if caller == nil { 150 + return 151 + } 152 + // Admin check: YAML adminIDs or grants admin flag 153 + isAdmin := g.adminIDs[caller.UserLogin] 154 + if !isAdmin { 155 + if grant := policy.ParseGrants(caller, g.cfg.Tailnet); grant != nil { 156 + isAdmin = grant.Admin 157 + } 158 + } 159 + if !isAdmin { 160 + writeJSON(w, http.StatusForbidden, map[string]string{"error": "admin access required"}) 161 + return 162 + } 163 + audit.UIHandler(g.audit)(w, r) 164 + } 165 + 166 + func (g *Gateway) handleMCP(w http.ResponseWriter, r *http.Request) { 167 + serverName := r.PathValue("server") 168 + srvs := g.ui.Servers() 169 + srv, ok := srvs[serverName] 170 + if !ok { 171 + writeJSON(w, http.StatusNotFound, map[string]string{"error": "unknown server: " + serverName}) 172 + return 173 + } 174 + 175 + caller := g.identifyOrReject(w, r) 176 + if caller == nil { 177 + return 178 + } 179 + 180 + // Check server-level access — grants take priority over YAML policy 181 + grant := policy.ParseGrants(caller, g.cfg.Tailnet) 182 + serverAllowed := false 183 + if grant != nil { 184 + serverAllowed = grant.EvalServer(serverName) == policy.Allow 185 + } else { 186 + serverAllowed = g.policy.EvalServer(caller, serverName) == policy.Allow 187 + } 188 + if !serverAllowed { 189 + slog.Warn("access denied", "caller", caller.String(), "server", serverName) 190 + g.audit.Log(audit.Entry{ 191 + Caller: caller.String(), 192 + CallerIP: caller.TailscaleIP, 193 + Node: caller.Node, 194 + Tags: strings.Join(caller.Tags, ","), 195 + Server: serverName, 196 + Method: r.Method, 197 + Status: "denied", 198 + }) 199 + writeJSON(w, http.StatusForbidden, map[string]string{"error": "access denied"}) 200 + return 201 + } 202 + 203 + // Determine if this caller's sessions should be recorded 204 + shouldRecord := grant != nil && grant.Record 205 + 206 + start := time.Now() 207 + 208 + switch r.Method { 209 + case http.MethodGet: 210 + // SSE stream — proxy transparently 211 + g.proxySSE(w, r, srv, caller, serverName) 212 + case http.MethodPost: 213 + // JSON-RPC proxy 214 + g.proxyJSONRPC(w, r, srv, caller, serverName, start, grant, shouldRecord) 215 + case http.MethodDelete: 216 + // Session termination — proxy DELETE to backend 217 + g.proxyDelete(w, r, srv, caller, serverName) 218 + default: 219 + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) 220 + } 221 + } 222 + 223 + // jsonRPCRequest is a minimal JSON-RPC request structure for extracting method/tool info. 224 + type jsonRPCRequest struct { 225 + Method string `json:"method"` 226 + Params json.RawMessage `json:"params"` 227 + } 228 + 229 + // toolCallParams extracts the tool name from tools/call params. 230 + type toolCallParams struct { 231 + Name string `json:"name"` 232 + } 233 + 234 + const maxRequestBody = 10 << 20 // 10MB 235 + 236 + func (g *Gateway) proxyJSONRPC(w http.ResponseWriter, r *http.Request, srv config.Server, caller *identity.Caller, serverName string, start time.Time, grant *policy.MCPGrant, record bool) { 237 + r.Body = http.MaxBytesReader(w, r.Body, maxRequestBody) 238 + body, err := io.ReadAll(r.Body) 239 + if err != nil { 240 + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "failed to read body"}) 241 + return 242 + } 243 + 244 + // Parse to extract method and optional tool name 245 + var rpcReq jsonRPCRequest 246 + json.Unmarshal(body, &rpcReq) // best-effort — we proxy even if we can't parse 247 + 248 + toolName := "" 249 + if rpcReq.Method == "tools/call" { 250 + var params toolCallParams 251 + json.Unmarshal(rpcReq.Params, &params) 252 + toolName = params.Name 253 + 254 + // Check tool-level access — grants take priority 255 + toolDenied := false 256 + if grant != nil { 257 + toolDenied = toolName != "" && grant.EvalTool(serverName, toolName) == policy.Deny 258 + } else { 259 + toolDenied = toolName != "" && g.policy.EvalTool(caller, serverName, toolName) == policy.Deny 260 + } 261 + if toolDenied { 262 + slog.Warn("tool denied", "caller", caller.String(), "server", serverName, "tool", toolName) 263 + g.audit.Log(audit.Entry{ 264 + Caller: caller.String(), 265 + CallerIP: caller.TailscaleIP, 266 + Node: caller.Node, 267 + Tags: strings.Join(caller.Tags, ","), 268 + Server: serverName, 269 + Method: rpcReq.Method, 270 + Tool: toolName, 271 + Status: "denied", 272 + }) 273 + writeJSON(w, http.StatusForbidden, map[string]string{"error": "tool access denied: " + toolName}) 274 + return 275 + } 276 + } 277 + 278 + // Filter tools/list response to remove denied tools 279 + filterTools := false 280 + deniedPatterns := []string{} 281 + if rpcReq.Method == "tools/list" { 282 + if grant != nil { 283 + deniedPatterns = grant.DeniedToolPatterns() 284 + } else { 285 + deniedPatterns = g.policy.DeniedTools(caller, serverName) 286 + } 287 + filterTools = len(deniedPatterns) > 0 288 + } 289 + 290 + // Proxy to backend 291 + proxyReq, err := http.NewRequestWithContext(r.Context(), http.MethodPost, srv.URL, strings.NewReader(string(body))) 292 + if err != nil { 293 + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "proxy request failed"}) 294 + return 295 + } 296 + proxyReq.Header.Set("Content-Type", "application/json") 297 + // Forward MCP-relevant headers (except Accept — we always force the correct value below) 298 + if v := r.Header.Get("Mcp-Session-Id"); v != "" { 299 + proxyReq.Header.Set("Mcp-Session-Id", v) 300 + } 301 + // Streamable HTTP POST requires both MIME types in Accept regardless of what the client sent 302 + proxyReq.Header.Set("Accept", "application/json, text/event-stream") 303 + 304 + resp, err := g.client.Do(proxyReq) 305 + if err != nil { 306 + latency := time.Since(start).Milliseconds() 307 + g.audit.Log(audit.Entry{ 308 + Caller: caller.String(), 309 + CallerIP: caller.TailscaleIP, 310 + Node: caller.Node, 311 + Tags: strings.Join(caller.Tags, ","), 312 + Server: serverName, 313 + Method: rpcReq.Method, 314 + Tool: toolName, 315 + LatencyMs: latency, 316 + Status: "error", 317 + Error: err.Error(), 318 + }) 319 + slog.Error("backend error", "server", serverName, "error", err) 320 + writeJSON(w, http.StatusBadGateway, map[string]string{"error": "backend unavailable"}) 321 + return 322 + } 323 + defer resp.Body.Close() 324 + 325 + respBody, err := io.ReadAll(resp.Body) 326 + if err != nil { 327 + writeJSON(w, http.StatusBadGateway, map[string]string{"error": "reading backend response"}) 328 + return 329 + } 330 + 331 + latency := time.Since(start).Milliseconds() 332 + 333 + // Filter tools/list if needed 334 + if filterTools { 335 + respBody = filterToolsList(respBody, deniedPatterns) 336 + } 337 + 338 + // Audit log 339 + status := "ok" 340 + errMsg := "" 341 + if resp.StatusCode >= 400 { 342 + status = "error" 343 + errMsg = string(respBody) 344 + if len(errMsg) > 200 { 345 + errMsg = errMsg[:200] 346 + } 347 + } 348 + entry := audit.Entry{ 349 + Caller: caller.String(), 350 + CallerIP: caller.TailscaleIP, 351 + Node: caller.Node, 352 + Tags: strings.Join(caller.Tags, ","), 353 + Server: serverName, 354 + Method: rpcReq.Method, 355 + Tool: toolName, 356 + LatencyMs: latency, 357 + Status: status, 358 + Error: errMsg, 359 + } 360 + if record { 361 + g.audit.LogWithRecording(entry, body, respBody) 362 + } else { 363 + g.audit.Log(entry) 364 + } 365 + 366 + // Forward response 367 + for k, v := range resp.Header { 368 + for _, vv := range v { 369 + w.Header().Add(k, vv) 370 + } 371 + } 372 + w.WriteHeader(resp.StatusCode) 373 + w.Write(respBody) 374 + } 375 + 376 + func (g *Gateway) proxyDelete(w http.ResponseWriter, r *http.Request, srv config.Server, caller *identity.Caller, serverName string) { 377 + proxyReq, err := http.NewRequestWithContext(r.Context(), http.MethodDelete, srv.URL, nil) 378 + if err != nil { 379 + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "proxy request failed"}) 380 + return 381 + } 382 + if sid := r.Header.Get("Mcp-Session-Id"); sid != "" { 383 + proxyReq.Header.Set("Mcp-Session-Id", sid) 384 + } 385 + 386 + resp, err := g.client.Do(proxyReq) 387 + if err != nil { 388 + slog.Error("backend error", "server", serverName, "error", err) 389 + writeJSON(w, http.StatusBadGateway, map[string]string{"error": "backend unavailable"}) 390 + return 391 + } 392 + defer resp.Body.Close() 393 + 394 + g.audit.Log(audit.Entry{ 395 + Caller: caller.String(), 396 + CallerIP: caller.TailscaleIP, 397 + Node: caller.Node, 398 + Tags: strings.Join(caller.Tags, ","), 399 + Server: serverName, 400 + Method: "DELETE", 401 + Status: "ok", 402 + }) 403 + 404 + body, _ := io.ReadAll(resp.Body) 405 + for k, v := range resp.Header { 406 + for _, vv := range v { 407 + w.Header().Add(k, vv) 408 + } 409 + } 410 + w.WriteHeader(resp.StatusCode) 411 + w.Write(body) 412 + } 413 + 414 + func (g *Gateway) proxySSE(w http.ResponseWriter, r *http.Request, srv config.Server, caller *identity.Caller, serverName string) { 415 + // Proxy GET request to backend for SSE 416 + proxyReq, err := http.NewRequestWithContext(r.Context(), http.MethodGet, srv.URL, nil) 417 + if err != nil { 418 + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "proxy request failed"}) 419 + return 420 + } 421 + // Forward MCP-relevant headers (except Accept — we always force the correct value below) 422 + for _, h := range []string{"Last-Event-ID", "Mcp-Session-Id"} { 423 + if v := r.Header.Get(h); v != "" { 424 + proxyReq.Header.Set(h, v) 425 + } 426 + } 427 + // SSE GET requires text/event-stream in Accept regardless of what the client sent 428 + proxyReq.Header.Set("Accept", "text/event-stream") 429 + 430 + resp, err := g.client.Do(proxyReq) 431 + if err != nil { 432 + g.audit.Log(audit.Entry{ 433 + Caller: caller.String(), 434 + CallerIP: caller.TailscaleIP, 435 + Node: caller.Node, 436 + Tags: strings.Join(caller.Tags, ","), 437 + Server: serverName, 438 + Method: "SSE", 439 + Status: "error", 440 + Error: err.Error(), 441 + }) 442 + writeJSON(w, http.StatusBadGateway, map[string]string{"error": "backend SSE error"}) 443 + return 444 + } 445 + defer resp.Body.Close() 446 + 447 + g.audit.Log(audit.Entry{ 448 + Caller: caller.String(), 449 + CallerIP: caller.TailscaleIP, 450 + Node: caller.Node, 451 + Tags: strings.Join(caller.Tags, ","), 452 + Server: serverName, 453 + Method: "SSE", 454 + Status: "ok", 455 + }) 456 + 457 + // Forward headers 458 + for k, v := range resp.Header { 459 + for _, vv := range v { 460 + w.Header().Add(k, vv) 461 + } 462 + } 463 + w.WriteHeader(resp.StatusCode) 464 + 465 + // Stream response body 466 + flusher, _ := w.(http.Flusher) 467 + buf := make([]byte, 4096) 468 + for { 469 + n, err := resp.Body.Read(buf) 470 + if n > 0 { 471 + w.Write(buf[:n]) 472 + if flusher != nil { 473 + flusher.Flush() 474 + } 475 + } 476 + if err != nil { 477 + return 478 + } 479 + } 480 + } 481 + 482 + func (g *Gateway) identifyOrReject(w http.ResponseWriter, r *http.Request) *identity.Caller { 483 + caller, err := g.ident.Identify(r) 484 + if err != nil { 485 + slog.Error("identity extraction failed", "error", err, "remote", r.RemoteAddr) 486 + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "identity unknown"}) 487 + return nil 488 + } 489 + return caller 490 + } 491 + 492 + func writeJSON(w http.ResponseWriter, status int, v any) { 493 + w.Header().Set("Content-Type", "application/json") 494 + w.WriteHeader(status) 495 + json.NewEncoder(w).Encode(v) 496 + } 497 + 498 + // filterToolsList removes denied tools from a tools/list JSON-RPC response. 499 + func filterToolsList(body []byte, deniedPatterns []string) []byte { 500 + var resp map[string]any 501 + if err := json.Unmarshal(body, &resp); err != nil { 502 + return body 503 + } 504 + 505 + result, ok := resp["result"].(map[string]any) 506 + if !ok { 507 + return body 508 + } 509 + 510 + tools, ok := result["tools"].([]any) 511 + if !ok { 512 + return body 513 + } 514 + 515 + filtered := make([]any, 0, len(tools)) 516 + for _, t := range tools { 517 + tool, ok := t.(map[string]any) 518 + if !ok { 519 + filtered = append(filtered, t) 520 + continue 521 + } 522 + name, _ := tool["name"].(string) 523 + denied := false 524 + for _, pattern := range deniedPatterns { 525 + if matchGlob(pattern, name) { 526 + denied = true 527 + break 528 + } 529 + } 530 + if !denied { 531 + filtered = append(filtered, t) 532 + } 533 + } 534 + 535 + result["tools"] = filtered 536 + resp["result"] = result 537 + out, err := json.Marshal(resp) 538 + if err != nil { 539 + return body 540 + } 541 + return out 542 + } 543 + 544 + // matchGlob matches a glob pattern with trailing * support. 545 + func matchGlob(pattern, name string) bool { 546 + if idx := len(pattern) - 1; idx >= 0 && pattern[idx] == '*' { 547 + return strings.HasPrefix(name, pattern[:idx]) 548 + } 549 + return pattern == name 550 + }
+571
internal/gateway/gateway_test.go
··· 1 + package gateway 2 + 3 + import ( 4 + "encoding/json" 5 + "io" 6 + "net/http" 7 + "net/http/httptest" 8 + "strings" 9 + "testing" 10 + 11 + "github.com/slanos/turnscale/internal/audit" 12 + "github.com/slanos/turnscale/internal/config" 13 + "github.com/slanos/turnscale/internal/identity" 14 + "github.com/slanos/turnscale/internal/policy" 15 + "tailscale.com/tailcfg" 16 + ) 17 + 18 + // mockIdentifier returns a fixed caller for all requests. 19 + type mockIdentifier struct { 20 + caller *identity.Caller 21 + err error 22 + } 23 + 24 + func (m *mockIdentifier) Identify(r *http.Request) (*identity.Caller, error) { 25 + return m.caller, m.err 26 + } 27 + 28 + // fakeMCPBackend returns a test server that echoes JSON-RPC requests. 29 + func fakeMCPBackend() *httptest.Server { 30 + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 31 + body, _ := io.ReadAll(r.Body) 32 + var req struct { 33 + Method string `json:"method"` 34 + ID any `json:"id"` 35 + } 36 + json.Unmarshal(body, &req) 37 + 38 + w.Header().Set("Content-Type", "application/json") 39 + switch req.Method { 40 + case "tools/list": 41 + json.NewEncoder(w).Encode(map[string]any{ 42 + "jsonrpc": "2.0", "id": req.ID, 43 + "result": map[string]any{ 44 + "tools": []map[string]string{ 45 + {"name": "list_repos", "description": "List repos"}, 46 + {"name": "delete_repo", "description": "Delete repo"}, 47 + }, 48 + }, 49 + }) 50 + case "tools/call": 51 + json.NewEncoder(w).Encode(map[string]any{ 52 + "jsonrpc": "2.0", "id": req.ID, 53 + "result": map[string]any{"content": []map[string]string{{"text": "ok"}}}, 54 + }) 55 + default: 56 + json.NewEncoder(w).Encode(map[string]any{ 57 + "jsonrpc": "2.0", "id": req.ID, 58 + "result": map[string]any{}, 59 + }) 60 + } 61 + })) 62 + } 63 + 64 + func setupGateway(t *testing.T, caller *identity.Caller, backendURL string) (*Gateway, http.Handler) { 65 + t.Helper() 66 + cfg := &config.Config{ 67 + Hostname: "test", 68 + Tailnet: "example.ts.net", 69 + Servers: map[string]config.Server{ 70 + "gitea": {URL: backendURL, Transport: "streamable-http"}, 71 + }, 72 + Policies: []config.Policy{ 73 + { 74 + Name: "admin", 75 + Match: config.Match{Identity: []string{"admin@user"}}, 76 + Allow: []string{"*"}, 77 + }, 78 + { 79 + Name: "agent", 80 + Match: config.Match{Tags: []string{"tag:ai-agent"}}, 81 + Allow: []string{"gitea"}, 82 + DenyTools: []string{"delete_*"}, 83 + }, 84 + { 85 + Name: "deny-all", 86 + Match: config.Match{Identity: []string{"*"}}, 87 + Deny: []string{"*"}, 88 + }, 89 + }, 90 + } 91 + pol := policy.NewEngine(cfg.Policies) 92 + aud, err := audit.NewLogger(t.TempDir()) 93 + if err != nil { 94 + t.Fatal(err) 95 + } 96 + t.Cleanup(func() { aud.Close() }) 97 + 98 + ident := &mockIdentifier{caller: caller} 99 + gw := New(cfg, ident, pol, aud) 100 + return gw, gw.Handler() 101 + } 102 + 103 + func TestHealthz(t *testing.T) { 104 + backend := fakeMCPBackend() 105 + defer backend.Close() 106 + 107 + caller := &identity.Caller{UserLogin: "admin@user", Node: "test"} 108 + _, handler := setupGateway(t, caller, backend.URL) 109 + 110 + req := httptest.NewRequest("GET", "/healthz", nil) 111 + rec := httptest.NewRecorder() 112 + handler.ServeHTTP(rec, req) 113 + 114 + if rec.Code != 200 { 115 + t.Fatalf("status = %d", rec.Code) 116 + } 117 + var resp map[string]string 118 + json.Unmarshal(rec.Body.Bytes(), &resp) 119 + if resp["status"] != "ok" { 120 + t.Errorf("status = %q", resp["status"]) 121 + } 122 + } 123 + 124 + func TestIndexRedirect(t *testing.T) { 125 + backend := fakeMCPBackend() 126 + defer backend.Close() 127 + 128 + caller := &identity.Caller{UserLogin: "admin@user", Node: "test"} 129 + _, handler := setupGateway(t, caller, backend.URL) 130 + 131 + req := httptest.NewRequest("GET", "/", nil) 132 + rec := httptest.NewRecorder() 133 + handler.ServeHTTP(rec, req) 134 + 135 + if rec.Code != 302 { 136 + t.Fatalf("status = %d, want 302", rec.Code) 137 + } 138 + if loc := rec.Header().Get("Location"); loc != "/ui/" { 139 + t.Errorf("Location = %q, want /ui/", loc) 140 + } 141 + } 142 + 143 + func TestServersEndpoint(t *testing.T) { 144 + backend := fakeMCPBackend() 145 + defer backend.Close() 146 + 147 + caller := &identity.Caller{UserLogin: "admin@user", Node: "test"} 148 + _, handler := setupGateway(t, caller, backend.URL) 149 + 150 + req := httptest.NewRequest("GET", "/servers", nil) 151 + rec := httptest.NewRecorder() 152 + handler.ServeHTTP(rec, req) 153 + 154 + if rec.Code != 200 { 155 + t.Fatalf("status = %d", rec.Code) 156 + } 157 + var servers []map[string]any 158 + json.Unmarshal(rec.Body.Bytes(), &servers) 159 + if len(servers) != 1 { 160 + t.Fatalf("expected 1 server, got %d", len(servers)) 161 + } 162 + if servers[0]["name"] != "gitea" { 163 + t.Errorf("server name = %v", servers[0]["name"]) 164 + } 165 + } 166 + 167 + func TestMCPProxyAllowed(t *testing.T) { 168 + backend := fakeMCPBackend() 169 + defer backend.Close() 170 + 171 + caller := &identity.Caller{UserLogin: "admin@user", Node: "test"} 172 + _, handler := setupGateway(t, caller, backend.URL) 173 + 174 + body := `{"jsonrpc":"2.0","id":1,"method":"tools/list"}` 175 + req := httptest.NewRequest("POST", "/gitea/mcp", strings.NewReader(body)) 176 + req.Header.Set("Content-Type", "application/json") 177 + rec := httptest.NewRecorder() 178 + handler.ServeHTTP(rec, req) 179 + 180 + if rec.Code != 200 { 181 + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) 182 + } 183 + 184 + var resp map[string]any 185 + json.Unmarshal(rec.Body.Bytes(), &resp) 186 + result, _ := resp["result"].(map[string]any) 187 + tools, _ := result["tools"].([]any) 188 + if len(tools) != 2 { 189 + t.Errorf("expected 2 tools, got %d", len(tools)) 190 + } 191 + } 192 + 193 + func TestMCPProxyDeniedServer(t *testing.T) { 194 + backend := fakeMCPBackend() 195 + defer backend.Close() 196 + 197 + // stranger matches deny-all policy 198 + caller := &identity.Caller{UserLogin: "stranger@user", Node: "test"} 199 + _, handler := setupGateway(t, caller, backend.URL) 200 + 201 + body := `{"jsonrpc":"2.0","id":1,"method":"tools/list"}` 202 + req := httptest.NewRequest("POST", "/gitea/mcp", strings.NewReader(body)) 203 + req.Header.Set("Content-Type", "application/json") 204 + rec := httptest.NewRecorder() 205 + handler.ServeHTTP(rec, req) 206 + 207 + if rec.Code != 403 { 208 + t.Fatalf("status = %d, want 403", rec.Code) 209 + } 210 + } 211 + 212 + func TestMCPProxyDeniedTool(t *testing.T) { 213 + backend := fakeMCPBackend() 214 + defer backend.Close() 215 + 216 + // agent can access gitea but delete_* is denied 217 + caller := &identity.Caller{Node: "owl", Tags: []string{"tag:ai-agent"}, IsTagged: true} 218 + _, handler := setupGateway(t, caller, backend.URL) 219 + 220 + body := `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"delete_repo"}}` 221 + req := httptest.NewRequest("POST", "/gitea/mcp", strings.NewReader(body)) 222 + req.Header.Set("Content-Type", "application/json") 223 + rec := httptest.NewRecorder() 224 + handler.ServeHTTP(rec, req) 225 + 226 + if rec.Code != 403 { 227 + t.Fatalf("status = %d, want 403", rec.Code) 228 + } 229 + } 230 + 231 + func TestMCPProxyAllowedTool(t *testing.T) { 232 + backend := fakeMCPBackend() 233 + defer backend.Close() 234 + 235 + caller := &identity.Caller{Node: "owl", Tags: []string{"tag:ai-agent"}, IsTagged: true} 236 + _, handler := setupGateway(t, caller, backend.URL) 237 + 238 + body := `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_repos"}}` 239 + req := httptest.NewRequest("POST", "/gitea/mcp", strings.NewReader(body)) 240 + req.Header.Set("Content-Type", "application/json") 241 + rec := httptest.NewRecorder() 242 + handler.ServeHTTP(rec, req) 243 + 244 + if rec.Code != 200 { 245 + t.Fatalf("status = %d, want 200", rec.Code) 246 + } 247 + } 248 + 249 + func TestMCPProxyUnknownServer(t *testing.T) { 250 + backend := fakeMCPBackend() 251 + defer backend.Close() 252 + 253 + caller := &identity.Caller{UserLogin: "admin@user", Node: "test"} 254 + _, handler := setupGateway(t, caller, backend.URL) 255 + 256 + req := httptest.NewRequest("POST", "/nonexistent/mcp", strings.NewReader("{}")) 257 + rec := httptest.NewRecorder() 258 + handler.ServeHTTP(rec, req) 259 + 260 + if rec.Code != 404 { 261 + t.Fatalf("status = %d, want 404", rec.Code) 262 + } 263 + } 264 + 265 + func TestMCPProxyUnauthorized(t *testing.T) { 266 + backend := fakeMCPBackend() 267 + defer backend.Close() 268 + 269 + cfg := &config.Config{ 270 + Hostname: "test", 271 + Tailnet: "example.ts.net", 272 + Servers: map[string]config.Server{"gitea": {URL: backend.URL, Transport: "streamable-http"}}, 273 + Policies: []config.Policy{{Name: "deny", Match: config.Match{Identity: []string{"*"}}, Deny: []string{"*"}}}, 274 + } 275 + pol := policy.NewEngine(cfg.Policies) 276 + aud, _ := audit.NewLogger(t.TempDir()) 277 + defer aud.Close() 278 + ident := &mockIdentifier{err: http.ErrNoCookie} // simulate identity failure 279 + gw := New(cfg, ident, pol, aud) 280 + handler := gw.Handler() 281 + 282 + req := httptest.NewRequest("POST", "/gitea/mcp", strings.NewReader("{}")) 283 + rec := httptest.NewRecorder() 284 + handler.ServeHTTP(rec, req) 285 + 286 + if rec.Code != 401 { 287 + t.Fatalf("status = %d, want 401", rec.Code) 288 + } 289 + } 290 + 291 + func TestMCPProxyWithGrants(t *testing.T) { 292 + backend := fakeMCPBackend() 293 + defer backend.Close() 294 + 295 + // Caller with grants — should use grants, not YAML policy 296 + caller := &identity.Caller{ 297 + UserLogin: "stranger@user", // would be denied by YAML 298 + Node: "test", 299 + Caps: tailcfg.PeerCapMap{ 300 + policy.MCPCapability("example.ts.net"): { 301 + tailcfg.RawMessage(`{"servers":["gitea"],"admin":true}`), 302 + }, 303 + }, 304 + } 305 + _, handler := setupGateway(t, caller, backend.URL) 306 + 307 + body := `{"jsonrpc":"2.0","id":1,"method":"tools/list"}` 308 + req := httptest.NewRequest("POST", "/gitea/mcp", strings.NewReader(body)) 309 + req.Header.Set("Content-Type", "application/json") 310 + rec := httptest.NewRecorder() 311 + handler.ServeHTTP(rec, req) 312 + 313 + // Should be allowed via grants even though YAML would deny 314 + if rec.Code != 200 { 315 + t.Fatalf("status = %d, want 200 (grants should override YAML deny)", rec.Code) 316 + } 317 + } 318 + 319 + func TestMCPProxyGrantsDenyTool(t *testing.T) { 320 + backend := fakeMCPBackend() 321 + defer backend.Close() 322 + 323 + caller := &identity.Caller{ 324 + UserLogin: "stranger@user", 325 + Node: "test", 326 + Caps: tailcfg.PeerCapMap{ 327 + policy.MCPCapability("example.ts.net"): { 328 + tailcfg.RawMessage(`{"servers":["gitea"],"denyTools":["delete_*"]}`), 329 + }, 330 + }, 331 + } 332 + _, handler := setupGateway(t, caller, backend.URL) 333 + 334 + body := `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"delete_repo"}}` 335 + req := httptest.NewRequest("POST", "/gitea/mcp", strings.NewReader(body)) 336 + req.Header.Set("Content-Type", "application/json") 337 + rec := httptest.NewRecorder() 338 + handler.ServeHTTP(rec, req) 339 + 340 + if rec.Code != 403 { 341 + t.Fatalf("status = %d, want 403 (grant denyTools)", rec.Code) 342 + } 343 + } 344 + 345 + func TestMCPToolsListFiltering(t *testing.T) { 346 + backend := fakeMCPBackend() 347 + defer backend.Close() 348 + 349 + // Agent with deny_tools should get filtered tools/list 350 + caller := &identity.Caller{Node: "owl", Tags: []string{"tag:ai-agent"}, IsTagged: true} 351 + _, handler := setupGateway(t, caller, backend.URL) 352 + 353 + body := `{"jsonrpc":"2.0","id":1,"method":"tools/list"}` 354 + req := httptest.NewRequest("POST", "/gitea/mcp", strings.NewReader(body)) 355 + req.Header.Set("Content-Type", "application/json") 356 + rec := httptest.NewRecorder() 357 + handler.ServeHTTP(rec, req) 358 + 359 + if rec.Code != 200 { 360 + t.Fatalf("status = %d", rec.Code) 361 + } 362 + 363 + var resp map[string]any 364 + json.Unmarshal(rec.Body.Bytes(), &resp) 365 + result, _ := resp["result"].(map[string]any) 366 + tools, _ := result["tools"].([]any) 367 + 368 + // delete_repo should be filtered out 369 + for _, tool := range tools { 370 + tm, _ := tool.(map[string]any) 371 + if tm["name"] == "delete_repo" { 372 + t.Error("delete_repo should have been filtered from tools/list") 373 + } 374 + } 375 + if len(tools) != 1 { 376 + t.Errorf("expected 1 tool after filtering, got %d", len(tools)) 377 + } 378 + } 379 + 380 + func TestMCPProxyWithRecording(t *testing.T) { 381 + backend := fakeMCPBackend() 382 + defer backend.Close() 383 + 384 + caller := &identity.Caller{ 385 + UserLogin: "test@user", 386 + Node: "test", 387 + Caps: tailcfg.PeerCapMap{ 388 + policy.MCPCapability("example.ts.net"): { 389 + tailcfg.RawMessage(`{"servers":["gitea"],"record":true}`), 390 + }, 391 + }, 392 + } 393 + 394 + cfg := &config.Config{ 395 + Hostname: "test", 396 + Tailnet: "example.ts.net", 397 + Servers: map[string]config.Server{"gitea": {URL: backend.URL, Transport: "streamable-http"}}, 398 + Policies: []config.Policy{{Name: "deny", Match: config.Match{Identity: []string{"*"}}, Deny: []string{"*"}}}, 399 + } 400 + pol := policy.NewEngine(cfg.Policies) 401 + aud, _ := audit.NewLogger(t.TempDir()) 402 + defer aud.Close() 403 + ident := &mockIdentifier{caller: caller} 404 + gw := New(cfg, ident, pol, aud) 405 + handler := gw.Handler() 406 + 407 + body := `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_repos"}}` 408 + req := httptest.NewRequest("POST", "/gitea/mcp", strings.NewReader(body)) 409 + req.Header.Set("Content-Type", "application/json") 410 + rec := httptest.NewRecorder() 411 + handler.ServeHTTP(rec, req) 412 + 413 + if rec.Code != 200 { 414 + t.Fatalf("status = %d", rec.Code) 415 + } 416 + 417 + // Check that a recording was created 418 + rows, _ := aud.Query(audit.QueryParams{Limit: 1}) 419 + if len(rows) == 0 { 420 + t.Fatal("expected audit entry") 421 + } 422 + 423 + recording, err := aud.GetRecording(rows[0].ID) 424 + if err != nil { 425 + t.Fatalf("GetRecording: %v", err) 426 + } 427 + if recording.Request == "" { 428 + t.Error("recording request should not be empty") 429 + } 430 + if recording.Response == "" { 431 + t.Error("recording response should not be empty") 432 + } 433 + } 434 + 435 + func TestAuditEndpointAdminOnly(t *testing.T) { 436 + backend := fakeMCPBackend() 437 + defer backend.Close() 438 + 439 + // Non-admin 440 + caller := &identity.Caller{UserLogin: "stranger@user", Node: "test"} 441 + _, handler := setupGateway(t, caller, backend.URL) 442 + 443 + req := httptest.NewRequest("GET", "/ui/audit", nil) 444 + rec := httptest.NewRecorder() 445 + handler.ServeHTTP(rec, req) 446 + 447 + if rec.Code != 403 { 448 + t.Fatalf("non-admin audit: status = %d, want 403", rec.Code) 449 + } 450 + } 451 + 452 + func TestAuditEndpointGrantsAdmin(t *testing.T) { 453 + backend := fakeMCPBackend() 454 + defer backend.Close() 455 + 456 + // Admin via grants (not in YAML adminIDs) 457 + caller := &identity.Caller{ 458 + UserLogin: "stranger@user", 459 + Node: "test", 460 + Caps: tailcfg.PeerCapMap{ 461 + policy.MCPCapability("example.ts.net"): { 462 + tailcfg.RawMessage(`{"servers":["*"],"admin":true}`), 463 + }, 464 + }, 465 + } 466 + _, handler := setupGateway(t, caller, backend.URL) 467 + 468 + req := httptest.NewRequest("GET", "/ui/audit", nil) 469 + rec := httptest.NewRecorder() 470 + handler.ServeHTTP(rec, req) 471 + 472 + // Should be allowed via grants admin 473 + if rec.Code != 200 { 474 + t.Fatalf("grants-admin audit: status = %d, want 200", rec.Code) 475 + } 476 + } 477 + 478 + func TestDiscover(t *testing.T) { 479 + backend := fakeMCPBackend() 480 + defer backend.Close() 481 + 482 + // Admin can access all servers 483 + caller := &identity.Caller{UserLogin: "admin@user", Node: "test"} 484 + _, handler := setupGateway(t, caller, backend.URL) 485 + 486 + req := httptest.NewRequest("GET", "/discover", nil) 487 + rec := httptest.NewRecorder() 488 + handler.ServeHTTP(rec, req) 489 + 490 + if rec.Code != 200 { 491 + t.Fatalf("status = %d", rec.Code) 492 + } 493 + var resp map[string]any 494 + json.Unmarshal(rec.Body.Bytes(), &resp) 495 + servers, _ := resp["servers"].([]any) 496 + if len(servers) != 1 || servers[0] != "gitea" { 497 + t.Errorf("servers = %v, want [gitea]", servers) 498 + } 499 + } 500 + 501 + func TestDiscoverDenied(t *testing.T) { 502 + backend := fakeMCPBackend() 503 + defer backend.Close() 504 + 505 + // Stranger is denied all servers 506 + caller := &identity.Caller{UserLogin: "stranger@user", Node: "test"} 507 + _, handler := setupGateway(t, caller, backend.URL) 508 + 509 + req := httptest.NewRequest("GET", "/discover", nil) 510 + rec := httptest.NewRecorder() 511 + handler.ServeHTTP(rec, req) 512 + 513 + if rec.Code != 200 { 514 + t.Fatalf("status = %d", rec.Code) 515 + } 516 + var resp map[string]any 517 + json.Unmarshal(rec.Body.Bytes(), &resp) 518 + servers, _ := resp["servers"].([]any) 519 + if servers != nil && len(servers) > 0 { 520 + t.Errorf("denied user should see no servers, got %v", servers) 521 + } 522 + } 523 + 524 + func TestFilterToolsList(t *testing.T) { 525 + body := []byte(`{"jsonrpc":"2.0","id":1,"result":{"tools":[ 526 + {"name":"list_repos"},{"name":"delete_branch"},{"name":"delete_file"},{"name":"create_issue"} 527 + ]}}`) 528 + 529 + filtered := filterToolsList(body, []string{"delete_*"}) 530 + var resp map[string]any 531 + json.Unmarshal(filtered, &resp) 532 + result, _ := resp["result"].(map[string]any) 533 + tools, _ := result["tools"].([]any) 534 + 535 + if len(tools) != 2 { 536 + t.Errorf("expected 2 tools after filtering, got %d", len(tools)) 537 + } 538 + for _, tool := range tools { 539 + tm, _ := tool.(map[string]any) 540 + if strings.HasPrefix(tm["name"].(string), "delete_") { 541 + t.Errorf("tool %q should have been filtered", tm["name"]) 542 + } 543 + } 544 + } 545 + 546 + func TestFilterToolsListInvalidJSON(t *testing.T) { 547 + body := []byte(`not json`) 548 + result := filterToolsList(body, []string{"delete_*"}) 549 + if string(result) != "not json" { 550 + t.Error("invalid JSON should be returned as-is") 551 + } 552 + } 553 + 554 + func TestMatchGlob(t *testing.T) { 555 + tests := []struct { 556 + pattern, name string 557 + want bool 558 + }{ 559 + {"delete_*", "delete_branch", true}, 560 + {"delete_*", "delete_file", true}, 561 + {"delete_*", "list_repos", false}, 562 + {"exact", "exact", true}, 563 + {"exact", "other", false}, 564 + {"*", "anything", true}, 565 + } 566 + for _, tt := range tests { 567 + if got := matchGlob(tt.pattern, tt.name); got != tt.want { 568 + t.Errorf("matchGlob(%q, %q) = %v, want %v", tt.pattern, tt.name, got, tt.want) 569 + } 570 + } 571 + }
+111
internal/identity/identity.go
··· 1 + package identity 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "net/http" 7 + "strings" 8 + 9 + "tailscale.com/client/local" 10 + "tailscale.com/client/tailscale/apitype" 11 + "tailscale.com/tailcfg" 12 + ) 13 + 14 + type contextKey struct{} 15 + 16 + // Caller represents the Tailscale identity of an incoming request. 17 + type Caller struct { 18 + // UserLogin is the Tailscale user login (e.g. "alice@github"). 19 + UserLogin string 20 + // DisplayName is the user's display name. 21 + DisplayName string 22 + // Node is the hostname of the connecting Tailscale node. 23 + Node string 24 + // TailscaleIP is the Tailscale IP of the connecting node. 25 + TailscaleIP string 26 + // Tags are the ACL tags on the connecting node (e.g. ["tag:ai-agent"]). 27 + Tags []string 28 + // IsTagged is true if the node uses tags (no user identity). 29 + IsTagged bool 30 + // Caps holds Tailscale ACL grant capabilities for this caller. 31 + Caps tailcfg.PeerCapMap 32 + // NodeID is the stable node ID. 33 + NodeID string 34 + } 35 + 36 + // String returns a human-readable identity string. 37 + func (c *Caller) String() string { 38 + if c.IsTagged { 39 + return fmt.Sprintf("%s (%s)", c.Node, strings.Join(c.Tags, ",")) 40 + } 41 + return fmt.Sprintf("%s@%s", c.UserLogin, c.Node) 42 + } 43 + 44 + // Identifier is the interface for extracting caller identity from requests. 45 + type Identifier interface { 46 + Identify(r *http.Request) (*Caller, error) 47 + } 48 + 49 + // TailscaleIdentifier extracts identity using the tsnet LocalClient. 50 + type TailscaleIdentifier struct { 51 + LC *local.Client 52 + } 53 + 54 + // Identify extracts the Tailscale caller identity from an HTTP request. 55 + func (ti *TailscaleIdentifier) Identify(r *http.Request) (*Caller, error) { 56 + who, err := ti.LC.WhoIs(r.Context(), r.RemoteAddr) 57 + if err != nil { 58 + return nil, fmt.Errorf("WhoIs(%s): %w", r.RemoteAddr, err) 59 + } 60 + return callerFromWhoIs(who), nil 61 + } 62 + 63 + func callerFromWhoIs(who *apitype.WhoIsResponse) *Caller { 64 + c := &Caller{} 65 + 66 + if who.Node != nil { 67 + c.Node = who.Node.ComputedName 68 + c.NodeID = string(who.Node.StableID) 69 + if len(who.Node.Addresses) > 0 { 70 + c.TailscaleIP = who.Node.Addresses[0].Addr().String() 71 + } 72 + if len(who.Node.Tags) > 0 { 73 + c.Tags = who.Node.Tags 74 + c.IsTagged = true 75 + } 76 + } 77 + 78 + if who.UserProfile != nil && !c.IsTagged { 79 + c.UserLogin = string(who.UserProfile.LoginName) 80 + c.DisplayName = string(who.UserProfile.DisplayName) 81 + } 82 + 83 + c.Caps = who.CapMap 84 + 85 + return c 86 + } 87 + 88 + // WithCaller attaches a Caller to the request context. 89 + func WithCaller(ctx context.Context, c *Caller) context.Context { 90 + return context.WithValue(ctx, contextKey{}, c) 91 + } 92 + 93 + // FromContext extracts a Caller from the request context. 94 + func FromContext(ctx context.Context) *Caller { 95 + c, _ := ctx.Value(contextKey{}).(*Caller) 96 + return c 97 + } 98 + 99 + // DemoIdentifier always returns a fixed demo caller. Used with --demo flag. 100 + type DemoIdentifier struct { 101 + Inner Identifier 102 + } 103 + 104 + func (d *DemoIdentifier) Identify(r *http.Request) (*Caller, error) { 105 + return &Caller{ 106 + UserLogin: "calvin@corp.dev", 107 + DisplayName: "Calvin Klaude", 108 + Node: "calvins-macbook", 109 + TailscaleIP: "100.64.1.42", 110 + }, nil 111 + }
+136
internal/identity/identity_test.go
··· 1 + package identity 2 + 3 + import ( 4 + "context" 5 + "net/netip" 6 + "testing" 7 + 8 + "tailscale.com/client/tailscale/apitype" 9 + "tailscale.com/tailcfg" 10 + ) 11 + 12 + func TestCallerStringUser(t *testing.T) { 13 + c := &Caller{UserLogin: "scott@github", Node: "little-mac"} 14 + if s := c.String(); s != "scott@github@little-mac" { 15 + t.Errorf("String() = %q, want scott@github@little-mac", s) 16 + } 17 + } 18 + 19 + func TestCallerStringTagged(t *testing.T) { 20 + c := &Caller{Node: "owl", Tags: []string{"tag:ai-agent"}, IsTagged: true} 21 + if s := c.String(); s != "owl (tag:ai-agent)" { 22 + t.Errorf("String() = %q, want owl (tag:ai-agent)", s) 23 + } 24 + } 25 + 26 + func TestCallerFromWhoIsUser(t *testing.T) { 27 + who := &apitype.WhoIsResponse{ 28 + Node: &tailcfg.Node{ 29 + ComputedName: "little-mac", 30 + StableID: "stable123", 31 + Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")}, 32 + }, 33 + UserProfile: &tailcfg.UserProfile{ 34 + LoginName: "scott@github", 35 + DisplayName: "Scott", 36 + }, 37 + } 38 + 39 + c := callerFromWhoIs(who) 40 + 41 + if c.UserLogin != "scott@github" { 42 + t.Errorf("UserLogin = %q", c.UserLogin) 43 + } 44 + if c.DisplayName != "Scott" { 45 + t.Errorf("DisplayName = %q", c.DisplayName) 46 + } 47 + if c.Node != "little-mac" { 48 + t.Errorf("Node = %q", c.Node) 49 + } 50 + if c.NodeID != "stable123" { 51 + t.Errorf("NodeID = %q", c.NodeID) 52 + } 53 + if c.TailscaleIP != "100.64.0.1" { 54 + t.Errorf("TailscaleIP = %q", c.TailscaleIP) 55 + } 56 + if c.IsTagged { 57 + t.Error("expected IsTagged=false for user node") 58 + } 59 + } 60 + 61 + func TestCallerFromWhoIsTagged(t *testing.T) { 62 + who := &apitype.WhoIsResponse{ 63 + Node: &tailcfg.Node{ 64 + ComputedName: "owl", 65 + StableID: "stable456", 66 + Tags: []string{"tag:ai-agent"}, 67 + Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.2/32")}, 68 + }, 69 + UserProfile: &tailcfg.UserProfile{ 70 + LoginName: "should-be-ignored", 71 + }, 72 + } 73 + 74 + c := callerFromWhoIs(who) 75 + 76 + if !c.IsTagged { 77 + t.Error("expected IsTagged=true") 78 + } 79 + if c.UserLogin != "" { 80 + t.Errorf("tagged node should not have UserLogin, got %q", c.UserLogin) 81 + } 82 + if len(c.Tags) != 1 || c.Tags[0] != "tag:ai-agent" { 83 + t.Errorf("Tags = %v", c.Tags) 84 + } 85 + } 86 + 87 + func TestCallerFromWhoIsWithCapMap(t *testing.T) { 88 + caps := tailcfg.PeerCapMap{ 89 + "example.com/cap/test": {tailcfg.RawMessage(`{"foo":"bar"}`)}, 90 + } 91 + who := &apitype.WhoIsResponse{ 92 + Node: &tailcfg.Node{ 93 + ComputedName: "test", 94 + Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.3/32")}, 95 + }, 96 + UserProfile: &tailcfg.UserProfile{LoginName: "test@user"}, 97 + CapMap: caps, 98 + } 99 + 100 + c := callerFromWhoIs(who) 101 + 102 + if c.Caps == nil { 103 + t.Fatal("Caps should not be nil") 104 + } 105 + if _, ok := c.Caps["example.com/cap/test"]; !ok { 106 + t.Error("missing capability in CapMap") 107 + } 108 + } 109 + 110 + func TestCallerFromWhoIsNilNode(t *testing.T) { 111 + who := &apitype.WhoIsResponse{} 112 + c := callerFromWhoIs(who) 113 + if c.Node != "" || c.TailscaleIP != "" { 114 + t.Error("nil node should result in empty fields") 115 + } 116 + } 117 + 118 + func TestWithCallerAndFromContext(t *testing.T) { 119 + c := &Caller{UserLogin: "test@user", Node: "test-node"} 120 + ctx := WithCaller(context.Background(), c) 121 + 122 + got := FromContext(ctx) 123 + if got == nil { 124 + t.Fatal("FromContext returned nil") 125 + } 126 + if got.UserLogin != "test@user" { 127 + t.Errorf("UserLogin = %q", got.UserLogin) 128 + } 129 + } 130 + 131 + func TestFromContextEmpty(t *testing.T) { 132 + got := FromContext(context.Background()) 133 + if got != nil { 134 + t.Error("expected nil from empty context") 135 + } 136 + }
+88
internal/policy/grants.go
··· 1 + package policy 2 + 3 + import ( 4 + "encoding/json" 5 + 6 + "github.com/slanos/turnscale/internal/identity" 7 + "tailscale.com/tailcfg" 8 + ) 9 + 10 + // MCPCapability returns the Tailscale grant capability name for MCP access. 11 + // Uses the tailnet domain as namespace (tailscale.com/cap/* is reserved by Tailscale). 12 + func MCPCapability(tailnet string) tailcfg.PeerCapability { 13 + return tailcfg.PeerCapability(tailnet + "/cap/mcp") 14 + } 15 + 16 + // MCPGrant represents an MCP access grant from a Tailscale ACL grants block. 17 + // Multiple grants for the same caller are unioned. 18 + type MCPGrant struct { 19 + Servers []string `json:"servers"` // ["gitea","nomad"] or ["*"] 20 + DenyTools []string `json:"denyTools"` // ["delete_*"] 21 + Admin bool `json:"admin"` 22 + Record bool `json:"record"` // enable session recording 23 + } 24 + 25 + // ParseGrants extracts MCP grants from a caller's Tailscale capability map. 26 + // Returns nil if no MCP grants are present. 27 + func ParseGrants(caller *identity.Caller, tailnet string) *MCPGrant { 28 + if caller.Caps == nil { 29 + return nil 30 + } 31 + 32 + cap := MCPCapability(tailnet) 33 + raw, ok := caller.Caps[cap] 34 + if !ok || len(raw) == 0 { 35 + return nil 36 + } 37 + 38 + // Union all grant entries 39 + merged := &MCPGrant{} 40 + for _, r := range raw { 41 + var g MCPGrant 42 + if err := json.Unmarshal([]byte(r), &g); err != nil { 43 + continue 44 + } 45 + merged.Servers = append(merged.Servers, g.Servers...) 46 + merged.DenyTools = append(merged.DenyTools, g.DenyTools...) 47 + if g.Admin { 48 + merged.Admin = true 49 + } 50 + if g.Record { 51 + merged.Record = true 52 + } 53 + } 54 + 55 + if len(merged.Servers) == 0 { 56 + return nil 57 + } 58 + 59 + return merged 60 + } 61 + 62 + // EvalServerGrant checks if the grant allows access to the named server. 63 + func (g *MCPGrant) EvalServer(server string) Decision { 64 + for _, s := range g.Servers { 65 + if s == "*" || s == server { 66 + return Allow 67 + } 68 + } 69 + return Deny 70 + } 71 + 72 + // EvalToolGrant checks if the grant allows the named tool on the named server. 73 + func (g *MCPGrant) EvalTool(server, tool string) Decision { 74 + if g.EvalServer(server) == Deny { 75 + return Deny 76 + } 77 + for _, pattern := range g.DenyTools { 78 + if matchGlob(pattern, tool) { 79 + return Deny 80 + } 81 + } 82 + return Allow 83 + } 84 + 85 + // DeniedToolPatterns returns the deny_tools patterns from this grant. 86 + func (g *MCPGrant) DeniedToolPatterns() []string { 87 + return g.DenyTools 88 + }
+99
internal/policy/grants_test.go
··· 1 + package policy 2 + 3 + import ( 4 + "testing" 5 + 6 + "github.com/slanos/turnscale/internal/identity" 7 + "tailscale.com/tailcfg" 8 + ) 9 + 10 + const testTailnet = "example.ts.net" 11 + 12 + func TestParseGrantsNil(t *testing.T) { 13 + caller := &identity.Caller{UserLogin: "test@user"} 14 + if g := ParseGrants(caller, testTailnet); g != nil { 15 + t.Error("expected nil grants for caller without caps") 16 + } 17 + } 18 + 19 + func TestParseGrantsBasic(t *testing.T) { 20 + raw := tailcfg.RawMessage(`{"servers":["gitea","nomad"],"denyTools":["delete_*"],"admin":true,"record":true}`) 21 + caller := &identity.Caller{ 22 + UserLogin: "admin@user", 23 + Caps: tailcfg.PeerCapMap{ 24 + MCPCapability(testTailnet): {raw}, 25 + }, 26 + } 27 + 28 + g := ParseGrants(caller, testTailnet) 29 + if g == nil { 30 + t.Fatal("expected grants") 31 + } 32 + if !g.Admin { 33 + t.Error("expected admin=true") 34 + } 35 + if !g.Record { 36 + t.Error("expected record=true") 37 + } 38 + if len(g.Servers) != 2 { 39 + t.Errorf("expected 2 servers, got %d", len(g.Servers)) 40 + } 41 + } 42 + 43 + func TestParseGrantsUnion(t *testing.T) { 44 + r1 := tailcfg.RawMessage(`{"servers":["gitea"],"admin":false}`) 45 + r2 := tailcfg.RawMessage(`{"servers":["nomad"],"admin":true}`) 46 + caller := &identity.Caller{ 47 + UserLogin: "test@user", 48 + Caps: tailcfg.PeerCapMap{ 49 + MCPCapability(testTailnet): {r1, r2}, 50 + }, 51 + } 52 + 53 + g := ParseGrants(caller, testTailnet) 54 + if g == nil { 55 + t.Fatal("expected grants") 56 + } 57 + if !g.Admin { 58 + t.Error("admin should be true (unioned)") 59 + } 60 + if len(g.Servers) != 2 { 61 + t.Errorf("expected 2 servers (union), got %d", len(g.Servers)) 62 + } 63 + } 64 + 65 + func TestGrantEvalServer(t *testing.T) { 66 + g := &MCPGrant{Servers: []string{"gitea", "nomad"}} 67 + 68 + if g.EvalServer("gitea") != Allow { 69 + t.Error("gitea should be allowed") 70 + } 71 + if g.EvalServer("omada") != Deny { 72 + t.Error("omada should be denied") 73 + } 74 + } 75 + 76 + func TestGrantEvalServerWildcard(t *testing.T) { 77 + g := &MCPGrant{Servers: []string{"*"}} 78 + 79 + if g.EvalServer("anything") != Allow { 80 + t.Error("wildcard should allow anything") 81 + } 82 + } 83 + 84 + func TestGrantEvalTool(t *testing.T) { 85 + g := &MCPGrant{ 86 + Servers: []string{"gitea"}, 87 + DenyTools: []string{"delete_*"}, 88 + } 89 + 90 + if g.EvalTool("gitea", "list_repos") != Allow { 91 + t.Error("list_repos should be allowed") 92 + } 93 + if g.EvalTool("gitea", "delete_branch") != Deny { 94 + t.Error("delete_branch should be denied") 95 + } 96 + if g.EvalTool("nomad", "list_jobs") != Deny { 97 + t.Error("nomad should be denied (server not in grant)") 98 + } 99 + }
+145
internal/policy/policy.go
··· 1 + package policy 2 + 3 + import ( 4 + "path" 5 + 6 + "github.com/slanos/turnscale/internal/config" 7 + "github.com/slanos/turnscale/internal/identity" 8 + ) 9 + 10 + // Decision is the result of a policy evaluation. 11 + type Decision int 12 + 13 + const ( 14 + // Deny means the request is not allowed. 15 + Deny Decision = iota 16 + // Allow means the request is allowed. 17 + Allow 18 + ) 19 + 20 + // Engine evaluates access policies against caller identities. 21 + type Engine struct { 22 + policies []config.Policy 23 + } 24 + 25 + // NewEngine creates a policy engine from config policies. 26 + func NewEngine(policies []config.Policy) *Engine { 27 + return &Engine{policies: policies} 28 + } 29 + 30 + // EvalServer checks if the caller is allowed to access the named server. 31 + // Returns Allow if any matching policy allows the server and no matching 32 + // policy denies it. Policies are evaluated top-to-bottom, first match wins. 33 + func (e *Engine) EvalServer(caller *identity.Caller, server string) Decision { 34 + for _, p := range e.policies { 35 + if !matchesCaller(p.Match, caller) { 36 + continue 37 + } 38 + // First matching policy wins 39 + if matchesServer(p.Deny, server) { 40 + return Deny 41 + } 42 + if matchesServer(p.Allow, server) { 43 + return Allow 44 + } 45 + // Policy matched the identity but didn't say anything about this server. 46 + // Continue to next policy. 47 + } 48 + // No policy matched — default deny. 49 + return Deny 50 + } 51 + 52 + // EvalTool checks if the caller is allowed to call the named tool on the named server. 53 + // This is checked after EvalServer — if the server is denied, the tool is irrelevant. 54 + func (e *Engine) EvalTool(caller *identity.Caller, server, tool string) Decision { 55 + for _, p := range e.policies { 56 + if !matchesCaller(p.Match, caller) { 57 + continue 58 + } 59 + // Check if this policy allows the server first 60 + if matchesServer(p.Deny, server) { 61 + return Deny 62 + } 63 + if !matchesServer(p.Allow, server) { 64 + continue 65 + } 66 + // Server is allowed — check tool deny list 67 + for _, pattern := range p.DenyTools { 68 + if matchGlob(pattern, tool) { 69 + return Deny 70 + } 71 + } 72 + return Allow 73 + } 74 + return Deny 75 + } 76 + 77 + // DeniedTools returns the list of tool deny patterns for the first matching 78 + // policy that allows the given server. Used to filter tools/list responses. 79 + func (e *Engine) DeniedTools(caller *identity.Caller, server string) []string { 80 + for _, p := range e.policies { 81 + if !matchesCaller(p.Match, caller) { 82 + continue 83 + } 84 + if matchesServer(p.Deny, server) { 85 + return nil 86 + } 87 + if matchesServer(p.Allow, server) { 88 + return p.DenyTools 89 + } 90 + } 91 + return nil 92 + } 93 + 94 + // FirstMatch returns the index of the first policy matching the caller, or -1. 95 + func (e *Engine) FirstMatch(caller *identity.Caller) int { 96 + for i, p := range e.policies { 97 + if matchesCaller(p.Match, caller) { 98 + return i 99 + } 100 + } 101 + return -1 102 + } 103 + 104 + func matchesCaller(m config.Match, caller *identity.Caller) bool { 105 + // Match by identity (user login) 106 + for _, id := range m.Identity { 107 + if id == "*" { 108 + return true 109 + } 110 + if caller.UserLogin == id { 111 + return true 112 + } 113 + } 114 + // Match by tag 115 + for _, tag := range m.Tags { 116 + for _, ct := range caller.Tags { 117 + if ct == tag { 118 + return true 119 + } 120 + } 121 + } 122 + return false 123 + } 124 + 125 + func matchesServer(list []string, server string) bool { 126 + for _, s := range list { 127 + if s == "*" || s == server { 128 + return true 129 + } 130 + } 131 + return false 132 + } 133 + 134 + // matchGlob does simple glob matching (only supports trailing *). 135 + func matchGlob(pattern, name string) bool { 136 + matched, err := path.Match(pattern, name) 137 + if err != nil { 138 + // Fall back to prefix match for patterns like "mcp__gitea__delete_*" 139 + if idx := len(pattern) - 1; idx >= 0 && pattern[idx] == '*' { 140 + return len(name) >= idx && name[:idx] == pattern[:idx] 141 + } 142 + return false 143 + } 144 + return matched 145 + }
+149
internal/policy/policy_test.go
··· 1 + package policy 2 + 3 + import ( 4 + "testing" 5 + 6 + "github.com/slanos/turnscale/internal/config" 7 + "github.com/slanos/turnscale/internal/identity" 8 + ) 9 + 10 + func testPolicies() []config.Policy { 11 + return []config.Policy{ 12 + { 13 + Name: "admin-full-access", 14 + Match: config.Match{Identity: []string{"scott@github"}}, 15 + Allow: []string{"*"}, 16 + }, 17 + { 18 + Name: "ai-agents", 19 + Match: config.Match{Tags: []string{"tag:ai-agent"}}, 20 + Allow: []string{"gitea", "nomad", "matrix"}, 21 + DenyTools: []string{"mcp__gitea__delete_*"}, 22 + }, 23 + { 24 + Name: "default-deny", 25 + Match: config.Match{Identity: []string{"*"}}, 26 + Deny: []string{"*"}, 27 + }, 28 + } 29 + } 30 + 31 + func TestEvalServerAdmin(t *testing.T) { 32 + e := NewEngine(testPolicies()) 33 + caller := &identity.Caller{UserLogin: "scott@github", Node: "little-mac"} 34 + 35 + for _, server := range []string{"gitea", "nomad", "matrix", "anything"} { 36 + if d := e.EvalServer(caller, server); d != Allow { 37 + t.Errorf("admin access to %q = %v, want Allow", server, d) 38 + } 39 + } 40 + } 41 + 42 + func TestEvalServerTaggedAgent(t *testing.T) { 43 + e := NewEngine(testPolicies()) 44 + caller := &identity.Caller{ 45 + Node: "owl", 46 + Tags: []string{"tag:ai-agent"}, 47 + IsTagged: true, 48 + } 49 + 50 + if d := e.EvalServer(caller, "gitea"); d != Allow { 51 + t.Error("agent access to gitea: want Allow") 52 + } 53 + if d := e.EvalServer(caller, "nomad"); d != Allow { 54 + t.Error("agent access to nomad: want Allow") 55 + } 56 + if d := e.EvalServer(caller, "omada"); d != Deny { 57 + t.Error("agent access to omada: want Deny (not in allow list)") 58 + } 59 + } 60 + 61 + func TestEvalServerDefaultDeny(t *testing.T) { 62 + e := NewEngine(testPolicies()) 63 + caller := &identity.Caller{UserLogin: "stranger@github", Node: "unknown"} 64 + 65 + if d := e.EvalServer(caller, "gitea"); d != Deny { 66 + t.Error("stranger access to gitea: want Deny") 67 + } 68 + } 69 + 70 + func TestEvalToolDenyPattern(t *testing.T) { 71 + e := NewEngine(testPolicies()) 72 + caller := &identity.Caller{ 73 + Node: "owl", 74 + Tags: []string{"tag:ai-agent"}, 75 + IsTagged: true, 76 + } 77 + 78 + if d := e.EvalTool(caller, "gitea", "mcp__gitea__list_repos"); d != Allow { 79 + t.Error("agent gitea list_repos: want Allow") 80 + } 81 + if d := e.EvalTool(caller, "gitea", "mcp__gitea__delete_branch"); d != Deny { 82 + t.Error("agent gitea delete_branch: want Deny") 83 + } 84 + if d := e.EvalTool(caller, "gitea", "mcp__gitea__delete_file"); d != Deny { 85 + t.Error("agent gitea delete_file: want Deny") 86 + } 87 + } 88 + 89 + func TestDeniedTools(t *testing.T) { 90 + e := NewEngine(testPolicies()) 91 + agent := &identity.Caller{ 92 + Node: "owl", 93 + Tags: []string{"tag:ai-agent"}, 94 + IsTagged: true, 95 + } 96 + admin := &identity.Caller{UserLogin: "scott@github", Node: "little-mac"} 97 + 98 + if tools := e.DeniedTools(agent, "gitea"); len(tools) != 1 { 99 + t.Errorf("agent denied tools for gitea = %v, want 1 pattern", tools) 100 + } 101 + if tools := e.DeniedTools(admin, "gitea"); len(tools) != 0 { 102 + t.Errorf("admin denied tools for gitea = %v, want 0", tools) 103 + } 104 + } 105 + 106 + func TestFirstMatch(t *testing.T) { 107 + e := NewEngine(testPolicies()) 108 + 109 + admin := &identity.Caller{UserLogin: "scott@github", Node: "little-mac"} 110 + if idx := e.FirstMatch(admin); idx != 0 { 111 + t.Errorf("admin FirstMatch = %d, want 0", idx) 112 + } 113 + 114 + agent := &identity.Caller{Node: "owl", Tags: []string{"tag:ai-agent"}, IsTagged: true} 115 + if idx := e.FirstMatch(agent); idx != 1 { 116 + t.Errorf("agent FirstMatch = %d, want 1", idx) 117 + } 118 + 119 + stranger := &identity.Caller{UserLogin: "stranger@github", Node: "unknown"} 120 + if idx := e.FirstMatch(stranger); idx != 2 { 121 + t.Errorf("stranger FirstMatch = %d, want 2 (default-deny matches *)", idx) 122 + } 123 + 124 + // Empty caller with no matching policies (impossible with default-deny, but test logic) 125 + noMatch := NewEngine([]config.Policy{ 126 + {Name: "specific", Match: config.Match{Identity: []string{"specific@user"}}}, 127 + }) 128 + if idx := noMatch.FirstMatch(stranger); idx != -1 { 129 + t.Errorf("no-match FirstMatch = %d, want -1", idx) 130 + } 131 + } 132 + 133 + func TestMatchGlob(t *testing.T) { 134 + tests := []struct { 135 + pattern, name string 136 + want bool 137 + }{ 138 + {"mcp__gitea__delete_*", "mcp__gitea__delete_branch", true}, 139 + {"mcp__gitea__delete_*", "mcp__gitea__delete_file", true}, 140 + {"mcp__gitea__delete_*", "mcp__gitea__list_repos", false}, 141 + {"exact_match", "exact_match", true}, 142 + {"exact_match", "not_match", false}, 143 + } 144 + for _, tt := range tests { 145 + if got := matchGlob(tt.pattern, tt.name); got != tt.want { 146 + t.Errorf("matchGlob(%q, %q) = %v, want %v", tt.pattern, tt.name, got, tt.want) 147 + } 148 + } 149 + }
+726
internal/ui/templates.go
··· 1 + package ui 2 + 3 + import ( 4 + "html/template" 5 + "strings" 6 + ) 7 + 8 + var funcMap = template.FuncMap{ 9 + "initial": initial, 10 + "join": strings.Join, 11 + "sub": func(a, b int) int { return a - b }, 12 + "inc": func(i int) int { return i + 1 }, 13 + "half": func(n int) int { return n / 2 }, 14 + "hasRec": func(recIDs map[int64]bool, id int64) bool { return recIDs[id] }, 15 + } 16 + 17 + var dashboardTmpl = template.Must(template.New("dashboard").Funcs(funcMap).Parse(dashboardHTML)) 18 + var sessionTmpl = template.Must(template.New("session").Parse(sessionHTML)) 19 + 20 + const dashboardHTML = `<!DOCTYPE html> 21 + <html lang="en"> 22 + <head> 23 + <meta charset="UTF-8"> 24 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 25 + <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><circle cx='16' cy='16' r='14' fill='%239ece6a'/></svg>"> 26 + <title>Turnscale</title> 27 + <style> 28 + :root { 29 + --bg: #131315; --bg-raised: #1c1c1f; --bg-hover: #202024; 30 + --text: #d4d4d8; --text-dim: #a1a1a8; --text-muted: #7e7e88; 31 + --border: #27272a; --border-hover: #3f3f46; 32 + --accent: #7aa2f7; --ok: #9ece6a; --err: #f7768e; --warn: #e0af68; 33 + } 34 + * { margin: 0; padding: 0; box-sizing: border-box; } 35 + body { 36 + font-family: -apple-system, system-ui, sans-serif; 37 + background: var(--bg); color: var(--text); line-height: 1.5; font-size: 14px; 38 + } 39 + a { color: var(--accent); text-decoration: none; } 40 + a:hover { text-decoration: underline; } 41 + .wrap { max-width: 1000px; margin: 0 auto; padding: 24px; } 42 + input, select { 43 + font-family: inherit; font-size: 13px; padding: 6px 10px; 44 + background: var(--bg-raised); color: var(--text); border: 1px solid var(--border); border-radius: 4px; 45 + } 46 + input:focus-visible, select:focus-visible { 47 + outline: 2px solid var(--accent); outline-offset: 1px; 48 + } 49 + button, .btn { 50 + font-family: inherit; font-size: 13px; padding: 6px 14px; 51 + background: var(--bg-raised); color: var(--text); border: 1px solid var(--border); border-radius: 4px; 52 + cursor: pointer; 53 + } 54 + button:hover, .btn:hover { background: var(--bg-hover); border-color: var(--border-hover); } 55 + button:focus-visible, .btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; } 56 + .btn-danger { color: var(--err); border-color: #4a2020; } 57 + .btn-danger:hover { background: #2a1515; } 58 + @media (prefers-reduced-motion: reduce) { 59 + *, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } 60 + } 61 + 62 + /* Header */ 63 + .hdr { 64 + display: flex; justify-content: space-between; align-items: baseline; 65 + margin-bottom: 24px; padding-bottom: 16px; border-bottom: 1px solid var(--border); 66 + } 67 + .hdr h1 { font-size: 16px; font-weight: 600; color: #f0f0f2; } 68 + .hdr h1 span { font-weight: 400; color: var(--text-muted); } 69 + .hdr nav { display: flex; gap: 16px; font-size: 13px; } 70 + .hdr nav a { color: var(--text-muted); } 71 + .hdr nav a:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 2px; } 72 + .hdr nav a.on { color: var(--text); } 73 + 74 + .who { font-size: 13px; color: var(--text-dim); margin-bottom: 32px; } 75 + .who strong { color: var(--text); font-weight: 500; } 76 + .who .admin { color: var(--accent); font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; } 77 + .who .grants { color: var(--ok); font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; } 78 + .who .tag { color: var(--warn); } 79 + 80 + .label { 81 + font-size: 11px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; 82 + letter-spacing: 0.08em; margin-bottom: 12px; 83 + } 84 + 85 + /* Chart */ 86 + .chart-wrap { 87 + display: flex; gap: 0; margin-bottom: 4px; 88 + } 89 + .chart-yaxis { 90 + display: flex; flex-direction: column; justify-content: space-between; 91 + align-items: flex-end; padding-right: 6px; 92 + font-size: 9px; color: var(--text-muted); height: 120px; 93 + min-width: 28px; 94 + } 95 + .chart { 96 + display: flex; align-items: flex-end; gap: 2px; height: 120px; 97 + flex: 1; min-width: 0; 98 + } 99 + .chart-bar { 100 + flex: 1; min-width: 0; 101 + position: relative; height: 100%; 102 + display: flex; flex-direction: column; justify-content: flex-end; 103 + cursor: default; 104 + } 105 + .chart-bar .ok-part { 106 + background: #9ece6a; border-radius: 2px 2px 0 0; opacity: 0.6; 107 + } 108 + .chart-bar .err-part { background: #f7768e; opacity: 0.8; } 109 + .chart-bar .deny-part { background: #e0af68; opacity: 0.8; } 110 + .chart-bar:hover .ok-part { opacity: 1; } 111 + .chart-bar:hover .err-part { opacity: 1; } 112 + .chart-bar:hover .deny-part { opacity: 1; } 113 + .chart-tip { 114 + display: none; position: absolute; bottom: calc(100% + 6px); 115 + left: 0; z-index: 10; white-space: nowrap; 116 + background: var(--bg-raised); border: 1px solid var(--border-hover); border-radius: 4px; 117 + padding: 4px 8px; font-size: 11px; color: var(--text); pointer-events: none; 118 + max-width: 320px; 119 + } 120 + .chart-tip .ct-h { color: #f0f0f2; font-weight: 600; } 121 + .chart-tip .ct-ok { color: var(--ok); } 122 + .chart-tip .ct-err { color: var(--err); } 123 + .chart-tip .ct-deny { color: #e0af68; } 124 + .chart-tip .ct-row { 125 + color: var(--text-dim); font-size: 10px; margin-top: 2px; 126 + overflow: hidden; text-overflow: ellipsis; 127 + } 128 + .chart-tip .ct-cnt { color: var(--ok); float: right; margin-left: 8px; } 129 + .chart-bar:hover .chart-tip { display: block; } 130 + /* Keep tooltip on screen for right-edge bars */ 131 + .chart-bar:nth-last-child(-n+4) .chart-tip { left: auto; right: 0; } 132 + .chart-totals { 133 + display: flex; gap: 16px; font-size: 11px; color: var(--text-dim); margin-bottom: 6px; 134 + } 135 + .chart-totals .ct-val { font-weight: 600; } 136 + .chart-labels { 137 + display: flex; gap: 2px; font-size: 9px; color: var(--text-muted); margin-bottom: 24px; 138 + padding-left: 34px; 139 + } 140 + .chart-labels span { flex: 1; text-align: center; } 141 + 142 + /* Servers */ 143 + .srv-tbl { width: 100%; border-collapse: collapse; font-size: 12px; table-layout: fixed; } 144 + .srv-tbl th { 145 + text-align: left; padding: 5px 6px; color: var(--text-muted); font-weight: 600; 146 + font-size: 10px; text-transform: uppercase; letter-spacing: 0.05em; 147 + border-bottom: 1px solid var(--border); 148 + } 149 + .srv-tbl td { padding: 4px 6px; border-bottom: 1px solid var(--border); color: var(--text-dim); vertical-align: middle; font-variant-numeric: tabular-nums; } 150 + .srv-tbl tr:hover td { color: var(--text); } 151 + .srv-tbl .sn { color: var(--text); font-weight: 500; } 152 + .srv-tbl .srv-down .sn { color: var(--err); } 153 + .srv-tbl .tools-cell { 154 + font-family: ui-monospace, monospace; font-size: 11px; color: var(--text-muted); 155 + max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; 156 + } 157 + .srv-tbl .tools-cell .denied { text-decoration: line-through; color: var(--err); } 158 + .srv-tbl .srv-main { cursor: pointer; } 159 + .srv-tbl .srv-main:hover td { background: var(--bg-raised); } 160 + .srv-tbl .srv-main:focus-visible td { outline: 2px solid var(--accent); } 161 + .srv-expand td { border-bottom: none; padding: 0; } 162 + .srv-expand { overflow: hidden; } 163 + .srv-expand-inner > div { overflow: hidden; } 164 + .srv-expand-inner { 165 + display: grid; grid-template-rows: 0fr; 166 + transition: grid-template-rows 0.2s ease-out; 167 + } 168 + .srv-expand-inner.open { grid-template-rows: 1fr; } 169 + .srv-expand-inner > div { overflow: hidden; } 170 + .srv-tools-full { 171 + padding: 8px 6px; font-family: ui-monospace, monospace; font-size: 11px; 172 + color: var(--text-dim); line-height: 1.8; word-break: break-all; 173 + } 174 + .srv-tools-full .denied { text-decoration: line-through; color: var(--err); } 175 + .srv-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; } 176 + .srv-dot.up { background: var(--ok); } 177 + .srv-dot.down { background: var(--err); } 178 + .ok { color: var(--ok); } 179 + .no { color: var(--err); } 180 + .dim { color: var(--text-muted); } 181 + .icon-btn { 182 + background: none; border: none; cursor: pointer; padding: 6px; 183 + color: var(--text-muted); font-size: 14px; line-height: 1; border-radius: 3px; 184 + min-width: 28px; min-height: 28px; display: inline-flex; align-items: center; justify-content: center; 185 + } 186 + .icon-btn:hover { color: var(--text); background: var(--bg-raised); } 187 + .icon-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; } 188 + .icon-btn.danger:hover { color: var(--err); background: #1a1111; } 189 + .srv-acts { white-space: nowrap; text-align: right; } 190 + 191 + .tools-hidden { display: none; } 192 + 193 + .srv-err { 194 + font-size: 12px; color: #f7768e; padding-left: 18px; margin-top: 6px; 195 + background: #1a1111; border: 1px solid #332222; border-radius: 4px; 196 + padding: 8px 12px 8px 18px; font-family: ui-monospace, monospace; word-break: break-all; 197 + } 198 + .srv-url { font-size: 11px; color: var(--text-muted); padding-left: 18px; font-family: ui-monospace, monospace; } 199 + 200 + /* Server management */ 201 + .srv-actions { padding-left: 18px; margin-top: 8px; display: flex; gap: 6px; } 202 + .edit-form { 203 + padding: 8px 0; margin-top: 6px; border-top: 1px solid var(--border); 204 + } 205 + .edit-form .row { display: flex; gap: 8px; align-items: center; margin-bottom: 8px; } 206 + .edit-form .row:last-child { margin-bottom: 0; } 207 + .edit-form label { font-size: 12px; color: var(--text-muted); min-width: 70px; } 208 + .edit-form input { flex: 1; } 209 + 210 + /* Add server */ 211 + .add-srv { margin-top: 8px; } 212 + .add-srv summary { 213 + font-size: 13px; color: var(--text-muted); cursor: pointer; list-style: none; 214 + } 215 + .add-srv summary::-webkit-details-marker { display: none; } 216 + .add-srv summary::before { content: '+ '; color: #7aa2f7; } 217 + .add-srv .form-row { display: flex; gap: 8px; align-items: center; margin-top: 8px; } 218 + .add-srv input { width: 180px; } 219 + 220 + /* Policies */ 221 + .pols { margin-top: 32px; } 222 + .pol { padding: 8px 0; font-size: 13px; } 223 + .pol-name { color: var(--text); font-weight: 500; } 224 + .pol.you .pol-name { color: var(--accent); } 225 + .pol .you-tag { 226 + font-size: 10px; font-weight: 600; text-transform: uppercase; 227 + color: var(--accent); letter-spacing: 0.04em; margin-left: 6px; 228 + } 229 + .pol-detail { color: var(--text-muted); font-size: 12px; padding-left: 24px; } 230 + .pol-detail code { font-family: ui-monospace, monospace; font-size: 11px; color: var(--text-dim); } 231 + 232 + /* Activity */ 233 + .activity { margin-top: 32px; } 234 + table { width: 100%; border-collapse: collapse; font-size: 12px; } 235 + th { 236 + text-align: left; padding: 6px 8px; color: var(--text-muted); font-weight: 600; 237 + font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; 238 + border-bottom: 1px solid var(--border); 239 + } 240 + td { padding: 5px 8px; border-bottom: 1px solid var(--border); color: var(--text-dim); font-variant-numeric: tabular-nums; } 241 + tr:hover td { color: var(--text); } 242 + td.ts { color: var(--text-muted); white-space: nowrap; } 243 + td.mono { font-family: ui-monospace, monospace; font-size: 11px; } 244 + .s-ok { color: var(--ok); } 245 + .s-error { color: var(--err); } 246 + .s-denied { color: var(--warn); } 247 + .empty { padding: 24px 8px; color: var(--text-muted); } 248 + .activity tr { cursor: pointer; } 249 + .activity tr:hover td { background: var(--bg-raised); } 250 + 251 + /* Refresh indicator */ 252 + .refresh-dot { 253 + width: 6px; height: 6px; border-radius: 50%; background: var(--ok); 254 + display: inline-block; margin-left: 6px; transition: background 0.3s; 255 + animation: alive 3s ease-in-out infinite; 256 + } 257 + .refresh-dot.loading { background: var(--accent); animation: pulse 0.8s infinite; } 258 + .refresh-dot.err { background: var(--err); animation: none; } 259 + @keyframes alive { 0%,100%{opacity:1}50%{opacity:0.4} } 260 + @keyframes pulse { 0%,100%{opacity:1}50%{opacity:0.3} } 261 + 262 + /* Data update animation */ 263 + .fade-update { animation: fadeIn 0.3s ease-out; } 264 + @keyframes fadeIn { from{opacity:0.5}to{opacity:1} } 265 + 266 + /* Footer */ 267 + .footer { 268 + margin-top: 48px; padding: 24px 0; 269 + display: flex; justify-content: center; align-items: center; 270 + font-size: 11px; color: var(--text-muted); 271 + } 272 + .footer a { color: var(--text-dim); } 273 + .footer a:hover { color: var(--text); } 274 + 275 + /* Modal */ 276 + .modal-bg { 277 + display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.7); 278 + z-index: 100; justify-content: center; align-items: center; 279 + } 280 + .modal-bg.open { display: flex; } 281 + .modal { 282 + background: var(--bg-raised); border: 1px solid var(--border-hover); border-radius: 6px; 283 + max-width: 700px; width: 90%; max-height: 80vh; overflow-y: auto; 284 + padding: 20px; 285 + } 286 + .modal-title { font-size: 14px; font-weight: 600; color: #f0f0f2; margin-bottom: 12px; display: flex; justify-content: space-between; } 287 + .modal-close { background: none; border: none; color: var(--text-muted); font-size: 18px; cursor: pointer; padding: 4px 8px; } 288 + .modal-close:hover { color: var(--text); } 289 + .modal-row { display: flex; gap: 8px; padding: 4px 0; font-size: 12px; border-bottom: 1px solid var(--border); } 290 + .modal-row:last-child { border-bottom: none; } 291 + .modal-lbl { color: var(--text-muted); min-width: 80px; font-weight: 500; text-transform: uppercase; font-size: 10px; letter-spacing: 0.04em; padding-top: 2px; } 292 + .modal-val { color: var(--text); word-break: break-all; } 293 + .modal-val.mono { font-family: ui-monospace, monospace; font-size: 11px; } 294 + .modal-err { 295 + background: #1a1111; border: 1px solid #332222; border-radius: 4px; 296 + padding: 8px 10px; font-family: ui-monospace, monospace; font-size: 11px; 297 + color: #f7768e; white-space: pre-wrap; word-break: break-all; max-height: 200px; 298 + overflow-y: auto; 299 + } 300 + 301 + footer { margin-top: 48px; font-size: 11px; color: var(--text-muted); } 302 + </style> 303 + </head> 304 + <body> 305 + <div class="wrap"> 306 + 307 + <div class="hdr"> 308 + <h1>Turnscale <span>/ {{.Hostname}}</span> {{if .TotalRequests}}<span style="font-size:11px;color:var(--text-muted);font-weight:400">&middot; {{.TotalRequests}} requests proxied</span>{{end}} <span class="refresh-dot" id="refresh-dot" style="position:relative;top:-1px"></span></h1> 309 + <nav> 310 + <a href="/ui/" class="on">dashboard</a> 311 + {{if .IsAdmin}}<a href="/ui/audit">audit</a>{{end}} 312 + </nav> 313 + </div> 314 + 315 + <div class="who"> 316 + <strong>{{if .Caller.IsTagged}}{{.Caller.Node}}{{else}}{{.Caller.DisplayName}}{{end}}</strong> 317 + {{if not .Caller.IsTagged}} &middot; {{.Caller.UserLogin}}{{end}} 318 + &middot; {{.Caller.Node}} 319 + {{if .Caller.TailscaleIP}} &middot; {{.Caller.TailscaleIP}}{{end}} 320 + {{if .IsAdmin}} &middot; <span class="admin">admin</span>{{end}} 321 + {{if .HasGrants}} &middot; <span class="grants">tailscale grants</span>{{end}} 322 + {{range .Caller.Tags}} &middot; <span class="tag">{{.}}</span>{{end}} 323 + </div> 324 + 325 + {{if and .IsAdmin .Chart}} 326 + <div id="section-chart"> 327 + <div class="label">Requests &middot; last 24h</div> 328 + <div class="chart-totals"> 329 + <span><span class="ct-val" style="color:var(--ok)">{{.ChartTotal}}</span> total</span> 330 + {{if .ChartErrors}}<span><span class="ct-val" style="color:var(--err)">{{.ChartErrors}}</span> errors</span>{{end}} 331 + {{if .ChartDenied}}<span><span class="ct-val" style="color:#e0af68">{{.ChartDenied}}</span> denied</span>{{end}} 332 + </div> 333 + <div class="chart-wrap"> 334 + <div class="chart-yaxis"> 335 + <span>{{.ChartMax}}</span> 336 + <span>{{half .ChartMax}}</span> 337 + <span>0</span> 338 + </div> 339 + <div class="chart"> 340 + {{range .Chart}} 341 + <div class="chart-bar"> 342 + <div class="chart-tip"> 343 + <span class="ct-h">{{.Label}}:00</span> &mdash; <span class="ct-ok">{{.Total}}</span> req{{if .Errors}}, <span class="ct-err">{{.Errors}} err</span>{{end}}{{if .Denied}}, <span class="ct-deny">{{.Denied}} denied</span>{{end}} 344 + {{range .Callers}}<div class="ct-row">{{.Caller}} &rarr; {{.Server}} <span class="ct-cnt">{{.Count}}</span></div>{{end}} 345 + </div> 346 + <div class="ok-part" style="height:{{.Height}}%"></div> 347 + {{if .DenyH}}<div class="deny-part" style="height:{{.DenyH}}%"></div>{{end}} 348 + {{if .ErrH}}<div class="err-part" style="height:{{.ErrH}}%"></div>{{end}} 349 + </div> 350 + {{end}} 351 + </div> 352 + </div> 353 + <div class="chart-labels"> 354 + {{range .Chart}}{{if or (eq .Label "00") (eq .Label "06") (eq .Label "12") (eq .Label "18")}}<span>{{.Label}}</span>{{else}}<span></span>{{end}}{{end}} 355 + </div> 356 + </div> 357 + {{end}} 358 + 359 + <div id="section-servers"> 360 + <div class="label" style="display:flex;align-items:center;justify-content:space-between"> 361 + <span>Servers &middot; {{.HealthyCount}}/{{len .Servers}} healthy &middot; {{.TotalTools}} tools</span> 362 + <input id="tool-search" type="text" placeholder="search tools..." oninput="filterTools(this.value)" style="font-size:11px;padding:3px 8px;width:160px;background:var(--bg-raised);color:var(--text);border:1px solid var(--border);border-radius:3px"> 363 + </div> 364 + 365 + {{if not .Servers}} 366 + <div style="padding:16px;background:#1a1511;border:1px solid #332a22;border-radius:4px;color:#e0af68;font-size:13px;margin-bottom:16px"> 367 + No servers configured. <a href="#" onclick="document.querySelector('.add-srv details').open=true;return false">Add a server</a> or edit <code>gateway.yaml</code>. 368 + </div> 369 + {{else if eq .TotalTools 0}} 370 + <div style="padding:16px;background:#1a1511;border:1px solid #332a22;border-radius:4px;color:#e0af68;font-size:13px;margin-bottom:16px"> 371 + No tools discovered. Servers are configured but none returned tools from MCP <code>tools/list</code>. Check that backends are running and responding to MCP protocol. 372 + </div> 373 + {{end}} 374 + 375 + <table class="srv-tbl"> 376 + <thead> 377 + <tr> 378 + <th style="width:20px"></th> 379 + <th style="width:180px">Name</th> 380 + <th style="width:60px">Access</th> 381 + <th>Tools</th> 382 + {{if .IsAdmin}}<th style="width:100px;text-align:right">24h</th><th style="width:30px"></th>{{end}} 383 + </tr> 384 + </thead> 385 + <tbody> 386 + {{range .Servers}} 387 + <tr class="srv-main{{if not .Healthy}} srv-down{{end}}" onclick="var el=this.nextElementSibling;if(el&&el.classList.contains('srv-expand')){el.querySelector('.srv-expand-inner').classList.toggle('open')}"> 388 + <td><span class="srv-dot {{if .Healthy}}up{{else}}down{{end}}"></span></td> 389 + <td class="sn">{{.Name}} <span class="dim" style="font-weight:400">{{.Transport}}</span></td> 390 + <td>{{if .Allowed}}<span class="ok">granted</span>{{else}}<span class="no">denied</span>{{end}}</td> 391 + <td class="tools-cell"> 392 + {{- if .ToolCount}}<span class="dim">{{.ToolCount}}</span> {{end -}} 393 + {{- range $i, $t := .Tools -}} 394 + {{- if lt $i 4 -}} 395 + {{- if $i}}, {{end -}} 396 + <span{{if $t.Denied}} class="denied"{{end}}>{{$t.Name}}</span> 397 + {{- end -}} 398 + {{- end -}} 399 + {{- if gt .ToolCount 4}} ...{{end -}} 400 + </td> 401 + {{if $.IsAdmin}} 402 + <td class="dim" style="text-align:right">{{if .Stats}}{{.Stats.Total}} / {{printf "%.0f" .Stats.AvgLatency}}ms{{else}}-{{end}}</td> 403 + <td style="text-align:right" onclick="event.stopPropagation()"> 404 + <form method="POST" action="/ui/servers/delete" style="display:inline" onsubmit="return confirm('Remove server {{.Name}}? This will delete it from gateway.yaml.')"> 405 + <input type="hidden" name="name" value="{{.Name}}"> 406 + <button type="submit" class="icon-btn danger" title="Remove" onclick="event.stopPropagation()">&#10005;</button> 407 + </form> 408 + </td> 409 + {{end}} 410 + </tr> 411 + <tr class="srv-expand"><td></td><td colspan="{{if $.IsAdmin}}5{{else}}3{{end}}"> 412 + <div class="srv-expand-inner"> 413 + <div> 414 + {{if .Error}}<div class="srv-err">{{.Error}}</div>{{if not .Healthy}}<div class="srv-url">{{.URL}}</div>{{end}}{{end}} 415 + {{if .Tools}} 416 + <div class="srv-tools-full"> 417 + {{- range $i, $t := .Tools -}} 418 + {{- if $i}}, {{end -}} 419 + <span{{if $t.Denied}} class="denied"{{end}}>{{$t.Name}}</span> 420 + {{- end -}} 421 + </div> 422 + {{end}} 423 + {{if $.IsAdmin}} 424 + <div class="edit-form"> 425 + <form method="POST" action="/ui/servers/edit" onclick="event.stopPropagation()"> 426 + <input type="hidden" name="name" value="{{.Name}}"> 427 + <div class="row"> 428 + <label>URL</label> 429 + <input name="url" value="{{.URL}}" placeholder="http://localhost:8091/mcp"> 430 + </div> 431 + <div class="row"> 432 + <label>Transport</label> 433 + <select name="transport"> 434 + <option value="streamable-http"{{if eq .Transport "streamable-http"}} selected{{end}}>streamable-http</option> 435 + <option value="sse"{{if eq .Transport "sse"}} selected{{end}}>sse</option> 436 + <option value="stdio"{{if eq .Transport "stdio"}} selected{{end}}>stdio</option> 437 + </select> 438 + <button type="submit">save</button> 439 + </div> 440 + </form> 441 + </div> 442 + {{end}} 443 + </div> 444 + </div> 445 + </td></tr> 446 + {{end}} 447 + </tbody> 448 + </table> 449 + 450 + {{if .IsAdmin}} 451 + <div class="add-srv"> 452 + <details> 453 + <summary>Add server</summary> 454 + <form method="POST" action="/ui/servers"> 455 + <div class="form-row"> 456 + <input name="name" placeholder="name" required> 457 + <input name="url" placeholder="http://localhost:PORT/mcp" required> 458 + <select name="transport"> 459 + <option value="streamable-http" selected>streamable-http</option> 460 + <option value="sse">sse</option> 461 + <option value="stdio">stdio</option> 462 + </select> 463 + <button type="submit">add</button> 464 + </div> 465 + </form> 466 + </details> 467 + </div> 468 + {{end}} 469 + </div> 470 + 471 + <div class="pols"> 472 + <div class="label">Access Policies &middot; first match wins</div> 473 + {{range $i, $p := .Policies}} 474 + <div class="pol{{if $p.IsActive}} you{{end}}"> 475 + <span class="pol-name">{{$p.Name}}</span> 476 + {{if $p.IsActive}}<span class="you-tag">you</span>{{end}} 477 + <div class="pol-detail"> 478 + {{if $p.Identity}}match {{range $j, $v := $p.Identity}}{{if $j}}, {{end}}<code>{{$v}}</code>{{end}}{{end -}} 479 + {{if $p.Tags}}tags {{range $j, $v := $p.Tags}}{{if $j}}, {{end}}<code>{{$v}}</code>{{end}}{{end -}} 480 + {{if $p.Allow}} &rarr; allow {{range $j, $v := $p.Allow}}{{if $j}}, {{end}}<code>{{$v}}</code>{{end}}{{end -}} 481 + {{if $p.Deny}} &rarr; deny {{range $j, $v := $p.Deny}}{{if $j}}, {{end}}<code>{{$v}}</code>{{end}}{{end -}} 482 + {{if $p.DenyTools}} &middot; deny tools {{range $j, $v := $p.DenyTools}}{{if $j}}, {{end}}<code>{{$v}}</code>{{end}}{{end}} 483 + </div> 484 + </div> 485 + {{end}} 486 + </div> 487 + 488 + {{if .IsAdmin}} 489 + <div class="activity" id="section-activity"> 490 + <div class="label">Recent Activity</div> 491 + {{if .RecentAudit}} 492 + <table> 493 + <thead> 494 + <tr><th>Time</th><th>Caller</th><th>Server</th><th>Method</th><th>Tool</th><th>ms</th><th>Status</th></tr> 495 + </thead> 496 + <tbody> 497 + {{range .RecentAudit}} 498 + <tr onclick="showModal(this)" data-ts="{{.Timestamp}}" data-caller="{{.Caller}}" data-ip="{{.CallerIP}}" data-node="{{.Node}}" data-tags="{{.Tags}}" data-server="{{.Server}}" data-method="{{.Method}}" data-tool="{{.Tool}}" data-latency="{{.LatencyMs}}" data-status="{{.Status}}" data-error="{{.Error}}" data-id="{{.ID}}" data-has-rec="{{hasRec $.RecIDs .ID}}"> 499 + <td class="ts">{{.Timestamp}}</td> 500 + <td>{{.Caller}}</td> 501 + <td>{{.Server}}</td> 502 + <td>{{.Method}}</td> 503 + <td class="mono">{{.Tool}}</td> 504 + <td>{{.LatencyMs}}</td> 505 + <td class="s-{{.Status}}">{{.Status}}</td> 506 + </tr> 507 + {{end}} 508 + </tbody> 509 + </table> 510 + {{else}} 511 + <div class="empty">No recent activity</div> 512 + {{end}} 513 + </div> 514 + {{end}} 515 + 516 + <div class="footer"> 517 + <a href="https://tangled.sh/scottlanoue.com/turnscale" target="_blank">Turnscale v{{.Version}}</a> 518 + </div> 519 + 520 + </div> 521 + 522 + <div class="modal-bg" id="modal" onclick="if(event.target===this)this.classList.remove('open')"> 523 + <div class="modal"> 524 + <div class="modal-title"> 525 + <span id="modal-title">Request Detail</span> 526 + <button class="modal-close" onclick="document.getElementById('modal').classList.remove('open')">&times;</button> 527 + </div> 528 + <div id="modal-body"></div> 529 + </div> 530 + </div> 531 + 532 + <script> 533 + function esc(s){return s?s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'):''} 534 + function showModal(tr) { 535 + var d = tr.dataset; 536 + var rows = [ 537 + ['Time', esc(d.ts)], 538 + ['Caller', esc(d.caller)], 539 + ['Node', esc(d.node)], 540 + ['IP', esc(d.ip)], 541 + ['Server', esc(d.server)], 542 + ['Method', esc(d.method)], 543 + ]; 544 + if (d.tool) rows.push(['Tool', esc(d.tool)]); 545 + rows.push(['Latency', esc(d.latency) + 'ms']); 546 + rows.push(['Status', '<span class="s-' + esc(d.status) + '">' + esc(d.status) + '</span>']); 547 + if (d.tags) rows.push(['Tags', esc(d.tags)]); 548 + 549 + var html = ''; 550 + for (var i = 0; i < rows.length; i++) { 551 + var cls = (rows[i][0] === 'Tool') ? ' mono' : ''; 552 + html += '<div class="modal-row"><span class="modal-lbl">' + rows[i][0] + '</span><span class="modal-val' + cls + '">' + rows[i][1] + '</span></div>'; 553 + } 554 + if (d.error) { 555 + html += '<div class="modal-row"><span class="modal-lbl">Error</span><div class="modal-err">' + esc(d.error) + '</div></div>'; 556 + } 557 + if (d.hasRec === 'true') { 558 + html += '<div class="modal-row"><span class="modal-lbl">Recording</span><span class="modal-val"><a href="/ui/session/' + esc(d.id) + '">View full session</a></span></div>'; 559 + } 560 + document.getElementById('modal-title').textContent = d.method + (d.tool ? ' / ' + d.tool : '') + ' → ' + d.server; 561 + document.getElementById('modal-body').innerHTML = html; 562 + document.getElementById('modal').classList.add('open'); 563 + } 564 + document.addEventListener('keydown', function(e) { 565 + if (e.key === 'Escape') document.getElementById('modal').classList.remove('open'); 566 + }); 567 + 568 + // Smart refresh — update sections without full page reload 569 + (function() { 570 + var dot = document.getElementById('refresh-dot'); 571 + var interval = 30000; 572 + 573 + function refresh() { 574 + if (document.getElementById('modal').classList.contains('open')) return; // skip if modal open 575 + dot.className = 'refresh-dot loading'; 576 + fetch('/ui/', {headers: {'Accept': 'text/html'}}) 577 + .then(function(r) { return r.text(); }) 578 + .then(function(html) { 579 + var parser = new DOMParser(); 580 + var doc = parser.parseFromString(html, 'text/html'); 581 + // Preserve search input 582 + var search = document.getElementById('tool-search'); 583 + var searchVal = search ? search.value : ''; 584 + // Update sections 585 + ['section-chart', 'section-activity'].forEach(function(id) { 586 + var fresh = doc.getElementById(id); 587 + var current = document.getElementById(id); 588 + if (fresh && current) { 589 + current.innerHTML = fresh.innerHTML; 590 + current.classList.add('fade-update'); 591 + setTimeout(function() { current.classList.remove('fade-update'); }, 300); 592 + } 593 + }); 594 + // Update server stats (just the header label) without replacing expanded state 595 + var freshLabel = doc.querySelector('#section-servers .label span'); 596 + var curLabel = document.querySelector('#section-servers .label span'); 597 + if (freshLabel && curLabel) curLabel.textContent = freshLabel.textContent; 598 + // Restore search 599 + if (searchVal) { 600 + var s = document.getElementById('tool-search'); 601 + if (s) { s.value = searchVal; filterTools(searchVal); } 602 + } 603 + dot.className = 'refresh-dot'; 604 + }) 605 + .catch(function() { 606 + dot.className = 'refresh-dot err'; 607 + }); 608 + } 609 + 610 + setInterval(refresh, interval); 611 + })(); 612 + function filterTools(q) { 613 + q = q.toLowerCase(); 614 + // Style matches in expanded tool lists 615 + document.querySelectorAll('.srv-tools-full span').forEach(function(el) { 616 + var txt = el.textContent.trim(); 617 + if (!txt || txt === ',') return; 618 + if (!q) { 619 + el.style.color = ''; el.style.opacity = ''; 620 + } else if (txt.toLowerCase().includes(q)) { 621 + el.style.color = '#9ece6a'; el.style.opacity = '1'; 622 + } else { 623 + el.style.color = ''; el.style.opacity = '0.15'; 624 + } 625 + }); 626 + // Style matches in collapsed tool cells 627 + document.querySelectorAll('.tools-cell span').forEach(function(el) { 628 + var txt = el.textContent.trim(); 629 + if (!txt || txt === '...') return; 630 + if (!q) { 631 + el.style.color = ''; el.style.opacity = ''; 632 + } else if (txt.toLowerCase().includes(q)) { 633 + el.style.color = '#9ece6a'; el.style.opacity = '1'; 634 + } else { 635 + el.style.color = ''; el.style.opacity = '0.15'; 636 + } 637 + }); 638 + // Auto-expand rows with matches 639 + document.querySelectorAll('.srv-expand-inner').forEach(function(el) { 640 + if (!q) { el.classList.remove('open'); return; } 641 + if (q.length < 2) return; 642 + var has = false; 643 + el.querySelectorAll('.srv-tools-full span').forEach(function(s) { 644 + if (s.textContent.toLowerCase().includes(q)) has = true; 645 + }); 646 + if (has) el.classList.add('open'); 647 + else el.classList.remove('open'); 648 + }); 649 + } 650 + </script> 651 + </body> 652 + </html> 653 + ` 654 + 655 + const sessionHTML = `<!DOCTYPE html> 656 + <html lang="en"> 657 + <head> 658 + <meta charset="UTF-8"> 659 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 660 + <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><circle cx='16' cy='16' r='14' fill='%239ece6a'/></svg>"> 661 + <title>Session Recording #{{.AuditID}}</title> 662 + <style> 663 + :root { 664 + --bg: #131315; --bg-raised: #1c1c1f; --bg-hover: #202024; 665 + --text: #d4d4d8; --text-dim: #a1a1a8; --text-muted: #7e7e88; 666 + --border: #27272a; --border-hover: #3f3f46; 667 + --accent: #7aa2f7; --ok: #9ece6a; --err: #f7768e; --warn: #e0af68; 668 + } 669 + * { margin: 0; padding: 0; box-sizing: border-box; } 670 + body { 671 + font-family: -apple-system, system-ui, sans-serif; 672 + background: var(--bg); color: var(--text); line-height: 1.5; font-size: 14px; 673 + } 674 + a { color: var(--accent); text-decoration: none; } 675 + a:hover { text-decoration: underline; } 676 + .wrap { max-width: 1000px; margin: 0 auto; padding: 24px; } 677 + .hdr { 678 + display: flex; justify-content: space-between; align-items: baseline; 679 + margin-bottom: 24px; padding-bottom: 16px; border-bottom: 1px solid var(--border); 680 + } 681 + .hdr h1 { font-size: 16px; font-weight: 600; color: var(--text); } 682 + .hdr h1 span { font-weight: 400; color: var(--text-muted); } 683 + .hdr nav { display: flex; gap: 16px; font-size: 13px; } 684 + .hdr nav a { color: var(--text-muted); } 685 + .meta { font-size: 13px; color: var(--text-dim); margin-bottom: 24px; } 686 + .meta strong { color: var(--text); font-weight: 500; } 687 + .label { 688 + font-size: 11px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; 689 + letter-spacing: 0.08em; margin-bottom: 8px; 690 + } 691 + .code-block { 692 + background: var(--bg-raised); border: 1px solid var(--border); border-radius: 4px; 693 + padding: 16px; margin-bottom: 24px; overflow-x: auto; 694 + font-family: ui-monospace, 'SF Mono', monospace; font-size: 12px; 695 + line-height: 1.6; color: var(--text-dim); white-space: pre-wrap; word-break: break-all; 696 + } 697 + .s-ok { color: var(--ok); } 698 + .s-error { color: var(--err); } 699 + .s-denied { color: var(--warn); } 700 + </style> 701 + </head> 702 + <body> 703 + <div class="wrap"> 704 + <div class="hdr"> 705 + <h1>Turnscale <span>/ session #{{.AuditID}}</span></h1> 706 + <nav> 707 + <a href="/ui/">dashboard</a> 708 + <a href="/ui/audit">audit</a> 709 + </nav> 710 + </div> 711 + <div class="meta"> 712 + <strong>{{.Row.Caller}}</strong> &middot; {{.Row.Server}} &middot; {{.Row.Method}} 713 + {{if .Row.Tool}} &middot; <code>{{.Row.Tool}}</code>{{end}} 714 + &middot; {{.Row.LatencyMs}}ms 715 + &middot; <span class="s-{{.Row.Status}}">{{.Row.Status}}</span> 716 + &middot; {{.Row.Timestamp}} 717 + </div> 718 + <div class="label">Request</div> 719 + <div class="code-block">{{.Request}}</div> 720 + <div class="label">Response</div> 721 + <div class="code-block">{{.Response}}</div> 722 + </div> 723 + </body> 724 + </html> 725 + ` 726 +
+680
internal/ui/ui.go
··· 1 + package ui 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "encoding/json" 7 + "fmt" 8 + "io" 9 + "log/slog" 10 + "net/http" 11 + "regexp" 12 + "sort" 13 + "strconv" 14 + "strings" 15 + "sync" 16 + "time" 17 + 18 + "github.com/slanos/turnscale/internal/audit" 19 + "github.com/slanos/turnscale/internal/config" 20 + "github.com/slanos/turnscale/internal/identity" 21 + "github.com/slanos/turnscale/internal/policy" 22 + ) 23 + 24 + // UI serves the web dashboard for the MCP gateway. 25 + type UI struct { 26 + cfg *config.Config 27 + ident identity.Identifier 28 + policy *policy.Engine 29 + audit *audit.Logger 30 + adminIDs map[string]bool 31 + 32 + mu sync.RWMutex // protects cfg and policy during updates 33 + } 34 + 35 + // New creates a new UI handler. 36 + func New(cfg *config.Config, ident identity.Identifier, pol *policy.Engine, aud *audit.Logger, adminIDs map[string]bool) *UI { 37 + return &UI{ 38 + cfg: cfg, 39 + ident: ident, 40 + policy: pol, 41 + audit: aud, 42 + adminIDs: adminIDs, 43 + } 44 + } 45 + 46 + // SetPolicy updates the policy engine (called after config changes). 47 + func (u *UI) SetPolicy(pol *policy.Engine) { 48 + u.mu.Lock() 49 + defer u.mu.Unlock() 50 + u.policy = pol 51 + } 52 + 53 + // Servers returns a snapshot copy of the server map (safe for concurrent use). 54 + func (u *UI) Servers() map[string]config.Server { 55 + u.mu.RLock() 56 + defer u.mu.RUnlock() 57 + cp := make(map[string]config.Server, len(u.cfg.Servers)) 58 + for k, v := range u.cfg.Servers { 59 + cp[k] = v 60 + } 61 + return cp 62 + } 63 + 64 + // Hostname returns the configured hostname. 65 + func (u *UI) Hostname() string { 66 + u.mu.RLock() 67 + defer u.mu.RUnlock() 68 + return u.cfg.Hostname 69 + } 70 + 71 + // ToolInfo holds a discovered tool from a backend server. 72 + type ToolInfo struct { 73 + Name string 74 + Description string 75 + Denied bool 76 + } 77 + 78 + // ServerStatus holds health, access, and tool info for a server. 79 + type ServerStatus struct { 80 + Name string 81 + URL string 82 + Transport string 83 + Healthy bool 84 + Allowed bool 85 + DeniedTools []string 86 + Tools []ToolInfo 87 + ToolCount int 88 + Error string 89 + Stats *audit.ServerStat 90 + } 91 + 92 + // PolicyView holds display info for a policy. 93 + type PolicyView struct { 94 + Name string 95 + Identity []string 96 + Tags []string 97 + Allow []string 98 + Deny []string 99 + DenyTools []string 100 + IsActive bool 101 + } 102 + 103 + // CallerStat holds a caller's request count for a chart tooltip. 104 + type CallerStat struct { 105 + Caller string 106 + Server string 107 + Count int 108 + } 109 + 110 + // ChartBar represents one bar in a chart. 111 + type ChartBar struct { 112 + Label string 113 + Total int 114 + Errors int 115 + Denied int 116 + Height int // percentage 0-100 117 + ErrH int // error height percentage 118 + DenyH int // denied height percentage 119 + Callers []CallerStat 120 + } 121 + 122 + type dashboardData struct { 123 + Version string 124 + Hostname string 125 + Caller *identity.Caller 126 + IsAdmin bool 127 + Servers []ServerStatus 128 + Policies []PolicyView 129 + RecentAudit []audit.Row 130 + TotalTools int 131 + HealthyCount int 132 + AccessCount int 133 + Chart []ChartBar 134 + ChartMax int 135 + ChartTotal int 136 + ChartErrors int 137 + ChartDenied int 138 + HasGrants bool 139 + RecIDs map[int64]bool 140 + TotalRequests int 141 + } 142 + 143 + // HandleDashboard renders the main dashboard page. 144 + func (u *UI) HandleDashboard(w http.ResponseWriter, r *http.Request) { 145 + caller, err := u.ident.Identify(r) 146 + if err != nil { 147 + http.Error(w, "unauthorized", http.StatusUnauthorized) 148 + return 149 + } 150 + 151 + u.mu.RLock() 152 + cfg := u.cfg 153 + pol := u.policy 154 + u.mu.RUnlock() 155 + 156 + isAdmin := u.adminIDs[caller.UserLogin] 157 + callerGrant := policy.ParseGrants(caller, u.cfg.Tailnet) 158 + if callerGrant != nil && callerGrant.Admin { 159 + isAdmin = true 160 + } 161 + hasGrants := callerGrant != nil 162 + probes := u.probeServers(r.Context(), caller) 163 + 164 + // Build server stats map 165 + var serverStatsMap map[string]*audit.ServerStat 166 + if isAdmin { 167 + if stats, err := u.audit.ServerStats(24); err == nil { 168 + serverStatsMap = make(map[string]*audit.ServerStat, len(stats)) 169 + for i := range stats { 170 + serverStatsMap[stats[i].Server] = &stats[i] 171 + } 172 + } 173 + } 174 + 175 + var servers []ServerStatus 176 + totalTools := 0 177 + for name, srv := range cfg.Servers { 178 + probe := probes[name] 179 + allowed := pol.EvalServer(caller, name) == policy.Allow 180 + var deniedTools []string 181 + if allowed { 182 + deniedTools = pol.DeniedTools(caller, name) 183 + } 184 + 185 + var tools []ToolInfo 186 + if allowed && probe.tools != nil { 187 + deniedPatterns := deniedTools 188 + for _, t := range probe.tools { 189 + denied := false 190 + for _, pattern := range deniedPatterns { 191 + if matchGlob(pattern, t.Name) { 192 + denied = true 193 + break 194 + } 195 + } 196 + tools = append(tools, ToolInfo{ 197 + Name: t.Name, 198 + Description: t.Description, 199 + Denied: denied, 200 + }) 201 + } 202 + } 203 + 204 + toolCount := len(tools) 205 + totalTools += toolCount 206 + 207 + servers = append(servers, ServerStatus{ 208 + Name: name, 209 + URL: srv.URL, 210 + Transport: srv.Transport, 211 + Healthy: probe.healthy, 212 + Allowed: allowed, 213 + DeniedTools: deniedTools, 214 + Tools: tools, 215 + ToolCount: toolCount, 216 + Error: probe.err, 217 + Stats: serverStatsMap[name], 218 + }) 219 + } 220 + sort.Slice(servers, func(i, j int) bool { return servers[i].Name < servers[j].Name }) 221 + 222 + healthyCount := 0 223 + accessCount := 0 224 + for _, s := range servers { 225 + if s.Healthy { 226 + healthyCount++ 227 + } 228 + if s.Allowed { 229 + accessCount++ 230 + } 231 + } 232 + 233 + firstMatch := pol.FirstMatch(caller) 234 + var policies []PolicyView 235 + for i, p := range cfg.Policies { 236 + policies = append(policies, PolicyView{ 237 + Name: p.Name, 238 + Identity: p.Match.Identity, 239 + Tags: p.Match.Tags, 240 + Allow: p.Allow, 241 + Deny: p.Deny, 242 + DenyTools: p.DenyTools, 243 + IsActive: i == firstMatch, 244 + }) 245 + } 246 + 247 + var recentAudit []audit.Row 248 + var recIDs map[int64]bool 249 + var cr *chartResult 250 + if isAdmin { 251 + recentAudit, _ = u.audit.Query(audit.QueryParams{Limit: 10}) 252 + cr = u.buildChart(24) 253 + // Check which audit entries have recordings 254 + if len(recentAudit) > 0 { 255 + ids := make([]int64, len(recentAudit)) 256 + for i, r := range recentAudit { 257 + ids[i] = r.ID 258 + } 259 + recIDs = u.audit.RecordingIDs(ids) 260 + } 261 + } 262 + 263 + data := dashboardData{ 264 + Version: "0.1.0", 265 + Hostname: cfg.Hostname, 266 + Caller: caller, 267 + IsAdmin: isAdmin, 268 + Servers: servers, 269 + Policies: policies, 270 + RecentAudit: recentAudit, 271 + TotalTools: totalTools, 272 + HealthyCount: healthyCount, 273 + AccessCount: accessCount, 274 + HasGrants: hasGrants, 275 + RecIDs: recIDs, 276 + TotalRequests: u.audit.TotalRequests(), 277 + } 278 + if cr != nil { 279 + data.Chart = cr.Bars 280 + data.ChartMax = cr.MaxVal 281 + data.ChartTotal = cr.Total 282 + data.ChartErrors = cr.Errors 283 + data.ChartDenied = cr.Denied 284 + } 285 + 286 + w.Header().Set("Content-Type", "text/html; charset=utf-8") 287 + dashboardTmpl.Execute(w, data) 288 + } 289 + 290 + // chartResult holds chart bars and aggregate stats. 291 + type chartResult struct { 292 + Bars []ChartBar 293 + MaxVal int 294 + Total int 295 + Errors int 296 + Denied int 297 + } 298 + 299 + // buildChart creates a 24-hour bar chart from audit data. 300 + func (u *UI) buildChart(hours int) *chartResult { 301 + counts, err := u.audit.HourlyCounts(hours) 302 + if err != nil || len(counts) == 0 { 303 + return nil 304 + } 305 + 306 + // Get per-caller breakdown 307 + details, _ := u.audit.HourlyBreakdown(hours) 308 + detailMap := make(map[string][]CallerStat) 309 + for _, d := range details { 310 + detailMap[d.Hour] = append(detailMap[d.Hour], CallerStat{ 311 + Caller: d.Caller, 312 + Server: d.Server, 313 + Count: d.Count, 314 + }) 315 + } 316 + 317 + // Find max for scaling and compute totals 318 + maxVal := 1 319 + totalReqs, totalErrs, totalDenied := 0, 0, 0 320 + for _, c := range counts { 321 + if c.Total > maxVal { 322 + maxVal = c.Total 323 + } 324 + totalReqs += c.Total 325 + totalErrs += c.Errors 326 + totalDenied += c.Denied 327 + } 328 + 329 + // Build bars indexed by hour 330 + hourMap := make(map[string]audit.HourlyCount, len(counts)) 331 + for _, c := range counts { 332 + hourMap[c.Hour] = c 333 + } 334 + 335 + now := time.Now().UTC() 336 + bars := make([]ChartBar, hours) 337 + for i := 0; i < hours; i++ { 338 + h := now.Add(-time.Duration(hours-1-i) * time.Hour).Truncate(time.Hour) 339 + key := h.Format("2006-01-02T15:00:00Z") 340 + c := hourMap[key] 341 + height := 0 342 + errH := 0 343 + denyH := 0 344 + if c.Total > 0 { 345 + height = c.Total * 100 / maxVal 346 + if height < 15 { 347 + height = 15 348 + } 349 + errH = c.Errors * 100 / maxVal 350 + if c.Errors > 0 && errH < 5 { 351 + errH = 5 352 + } 353 + denyH = c.Denied * 100 / maxVal 354 + if c.Denied > 0 && denyH < 5 { 355 + denyH = 5 356 + } 357 + } 358 + 359 + // Limit to top 3 caller/server combos to keep tooltips compact 360 + callers := detailMap[key] 361 + if len(callers) > 3 { 362 + callers = callers[:3] 363 + } 364 + bars[i] = ChartBar{ 365 + Label: h.Format("15"), 366 + Total: c.Total, 367 + Errors: c.Errors, 368 + Denied: c.Denied, 369 + Height: height, 370 + ErrH: errH, 371 + DenyH: denyH, 372 + Callers: callers, 373 + } 374 + } 375 + return &chartResult{Bars: bars, MaxVal: maxVal, Total: totalReqs, Errors: totalErrs, Denied: totalDenied} 376 + } 377 + 378 + var validServerName = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`) 379 + 380 + // HandleAddServer handles POST /ui/servers to add a new server. 381 + func (u *UI) HandleAddServer(w http.ResponseWriter, r *http.Request) { 382 + caller, err := u.ident.Identify(r) 383 + if err != nil || !u.adminIDs[caller.UserLogin] { 384 + http.Error(w, "forbidden", http.StatusForbidden) 385 + return 386 + } 387 + 388 + if err := r.ParseForm(); err != nil { 389 + http.Error(w, "bad form", http.StatusBadRequest) 390 + return 391 + } 392 + 393 + name := strings.TrimSpace(r.FormValue("name")) 394 + url := strings.TrimSpace(r.FormValue("url")) 395 + transport := strings.TrimSpace(r.FormValue("transport")) 396 + if name == "" || url == "" { 397 + http.Error(w, "name and url required", http.StatusBadRequest) 398 + return 399 + } 400 + if !validServerName.MatchString(name) { 401 + http.Error(w, "invalid server name: use lowercase letters, numbers, hyphens, underscores", http.StatusBadRequest) 402 + return 403 + } 404 + if transport == "" { 405 + transport = "streamable-http" 406 + } 407 + 408 + u.mu.Lock() 409 + u.cfg.Servers[name] = config.Server{URL: url, Transport: transport} 410 + if err := u.cfg.Save(); err != nil { 411 + slog.Error("config save failed", "error", err) 412 + } 413 + u.mu.Unlock() 414 + 415 + http.Redirect(w, r, "/ui/", http.StatusSeeOther) 416 + } 417 + 418 + // HandleDeleteServer handles POST /ui/servers/delete to remove a server. 419 + func (u *UI) HandleDeleteServer(w http.ResponseWriter, r *http.Request) { 420 + caller, err := u.ident.Identify(r) 421 + if err != nil || !u.adminIDs[caller.UserLogin] { 422 + http.Error(w, "forbidden", http.StatusForbidden) 423 + return 424 + } 425 + 426 + if err := r.ParseForm(); err != nil { 427 + http.Error(w, "bad form", http.StatusBadRequest) 428 + return 429 + } 430 + 431 + name := strings.TrimSpace(r.FormValue("name")) 432 + if name == "" { 433 + http.Error(w, "name required", http.StatusBadRequest) 434 + return 435 + } 436 + 437 + u.mu.Lock() 438 + delete(u.cfg.Servers, name) 439 + if err := u.cfg.Save(); err != nil { 440 + slog.Error("config save failed", "error", err) 441 + } 442 + u.mu.Unlock() 443 + 444 + http.Redirect(w, r, "/ui/", http.StatusSeeOther) 445 + } 446 + 447 + // HandleEditServer handles POST /ui/servers/edit to update a server. 448 + func (u *UI) HandleEditServer(w http.ResponseWriter, r *http.Request) { 449 + caller, err := u.ident.Identify(r) 450 + if err != nil || !u.adminIDs[caller.UserLogin] { 451 + http.Error(w, "forbidden", http.StatusForbidden) 452 + return 453 + } 454 + 455 + if err := r.ParseForm(); err != nil { 456 + http.Error(w, "bad form", http.StatusBadRequest) 457 + return 458 + } 459 + 460 + name := strings.TrimSpace(r.FormValue("name")) 461 + url := strings.TrimSpace(r.FormValue("url")) 462 + transport := strings.TrimSpace(r.FormValue("transport")) 463 + if name == "" || url == "" { 464 + http.Error(w, "name and url required", http.StatusBadRequest) 465 + return 466 + } 467 + if transport == "" { 468 + transport = "streamable-http" 469 + } 470 + 471 + u.mu.Lock() 472 + u.cfg.Servers[name] = config.Server{URL: url, Transport: transport} 473 + if err := u.cfg.Save(); err != nil { 474 + slog.Error("config save failed", "error", err) 475 + } 476 + u.mu.Unlock() 477 + 478 + http.Redirect(w, r, "/ui/", http.StatusSeeOther) 479 + } 480 + 481 + // HandleSession shows a session recording detail page. 482 + func (u *UI) HandleSession(w http.ResponseWriter, r *http.Request) { 483 + caller, err := u.ident.Identify(r) 484 + if err != nil { 485 + http.Error(w, "forbidden", http.StatusForbidden) 486 + return 487 + } 488 + isAdmin := u.adminIDs[caller.UserLogin] 489 + if !isAdmin { 490 + if g := policy.ParseGrants(caller, u.cfg.Tailnet); g != nil { 491 + isAdmin = g.Admin 492 + } 493 + } 494 + if !isAdmin { 495 + http.Error(w, "forbidden", http.StatusForbidden) 496 + return 497 + } 498 + 499 + idStr := r.PathValue("id") 500 + id, err := strconv.ParseInt(idStr, 10, 64) 501 + if err != nil { 502 + http.NotFound(w, r) 503 + return 504 + } 505 + 506 + rec, err := u.audit.GetRecording(id) 507 + if err != nil { 508 + http.NotFound(w, r) 509 + return 510 + } 511 + 512 + // Pretty-print JSON bodies 513 + rec.Request = prettyJSON(rec.Request) 514 + rec.Response = prettyJSON(rec.Response) 515 + 516 + w.Header().Set("Content-Type", "text/html; charset=utf-8") 517 + sessionTmpl.Execute(w, rec) 518 + } 519 + 520 + func prettyJSON(s string) string { 521 + var buf bytes.Buffer 522 + if err := json.Indent(&buf, []byte(s), "", " "); err != nil { 523 + return s 524 + } 525 + return buf.String() 526 + } 527 + 528 + 529 + // probeResult holds the result of probing a single backend server. 530 + type probeResult struct { 531 + healthy bool 532 + tools []mcpTool 533 + err string 534 + } 535 + 536 + // mcpTool is a tool discovered from a backend's tools/list response. 537 + type mcpTool struct { 538 + Name string `json:"name"` 539 + Description string `json:"description"` 540 + } 541 + 542 + // probeServers probes all backend servers concurrently for health and tools. 543 + func (u *UI) probeServers(ctx context.Context, caller *identity.Caller) map[string]probeResult { 544 + u.mu.RLock() 545 + servers := u.cfg.Servers 546 + u.mu.RUnlock() 547 + 548 + results := make(map[string]probeResult, len(servers)) 549 + var mu sync.Mutex 550 + var wg sync.WaitGroup 551 + 552 + for name, srv := range servers { 553 + wg.Add(1) 554 + go func(name, url string) { 555 + defer wg.Done() 556 + result := probeBackend(ctx, url) 557 + mu.Lock() 558 + results[name] = result 559 + mu.Unlock() 560 + }(name, srv.URL) 561 + } 562 + wg.Wait() 563 + return results 564 + } 565 + 566 + // probeBackend sends MCP initialize + tools/list to a backend and returns health + tools. 567 + func probeBackend(ctx context.Context, url string) probeResult { 568 + client := &http.Client{Timeout: 5 * time.Second} 569 + 570 + initReq := jsonRPCRequest{ 571 + JSONRPC: "2.0", 572 + ID: 1, 573 + Method: "initialize", 574 + Params: map[string]any{ 575 + "protocolVersion": "2025-03-26", 576 + "capabilities": map[string]any{}, 577 + "clientInfo": map[string]string{ 578 + "name": "turnscale-ui", 579 + "version": "0.1.0", 580 + }, 581 + }, 582 + } 583 + 584 + initBody, _ := json.Marshal(initReq) 585 + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(initBody)) 586 + if err != nil { 587 + return probeResult{err: err.Error()} 588 + } 589 + req.Header.Set("Content-Type", "application/json") 590 + req.Header.Set("Accept", "application/json, text/event-stream") 591 + 592 + resp, err := client.Do(req) 593 + if err != nil { 594 + return probeResult{err: err.Error()} 595 + } 596 + defer resp.Body.Close() 597 + io.ReadAll(resp.Body) 598 + 599 + if resp.StatusCode >= 400 { 600 + return probeResult{healthy: false, err: fmt.Sprintf("initialize: %s", resp.Status)} 601 + } 602 + 603 + sessionID := resp.Header.Get("Mcp-Session-Id") 604 + 605 + notifReq := jsonRPCRequest{JSONRPC: "2.0", Method: "notifications/initialized"} 606 + notifBody, _ := json.Marshal(notifReq) 607 + notifHTTP, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(notifBody)) 608 + if err == nil { 609 + notifHTTP.Header.Set("Content-Type", "application/json") 610 + notifHTTP.Header.Set("Accept", "application/json, text/event-stream") 611 + if sessionID != "" { 612 + notifHTTP.Header.Set("Mcp-Session-Id", sessionID) 613 + } 614 + if r, err := client.Do(notifHTTP); err == nil { 615 + io.ReadAll(r.Body) 616 + r.Body.Close() 617 + } 618 + } 619 + 620 + toolsReq := jsonRPCRequest{JSONRPC: "2.0", ID: 2, Method: "tools/list"} 621 + toolsBody, _ := json.Marshal(toolsReq) 622 + toolsHTTP, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(toolsBody)) 623 + if err != nil { 624 + return probeResult{healthy: true, err: "tools/list request failed"} 625 + } 626 + toolsHTTP.Header.Set("Content-Type", "application/json") 627 + toolsHTTP.Header.Set("Accept", "application/json, text/event-stream") 628 + if sessionID != "" { 629 + toolsHTTP.Header.Set("Mcp-Session-Id", sessionID) 630 + } 631 + 632 + toolsResp, err := client.Do(toolsHTTP) 633 + if err != nil { 634 + return probeResult{healthy: true, err: "tools/list: " + err.Error()} 635 + } 636 + defer toolsResp.Body.Close() 637 + 638 + toolsRespBody, err := io.ReadAll(toolsResp.Body) 639 + if err != nil { 640 + return probeResult{healthy: true, err: "reading tools/list response"} 641 + } 642 + 643 + tools := parseToolsList(toolsRespBody) 644 + sort.Slice(tools, func(i, j int) bool { return tools[i].Name < tools[j].Name }) 645 + 646 + return probeResult{healthy: true, tools: tools} 647 + } 648 + 649 + type jsonRPCRequest struct { 650 + JSONRPC string `json:"jsonrpc"` 651 + ID any `json:"id,omitempty"` 652 + Method string `json:"method"` 653 + Params any `json:"params,omitempty"` 654 + } 655 + 656 + func parseToolsList(body []byte) []mcpTool { 657 + var resp struct { 658 + Result struct { 659 + Tools []mcpTool `json:"tools"` 660 + } `json:"result"` 661 + } 662 + if err := json.Unmarshal(body, &resp); err != nil { 663 + return nil 664 + } 665 + return resp.Result.Tools 666 + } 667 + 668 + func matchGlob(pattern, name string) bool { 669 + if idx := len(pattern) - 1; idx >= 0 && pattern[idx] == '*' { 670 + return strings.HasPrefix(name, pattern[:idx]) 671 + } 672 + return pattern == name 673 + } 674 + 675 + func initial(s string) string { 676 + if len(s) > 0 { 677 + return strings.ToUpper(string(s[0])) 678 + } 679 + return "?" 680 + }
+575
internal/ui/ui_test.go
··· 1 + package ui 2 + 3 + import ( 4 + "encoding/json" 5 + "errors" 6 + "fmt" 7 + "io" 8 + "net/http" 9 + "net/http/httptest" 10 + "strings" 11 + "testing" 12 + 13 + "github.com/slanos/turnscale/internal/audit" 14 + "github.com/slanos/turnscale/internal/config" 15 + "github.com/slanos/turnscale/internal/identity" 16 + "github.com/slanos/turnscale/internal/policy" 17 + ) 18 + 19 + type mockIdentifier struct { 20 + caller *identity.Caller 21 + err error 22 + } 23 + 24 + func (m *mockIdentifier) Identify(r *http.Request) (*identity.Caller, error) { 25 + return m.caller, m.err 26 + } 27 + 28 + func testConfig() *config.Config { 29 + return &config.Config{ 30 + Hostname: "mcp", 31 + Tailnet: "example.ts.net", 32 + Servers: map[string]config.Server{ 33 + "gitea": {URL: "http://localhost:8091/mcp", Transport: "streamable-http"}, 34 + "nomad": {URL: "http://localhost:8090/mcp", Transport: "streamable-http"}, 35 + }, 36 + Policies: []config.Policy{ 37 + { 38 + Name: "admin-full-access", 39 + Match: config.Match{Identity: []string{"scott@github"}}, 40 + Allow: []string{"*"}, 41 + }, 42 + { 43 + Name: "ai-agents", 44 + Match: config.Match{Tags: []string{"tag:ai-agent"}}, 45 + Allow: []string{"gitea", "nomad"}, 46 + DenyTools: []string{"mcp__gitea__delete_*"}, 47 + }, 48 + { 49 + Name: "default-deny", 50 + Match: config.Match{Identity: []string{"*"}}, 51 + Deny: []string{"*"}, 52 + }, 53 + }, 54 + } 55 + } 56 + 57 + func setupTestUI(t *testing.T, caller *identity.Caller, identErr error) *UI { 58 + t.Helper() 59 + cfg := testConfig() 60 + pol := policy.NewEngine(cfg.Policies) 61 + aud, err := audit.NewLogger(t.TempDir()) 62 + if err != nil { 63 + t.Fatalf("audit logger: %v", err) 64 + } 65 + t.Cleanup(func() { aud.Close() }) 66 + adminIDs := map[string]bool{"scott@github": true} 67 + ident := &mockIdentifier{caller: caller, err: identErr} 68 + return New(cfg, ident, pol, aud, adminIDs) 69 + } 70 + 71 + func TestDashboardAdmin(t *testing.T) { 72 + caller := &identity.Caller{ 73 + UserLogin: "scott@github", 74 + DisplayName: "Test Admin", 75 + Node: "little-mac", 76 + TailscaleIP: "100.64.0.1", 77 + } 78 + u := setupTestUI(t, caller, nil) 79 + 80 + // Seed some audit entries 81 + u.audit.Log(audit.Entry{ 82 + Caller: "scott@github", Server: "gitea", Method: "tools/call", 83 + Tool: "mcp__gitea__list_repos", Status: "ok", LatencyMs: 42, 84 + }) 85 + 86 + req := httptest.NewRequest("GET", "/ui/", nil) 87 + rec := httptest.NewRecorder() 88 + u.HandleDashboard(rec, req) 89 + 90 + if rec.Code != http.StatusOK { 91 + t.Fatalf("status = %d, want 200", rec.Code) 92 + } 93 + if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "text/html") { 94 + t.Errorf("Content-Type = %q, want text/html", ct) 95 + } 96 + 97 + body := rec.Body.String() 98 + 99 + for _, want := range []string{ 100 + "scott@github", 101 + "little-mac", 102 + "100.64.0.1", 103 + "admin", 104 + "gitea", 105 + "nomad", 106 + "admin-full-access", 107 + "ai-agents", 108 + "default-deny", 109 + "Recent Activity", 110 + "mcp__gitea__list_repos", 111 + "Turnscale v", 112 + } { 113 + if !strings.Contains(body, want) { 114 + t.Errorf("body missing %q", want) 115 + } 116 + } 117 + } 118 + 119 + func TestDashboardNonAdmin(t *testing.T) { 120 + caller := &identity.Caller{ 121 + UserLogin: "stranger@github", 122 + Node: "other-mac", 123 + } 124 + u := setupTestUI(t, caller, nil) 125 + 126 + req := httptest.NewRequest("GET", "/ui/", nil) 127 + rec := httptest.NewRecorder() 128 + u.HandleDashboard(rec, req) 129 + 130 + if rec.Code != http.StatusOK { 131 + t.Fatalf("status = %d, want 200", rec.Code) 132 + } 133 + 134 + body := rec.Body.String() 135 + 136 + if !strings.Contains(body, "stranger@github") { 137 + t.Error("body missing caller identity") 138 + } 139 + if strings.Contains(body, "Recent Activity") { 140 + t.Error("non-admin should NOT see Recent Activity") 141 + } 142 + if strings.Contains(body, ">admin<") { 143 + t.Error("non-admin should NOT have admin badge") 144 + } 145 + } 146 + 147 + func TestDashboardTaggedNode(t *testing.T) { 148 + caller := &identity.Caller{ 149 + Node: "owl", 150 + TailscaleIP: "100.1.2.3", 151 + Tags: []string{"tag:ai-agent"}, 152 + IsTagged: true, 153 + } 154 + u := setupTestUI(t, caller, nil) 155 + 156 + req := httptest.NewRequest("GET", "/ui/", nil) 157 + rec := httptest.NewRecorder() 158 + u.HandleDashboard(rec, req) 159 + 160 + if rec.Code != http.StatusOK { 161 + t.Fatalf("status = %d, want 200", rec.Code) 162 + } 163 + 164 + body := rec.Body.String() 165 + if !strings.Contains(body, "owl") { 166 + t.Error("body missing node name") 167 + } 168 + if !strings.Contains(body, "tag:ai-agent") { 169 + t.Error("body missing tag") 170 + } 171 + } 172 + 173 + func TestDashboardUnauthorized(t *testing.T) { 174 + u := setupTestUI(t, nil, errors.New("no identity")) 175 + 176 + req := httptest.NewRequest("GET", "/ui/", nil) 177 + rec := httptest.NewRecorder() 178 + u.HandleDashboard(rec, req) 179 + 180 + if rec.Code != http.StatusUnauthorized { 181 + t.Fatalf("status = %d, want 401", rec.Code) 182 + } 183 + } 184 + 185 + // fakeMCPServer returns an httptest.Server that responds to MCP initialize and tools/list. 186 + func fakeMCPServer(tools []mcpTool) *httptest.Server { 187 + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 188 + body, _ := io.ReadAll(r.Body) 189 + var req jsonRPCRequest 190 + json.Unmarshal(body, &req) 191 + 192 + w.Header().Set("Content-Type", "application/json") 193 + switch req.Method { 194 + case "initialize": 195 + w.Header().Set("Mcp-Session-Id", "test-session") 196 + json.NewEncoder(w).Encode(map[string]any{ 197 + "jsonrpc": "2.0", 198 + "id": req.ID, 199 + "result": map[string]any{ 200 + "protocolVersion": "2025-03-26", 201 + "capabilities": map[string]any{"tools": map[string]any{}}, 202 + "serverInfo": map[string]string{"name": "test", "version": "1.0"}, 203 + }, 204 + }) 205 + case "notifications/initialized": 206 + w.WriteHeader(http.StatusNoContent) 207 + case "tools/list": 208 + json.NewEncoder(w).Encode(map[string]any{ 209 + "jsonrpc": "2.0", 210 + "id": req.ID, 211 + "result": map[string]any{"tools": tools}, 212 + }) 213 + default: 214 + w.WriteHeader(http.StatusBadRequest) 215 + } 216 + })) 217 + } 218 + 219 + func TestProbeBackendWithTools(t *testing.T) { 220 + tools := []mcpTool{ 221 + {Name: "list_repos", Description: "List all repositories"}, 222 + {Name: "create_issue", Description: "Create a new issue"}, 223 + {Name: "delete_branch", Description: "Delete a branch"}, 224 + } 225 + srv := fakeMCPServer(tools) 226 + defer srv.Close() 227 + 228 + result := probeBackend(t.Context(), srv.URL) 229 + 230 + if !result.healthy { 231 + t.Fatalf("expected healthy, got err: %s", result.err) 232 + } 233 + if len(result.tools) != 3 { 234 + t.Fatalf("expected 3 tools, got %d", len(result.tools)) 235 + } 236 + // Tools should be sorted by name 237 + if result.tools[0].Name != "create_issue" { 238 + t.Errorf("first tool = %q, want create_issue (sorted)", result.tools[0].Name) 239 + } 240 + } 241 + 242 + func TestProbeBackendUnreachable(t *testing.T) { 243 + result := probeBackend(t.Context(), "http://127.0.0.1:1") 244 + 245 + if result.healthy { 246 + t.Error("unreachable server should not be healthy") 247 + } 248 + if result.err == "" { 249 + t.Error("expected error message for unreachable server") 250 + } 251 + } 252 + 253 + func TestToolDiscoveryInDashboard(t *testing.T) { 254 + tools := []mcpTool{ 255 + {Name: "mcp__gitea__list_repos", Description: "List repos"}, 256 + {Name: "mcp__gitea__delete_branch", Description: "Delete a branch"}, 257 + } 258 + srv := fakeMCPServer(tools) 259 + defer srv.Close() 260 + 261 + caller := &identity.Caller{ 262 + Node: "owl", Tags: []string{"tag:ai-agent"}, IsTagged: true, 263 + } 264 + cfg := &config.Config{ 265 + Hostname: "mcp", 266 + Tailnet: "example.ts.net", 267 + Servers: map[string]config.Server{ 268 + "gitea": {URL: srv.URL, Transport: "streamable-http"}, 269 + }, 270 + Policies: []config.Policy{ 271 + { 272 + Name: "ai-agents", 273 + Match: config.Match{Tags: []string{"tag:ai-agent"}}, 274 + Allow: []string{"gitea"}, 275 + DenyTools: []string{"mcp__gitea__delete_*"}, 276 + }, 277 + }, 278 + } 279 + pol := policy.NewEngine(cfg.Policies) 280 + aud, err := audit.NewLogger(t.TempDir()) 281 + if err != nil { 282 + t.Fatal(err) 283 + } 284 + defer aud.Close() 285 + 286 + u := New(cfg, &mockIdentifier{caller: caller}, pol, aud, nil) 287 + 288 + req := httptest.NewRequest("GET", "/ui/", nil) 289 + rec := httptest.NewRecorder() 290 + u.HandleDashboard(rec, req) 291 + 292 + if rec.Code != http.StatusOK { 293 + t.Fatalf("status = %d, want 200", rec.Code) 294 + } 295 + 296 + body := rec.Body.String() 297 + 298 + // Should show tool names 299 + if !strings.Contains(body, "mcp__gitea__list_repos") { 300 + t.Error("body missing allowed tool name") 301 + } 302 + 303 + // Denied tool should have strikethrough class 304 + if !strings.Contains(body, `class="denied"`) { 305 + t.Error("body missing denied tool styling") 306 + } 307 + 308 + // Should show tool count 309 + if !strings.Contains(body, "2 tools") { 310 + t.Error("body missing tool count") 311 + } 312 + } 313 + 314 + func TestHandleAddServer(t *testing.T) { 315 + caller := &identity.Caller{UserLogin: "scott@github", Node: "test"} 316 + u := setupTestUI(t, caller, nil) 317 + 318 + form := strings.NewReader("name=jira&url=http://localhost:9090/mcp&transport=streamable-http") 319 + req := httptest.NewRequest("POST", "/ui/servers", form) 320 + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 321 + rec := httptest.NewRecorder() 322 + u.HandleAddServer(rec, req) 323 + 324 + if rec.Code != http.StatusSeeOther { 325 + t.Fatalf("status = %d, want 303", rec.Code) 326 + } 327 + } 328 + 329 + func TestHandleAddServerForbidden(t *testing.T) { 330 + caller := &identity.Caller{UserLogin: "stranger@github", Node: "test"} 331 + u := setupTestUI(t, caller, nil) 332 + 333 + form := strings.NewReader("name=jira&url=http://localhost:9090/mcp") 334 + req := httptest.NewRequest("POST", "/ui/servers", form) 335 + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 336 + rec := httptest.NewRecorder() 337 + u.HandleAddServer(rec, req) 338 + 339 + if rec.Code != http.StatusForbidden { 340 + t.Fatalf("status = %d, want 403", rec.Code) 341 + } 342 + } 343 + 344 + func TestHandleDeleteServer(t *testing.T) { 345 + caller := &identity.Caller{UserLogin: "scott@github", Node: "test"} 346 + u := setupTestUI(t, caller, nil) 347 + 348 + form := strings.NewReader("name=nomad") 349 + req := httptest.NewRequest("POST", "/ui/servers/delete", form) 350 + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 351 + rec := httptest.NewRecorder() 352 + u.HandleDeleteServer(rec, req) 353 + 354 + if rec.Code != http.StatusSeeOther { 355 + t.Fatalf("status = %d, want 303", rec.Code) 356 + } 357 + } 358 + 359 + func TestHandleEditServer(t *testing.T) { 360 + caller := &identity.Caller{UserLogin: "scott@github", Node: "test"} 361 + u := setupTestUI(t, caller, nil) 362 + 363 + form := strings.NewReader("name=gitea&url=http://localhost:9999/mcp&transport=streamable-http") 364 + req := httptest.NewRequest("POST", "/ui/servers/edit", form) 365 + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 366 + rec := httptest.NewRecorder() 367 + u.HandleEditServer(rec, req) 368 + 369 + if rec.Code != http.StatusSeeOther { 370 + t.Fatalf("status = %d, want 303", rec.Code) 371 + } 372 + } 373 + 374 + func TestHandleSessionNotFound(t *testing.T) { 375 + caller := &identity.Caller{UserLogin: "scott@github", Node: "test"} 376 + u := setupTestUI(t, caller, nil) 377 + 378 + req := httptest.NewRequest("GET", "/ui/session/99999", nil) 379 + req.SetPathValue("id", "99999") 380 + rec := httptest.NewRecorder() 381 + u.HandleSession(rec, req) 382 + 383 + if rec.Code != http.StatusNotFound { 384 + t.Fatalf("status = %d, want 404", rec.Code) 385 + } 386 + } 387 + 388 + func TestHandleSessionForbidden(t *testing.T) { 389 + caller := &identity.Caller{UserLogin: "stranger@github", Node: "test"} 390 + u := setupTestUI(t, caller, nil) 391 + 392 + req := httptest.NewRequest("GET", "/ui/session/1", nil) 393 + req.SetPathValue("id", "1") 394 + rec := httptest.NewRecorder() 395 + u.HandleSession(rec, req) 396 + 397 + if rec.Code != http.StatusForbidden { 398 + t.Fatalf("status = %d, want 403", rec.Code) 399 + } 400 + } 401 + 402 + func TestHandleSessionWithRecording(t *testing.T) { 403 + caller := &identity.Caller{UserLogin: "scott@github", Node: "test"} 404 + u := setupTestUI(t, caller, nil) 405 + 406 + // Create a recording 407 + id := u.audit.LogWithRecording( 408 + audit.Entry{Caller: "test", Server: "gitea", Method: "tools/call", Tool: "list", Status: "ok"}, 409 + []byte(`{"method":"tools/call"}`), []byte(`{"result":"ok"}`), 410 + ) 411 + 412 + req := httptest.NewRequest("GET", "/ui/session/"+fmt.Sprint(id), nil) 413 + req.SetPathValue("id", fmt.Sprint(id)) 414 + rec := httptest.NewRecorder() 415 + u.HandleSession(rec, req) 416 + 417 + if rec.Code != http.StatusOK { 418 + t.Fatalf("status = %d, want 200", rec.Code) 419 + } 420 + if !strings.Contains(rec.Body.String(), "tools/call") { 421 + t.Error("body missing method") 422 + } 423 + } 424 + 425 + func TestPrettyJSON(t *testing.T) { 426 + out := prettyJSON(`{"a":1,"b":2}`) 427 + if !strings.Contains(out, "\n") { 428 + t.Error("expected indented output") 429 + } 430 + // Invalid JSON returns as-is 431 + out = prettyJSON("not json") 432 + if out != "not json" { 433 + t.Error("invalid JSON should pass through") 434 + } 435 + } 436 + 437 + func TestBuildChartEmpty(t *testing.T) { 438 + caller := &identity.Caller{UserLogin: "scott@github", Node: "test"} 439 + u := setupTestUI(t, caller, nil) 440 + 441 + chart := u.buildChart(24) 442 + if chart != nil { 443 + t.Error("expected nil chart with no data") 444 + } 445 + } 446 + 447 + func TestBuildChartWithData(t *testing.T) { 448 + caller := &identity.Caller{UserLogin: "scott@github", Node: "test"} 449 + u := setupTestUI(t, caller, nil) 450 + 451 + u.audit.Log(audit.Entry{Caller: "a", Server: "gitea", Method: "tools/call", Status: "ok"}) 452 + u.audit.Log(audit.Entry{Caller: "b", Server: "gitea", Method: "tools/call", Status: "error", Error: "fail"}) 453 + 454 + cr := u.buildChart(24) 455 + if cr == nil { 456 + t.Fatal("expected chart data") 457 + } 458 + if len(cr.Bars) != 24 { 459 + t.Errorf("expected 24 bars, got %d", len(cr.Bars)) 460 + } 461 + if cr.Total != 2 { 462 + t.Errorf("expected total 2, got %d", cr.Total) 463 + } 464 + if cr.Errors != 1 { 465 + t.Errorf("expected 1 error, got %d", cr.Errors) 466 + } 467 + 468 + // At least one bar should have data 469 + hasData := false 470 + for _, bar := range cr.Bars { 471 + if bar.Total > 0 { 472 + hasData = true 473 + break 474 + } 475 + } 476 + if !hasData { 477 + t.Error("expected at least one bar with data") 478 + } 479 + } 480 + 481 + func TestBuildChartDenied(t *testing.T) { 482 + caller := &identity.Caller{UserLogin: "scott@github", Node: "test"} 483 + u := setupTestUI(t, caller, nil) 484 + 485 + u.audit.Log(audit.Entry{Caller: "a", Server: "gitea", Method: "tools/call", Status: "ok"}) 486 + u.audit.Log(audit.Entry{Caller: "b", Server: "gitea", Method: "tools/call", Status: "denied"}) 487 + 488 + cr := u.buildChart(24) 489 + if cr == nil { 490 + t.Fatal("expected chart data") 491 + } 492 + if cr.Denied != 1 { 493 + t.Errorf("expected 1 denied, got %d", cr.Denied) 494 + } 495 + 496 + // Find bar with data and check denied height 497 + for _, bar := range cr.Bars { 498 + if bar.Denied > 0 { 499 + if bar.DenyH == 0 { 500 + t.Error("expected non-zero DenyH for bar with denied requests") 501 + } 502 + return 503 + } 504 + } 505 + t.Error("no bar with denied data found") 506 + } 507 + 508 + func TestInitial(t *testing.T) { 509 + if v := initial("scott"); v != "S" { 510 + t.Errorf("initial(scott) = %q", v) 511 + } 512 + if v := initial(""); v != "?" { 513 + t.Errorf("initial('') = %q", v) 514 + } 515 + } 516 + 517 + func TestParseToolsList(t *testing.T) { 518 + body := `{"jsonrpc":"2.0","id":2,"result":{"tools":[ 519 + {"name":"foo","description":"Do foo"}, 520 + {"name":"bar","description":"Do bar"} 521 + ]}}` 522 + tools := parseToolsList([]byte(body)) 523 + if len(tools) != 2 { 524 + t.Fatalf("expected 2 tools, got %d", len(tools)) 525 + } 526 + if tools[0].Name != "foo" || tools[1].Name != "bar" { 527 + t.Errorf("unexpected tools: %v", tools) 528 + } 529 + } 530 + 531 + func TestBuildChartCallerLimit(t *testing.T) { 532 + caller := &identity.Caller{UserLogin: "scott@github", Node: "test"} 533 + u := setupTestUI(t, caller, nil) 534 + 535 + // Insert entries from many different callers to the same server 536 + for i := 0; i < 10; i++ { 537 + u.audit.Log(audit.Entry{ 538 + Caller: fmt.Sprintf("caller%d", i), Server: "gitea", 539 + Method: "tools/call", Status: "ok", 540 + }) 541 + } 542 + 543 + cr := u.buildChart(24) 544 + if cr == nil { 545 + t.Fatal("expected chart data") 546 + } 547 + 548 + // Find the bar with data 549 + for _, bar := range cr.Bars { 550 + if bar.Total > 0 { 551 + if len(bar.Callers) > 3 { 552 + t.Errorf("callers should be limited to 3, got %d", len(bar.Callers)) 553 + } 554 + return 555 + } 556 + } 557 + t.Error("no bar with data found") 558 + } 559 + 560 + func TestMatchGlob(t *testing.T) { 561 + tests := []struct { 562 + pattern, name string 563 + want bool 564 + }{ 565 + {"mcp__gitea__delete_*", "mcp__gitea__delete_branch", true}, 566 + {"mcp__gitea__delete_*", "mcp__gitea__list_repos", false}, 567 + {"exact_match", "exact_match", true}, 568 + {"exact_match", "not_match", false}, 569 + } 570 + for _, tt := range tests { 571 + if got := matchGlob(tt.pattern, tt.name); got != tt.want { 572 + t.Errorf("matchGlob(%q, %q) = %v, want %v", tt.pattern, tt.name, got, tt.want) 573 + } 574 + } 575 + }