From 238b49e88ae662febecf3950cf5c4a677adce2cb Mon Sep 17 00:00:00 2001 From: Kieran Klukas Date: Sat, 8 Aug 2026 19:01:56 -0400 Subject: [PATCH] bore: learn tunnels from frp hooks instead of polling the admin API The auth check failed open: it read a cache built by polling frps, so a failed fetch quietly made every gated tunnel public. frp has server plugins for this, so NewProxy/CloseProxy push now and unknown means deny. Also drops the public /api/* proxy in favour of a filtered /tunnels on bore-auth, and fixes an open redirect in the login flow. --- modules/home/apps/bore/default.nix | 12 +- .../services/bore/bore-auth/bore_auth_test.go | 121 +++++++++ modules/nixos/services/bore/bore-auth/frps.go | 103 ++++++++ modules/nixos/services/bore/bore-auth/main.go | 231 ++++++------------ .../nixos/services/bore/bore-auth/registry.go | 217 ++++++++++++++++ .../nixos/services/bore/bore-auth/status.go | 133 ++++++++++ modules/nixos/services/bore/bore.nix | 21 +- modules/nixos/services/bore/dashboard.html | 17 +- 8 files changed, 685 insertions(+), 170 deletions(-) create mode 100644 modules/nixos/services/bore/bore-auth/bore_auth_test.go create mode 100644 modules/nixos/services/bore/bore-auth/frps.go create mode 100644 modules/nixos/services/bore/bore-auth/registry.go create mode 100644 modules/nixos/services/bore/bore-auth/status.go diff --git a/modules/home/apps/bore/default.nix b/modules/home/apps/bore/default.nix index 814b6e6..2195135 100644 --- a/modules/home/apps/bore/default.nix +++ b/modules/home/apps/bore/default.nix @@ -24,15 +24,21 @@ let ${pkgs.gum}/bin/gum style --bold --foreground 212 "Active tunnels" echo - tunnels=$(${pkgs.curl}/bin/curl -s https://${cfg.domain}/api/proxy/http) + # --fail so a 5xx or an error page is reported as one, rather than + # rendering as "no tunnels". + if ! tunnels=$(${pkgs.curl}/bin/curl -fsS https://${cfg.domain}/tunnels 2>&1); then + ${pkgs.gum}/bin/gum style --foreground 196 "Could not reach ${cfg.domain}: $tunnels" + exit 1 + fi if ! echo "$tunnels" | ${pkgs.jq}/bin/jq -e '.proxies | length > 0' >/dev/null 2>&1; then ${pkgs.gum}/bin/gum style --foreground 117 "No active tunnels" exit 0 fi - # Filter only online tunnels with valid conf - echo "$tunnels" | ${pkgs.jq}/bin/jq -r '.proxies[] | select(.status == "online" and .conf != null) | if .type == "http" then "\(.name) → https://\(.conf.subdomain).${cfg.domain} [http]" elif .type == "tcp" then "\(.name) → tcp://\(.conf.remotePort) → localhost:\(.conf.localPort) [tcp]" elif .type == "udp" then "\(.name) → udp://\(.conf.remotePort) → localhost:\(.conf.localPort) [udp]" else "\(.name) [\(.type)]" end' | while read -r line; do + # Every type, not just http: tcp and udp tunnels were invisible here + # because this used to read the http-only endpoint. + echo "$tunnels" | ${pkgs.jq}/bin/jq -r '.proxies[] | select(.status == "online") | if .type == "http" then "\(.name) → https://\(.conf.subdomain).${cfg.domain} [http]" elif .type == "tcp" then "\(.name) → tcp://${cfg.domain}:\(.conf.remotePort) [tcp]" elif .type == "udp" then "\(.name) → udp://${cfg.domain}:\(.conf.remotePort) [udp]" else "\(.name) [\(.type)]" end' | while read -r line; do ${pkgs.gum}/bin/gum style --foreground 35 "✓ $line" done exit 0 diff --git a/modules/nixos/services/bore/bore-auth/bore_auth_test.go b/modules/nixos/services/bore/bore-auth/bore_auth_test.go new file mode 100644 index 0000000..c8eed7e --- /dev/null +++ b/modules/nixos/services/bore/bore-auth/bore_auth_test.go @@ -0,0 +1,121 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func init() { + config.CookieDomain = ".bore.dunkirk.sh" +} + +func TestSafeRedirect(t *testing.T) { + home := "https://bore.dunkirk.sh" + + for _, tc := range []struct { + in, want string + }{ + {"", home}, + {"https://evil.example", home}, + {"https://bore.dunkirk.sh.evil.example", home}, + {"http://bore.dunkirk.sh", home}, // downgrade + {"//evil.example", home}, // scheme-relative + {"/dashboard", home}, // no host to trust + {"https://app.bore.dunkirk.sh/x?y=1", "https://app.bore.dunkirk.sh/x?y=1"}, + {"https://bore.dunkirk.sh/dash", "https://bore.dunkirk.sh/dash"}, + } { + if got := safeRedirect(tc.in); got != tc.want { + t.Errorf("safeRedirect(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + +// check drives handleAuthCheck the way Caddy's forward_auth does. +func check(r *registry, host string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodGet, "/.auth/check", nil) + req.Header.Set("X-Forwarded-Host", host) + w := httptest.NewRecorder() + r.handleAuthCheck(w, req) + return w +} + +func TestAuthCheckFailsClosedBeforeSync(t *testing.T) { + r := newRegistry() // not synced + if got := check(r, "anything.bore.dunkirk.sh").Code; got != http.StatusServiceUnavailable { + t.Errorf("before sync: got %d, want 503; an unsynced registry must not let traffic through", got) + } +} + +func TestAuthCheckGating(t *testing.T) { + r := newRegistry() + r.synced = true + r.add(tunnel{name: "open", subdomain: "open"}) + r.add(tunnel{name: "shut", subdomain: "shut", gated: true}) + + if got := check(r, "open.bore.dunkirk.sh").Code; got != http.StatusOK { + t.Errorf("ungated tunnel: got %d, want 200", got) + } + if got := check(r, "shut.bore.dunkirk.sh").Code; got != http.StatusTemporaryRedirect { + t.Errorf("gated tunnel without a session: got %d, want a redirect to login", got) + } + // No proxy claims this, so frps will answer with its 404. + if got := check(r, "nothere.bore.dunkirk.sh").Code; got != http.StatusOK { + t.Errorf("unclaimed subdomain: got %d, want 200", got) + } + if got := check(r, "bore.dunkirk.sh").Code; got != http.StatusOK { + t.Errorf("base domain: got %d, want 200", got) + } +} + +// TestHookLifecycle is the contract with frps: a proxy appears when it is +// created and is gone when it closes. +func TestHookLifecycle(t *testing.T) { + r := newRegistry() + r.synced = true + + post := func(op, body string) { + req := httptest.NewRequest(http.MethodPost, "/.frp/hook?op="+op, strings.NewReader(body)) + w := httptest.NewRecorder() + r.handleHook(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("%s: got %d", op, w.Code) + } + var resp hookResponse + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("%s: %v", op, err) + } + if resp.Reject || !resp.Unchange { + t.Errorf("%s: got %+v, want an unchanged accept", op, resp) + } + } + + // The payload frps sends; "metas" is msg.NewProxy's tag for the proxy's + // metadatas, which is where the bore CLI puts auth. + post("NewProxy", `{"content":{"proxy_name":"myapp","proxy_type":"http", + "subdomain":"myapp","metas":{"auth":"indiko","labels":"dev"}}}`) + + if got := check(r, "myapp.bore.dunkirk.sh").Code; got != http.StatusTemporaryRedirect { + t.Errorf("after NewProxy: got %d, want the gate to be up", got) + } + + post("CloseProxy", `{"content":{"proxy_name":"myapp"}}`) + + if got := check(r, "myapp.bore.dunkirk.sh").Code; got != http.StatusOK { + t.Errorf("after CloseProxy: got %d, want the tunnel to be gone", got) + } +} + +func TestHookIgnoresOtherOps(t *testing.T) { + r := newRegistry() + req := httptest.NewRequest(http.MethodPost, "/.frp/hook?op=Ping", strings.NewReader(`{"content":{}}`)) + w := httptest.NewRecorder() + r.handleHook(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Ping: got %d, want 200; refusing an op we ignore would block clients", w.Code) + } +} diff --git a/modules/nixos/services/bore/bore-auth/frps.go b/modules/nixos/services/bore/bore-auth/frps.go new file mode 100644 index 0000000..8d9fa01 --- /dev/null +++ b/modules/nixos/services/bore/bore-auth/frps.go @@ -0,0 +1,103 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "time" +) + +// Every call to frps goes through a client with a deadline. The previous code +// used http.DefaultClient, which has none, and held the cache's write lock +// across the request: an frps that accepted the connection and never replied +// would wedge every auth check on the server. +var frpsClient = &http.Client{Timeout: 5 * time.Second} + +type serverInfo struct { + ClientCounts int `json:"clientCounts"` + CurConns int `json:"curConns"` + TotalTrafficIn int64 `json:"totalTrafficIn"` + TotalTrafficOut int64 `json:"totalTrafficOut"` +} + +// proxyInfo is one entry of the frps proxy list. Conf stays raw because frps +// shapes it per proxy type. +type proxyInfo struct { + Type string `json:"-"` // filled in from the endpoint + Name string `json:"name"` + Status string `json:"status"` + LastStartTime string `json:"lastStartTime"` + TodayTrafficIn int64 `json:"todayTrafficIn"` + TodayTrafficOut int64 `json:"todayTrafficOut"` + Conf json.RawMessage `json:"conf"` +} + +type proxyConfRaw struct { + Subdomain string `json:"subdomain"` + RemotePort int `json:"remotePort"` + Metadatas map[string]string `json:"metadatas"` +} + +func (p proxyInfo) conf() (proxyConfRaw, error) { + var conf proxyConfRaw + if len(p.Conf) == 0 { + return conf, fmt.Errorf("proxy %q has no conf", p.Name) + } + err := json.Unmarshal(p.Conf, &conf) + return conf, err +} + +// tunnel converts a proxy list entry into what the registry cares about. +func (p proxyInfo) tunnel() (tunnel, bool) { + conf, err := p.conf() + if err != nil || conf.Subdomain == "" || p.Status != "online" { + return tunnel{}, false // only http proxies have a subdomain to gate + } + return tunnel{ + name: p.Name, + subdomain: conf.Subdomain, + gated: conf.Metadatas["auth"] == "indiko", + }, true +} + +func fetchServerInfo() (serverInfo, error) { + var info serverInfo + err := getJSON("/api/serverinfo", &info) + return info, err +} + +// proxyTypes are the kinds of tunnel bore offers. The status page used to read +// /api/proxy/http only, so tcp and udp tunnels were invisible to `bore --list` +// even though the CLI can create them. +var proxyTypes = []string{"http", "tcp", "udp"} + +// fetchProxies returns every proxy, tagged with its type. +func fetchProxies() ([]proxyInfo, error) { + var all []proxyInfo + for _, kind := range proxyTypes { + var list struct { + Proxies []proxyInfo `json:"proxies"` + } + if err := getJSON("/api/proxy/"+kind, &list); err != nil { + return nil, err + } + for _, p := range list.Proxies { + p.Type = kind + all = append(all, p) + } + } + return all, nil +} + +func getJSON(path string, out any) error { + resp, err := frpsClient.Get(config.FrpsAPIURL + path) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("frps %s returned %s", path, resp.Status) + } + return json.NewDecoder(resp.Body).Decode(out) +} diff --git a/modules/nixos/services/bore/bore-auth/main.go b/modules/nixos/services/bore/bore-auth/main.go index 40d253f..ec276b1 100644 --- a/modules/nixos/services/bore/bore-auth/main.go +++ b/modules/nixos/services/bore/bore-auth/main.go @@ -19,17 +19,17 @@ import ( ) type Config struct { - ListenAddr string - FrpsAPIURL string - IndikoURL string - ClientID string - ClientSecret string - RedirectURI string - CookieDomain string - CookieSecure bool - SessionMaxAge int - HashKey []byte - BlockKey []byte + ListenAddr string + FrpsAPIURL string + IndikoURL string + ClientID string + ClientSecret string + RedirectURI string + CookieDomain string + CookieSecure bool + SessionMaxAge int + HashKey []byte + BlockKey []byte } type Session struct { @@ -45,21 +45,6 @@ type PKCEState struct { CreatedAt time.Time } -type ProxyInfo struct { - Name string `json:"name"` - Status string `json:"status"` - Conf json.RawMessage `json:"conf"` -} - -type ProxyConf struct { - Subdomain string `json:"subdomain"` - Metadatas map[string]string `json:"metadatas"` -} - -type ProxyListResponse struct { - Proxies []ProxyInfo `json:"proxies"` -} - type TokenResponse struct { AccessToken string `json:"access_token"` TokenType string `json:"token_type"` @@ -80,9 +65,6 @@ var ( secureCookie *securecookie.SecureCookie pkceStates = make(map[string]PKCEState) pkceStatesMu sync.Mutex - proxyCache = make(map[string]*ProxyConf) - proxyCacheMu sync.RWMutex - proxyCacheAt time.Time ) func main() { @@ -114,17 +96,33 @@ func main() { secureCookie = securecookie.New(config.HashKey, config.BlockKey) secureCookie.MaxAge(config.SessionMaxAge) - // Start background cache refresh - go refreshProxyCachePeriodically() + // What exists and what is gated, learned from frps as it happens. + proxies := newRegistry() + go proxies.sync() + + // What the status page shows. Separate on purpose: if this falls behind, + // the dashboard is stale and nothing else is affected. + status := &statusCache{} + go status.run() - http.HandleFunc("/.auth/check", handleAuthCheck) + http.HandleFunc("/.frp/hook", proxies.handleHook) + http.HandleFunc("/.auth/check", proxies.handleAuthCheck) http.HandleFunc("/.auth/login", handleLogin) http.HandleFunc("/.auth/callback", handleCallback) http.HandleFunc("/.auth/logout", handleLogout) + http.HandleFunc("/tunnels", status.handle) http.HandleFunc("/healthz", handleHealthz) + server := &http.Server{ + Addr: config.ListenAddr, + ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 30 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 120 * time.Second, + } + log.Printf("bore-auth listening on %s", config.ListenAddr) - log.Fatal(http.ListenAndServe(config.ListenAddr, nil)) + log.Fatal(server.ListenAndServe()) } func handleHealthz(w http.ResponseWriter, r *http.Request) { @@ -132,60 +130,52 @@ func handleHealthz(w http.ResponseWriter, r *http.Request) { w.Write([]byte("ok")) } -func handleAuthCheck(w http.ResponseWriter, r *http.Request) { - host := r.Header.Get("X-Forwarded-Host") - if host == "" { - host = r.Host +// redirectToLogin sends an unauthenticated visitor to the login flow, keeping +// the URL they were trying to reach. +func redirectToLogin(w http.ResponseWriter, r *http.Request, host string) { + originalURL := r.Header.Get("X-Forwarded-Uri") + if originalURL == "" { + originalURL = r.URL.RequestURI() } - - subdomain := extractSubdomain(host) - if subdomain == "" { - w.WriteHeader(http.StatusOK) - return - } - - proxyConf := getProxyConf(subdomain) - if proxyConf == nil { - w.WriteHeader(http.StatusOK) - return + scheme := r.Header.Get("X-Forwarded-Proto") + if scheme == "" { + scheme = "https" } - authType := proxyConf.Metadatas["auth"] - if authType != "indiko" { - w.WriteHeader(http.StatusOK) - return - } + redirectTo := fmt.Sprintf("%s://%s%s", scheme, host, originalURL) + loginURL := fmt.Sprintf("https://%s/.auth/login?redirect=%s", + baseDomain(), url.QueryEscape(redirectTo)) - session, err := getSession(r) - if err != nil || session == nil || time.Now().After(session.ExpiresAt) { - originalURL := r.Header.Get("X-Forwarded-Uri") - if originalURL == "" { - originalURL = r.URL.RequestURI() - } - scheme := r.Header.Get("X-Forwarded-Proto") - if scheme == "" { - scheme = "https" - } - - redirectTo := fmt.Sprintf("%s://%s%s", scheme, host, originalURL) - loginURL := fmt.Sprintf("https://%s/.auth/login?redirect=%s", config.CookieDomain[1:], url.QueryEscape(redirectTo)) - - w.Header().Set("Location", loginURL) - w.WriteHeader(http.StatusTemporaryRedirect) - return - } + w.Header().Set("Location", loginURL) + w.WriteHeader(http.StatusTemporaryRedirect) +} - w.Header().Set("X-Auth-User", session.UserID) - w.Header().Set("X-Auth-Name", session.Name) - w.Header().Set("X-Auth-Email", session.Email) - w.WriteHeader(http.StatusOK) +// safeRedirect keeps ?redirect= pointing inside our own domain. +// +// Taken verbatim it is an open redirect: an attacker sends someone to a real +// login on a real domain and chooses where they land afterwards, which is a +// phishing primitive wearing our certificate. +func safeRedirect(target string) string { + home := "https://" + baseDomain() + if target == "" { + return home + } + parsed, err := url.Parse(target) + if err != nil || parsed.Scheme != "https" { + return home + } + host := parsed.Hostname() + if host != baseDomain() && !strings.HasSuffix(host, config.CookieDomain) { + return home + } + return parsed.String() } +// baseDomain is the cookie domain without its leading dot. +func baseDomain() string { return strings.TrimPrefix(config.CookieDomain, ".") } + func handleLogin(w http.ResponseWriter, r *http.Request) { - redirectTo := r.URL.Query().Get("redirect") - if redirectTo == "" { - redirectTo = "https://" + config.CookieDomain[1:] - } + redirectTo := safeRedirect(r.URL.Query().Get("redirect")) codeVerifier := generateCodeVerifier() codeChallenge := generateCodeChallenge(codeVerifier) @@ -273,12 +263,7 @@ func handleLogout(w http.ResponseWriter, r *http.Request) { SameSite: http.SameSiteLaxMode, }) - redirectTo := r.URL.Query().Get("redirect") - if redirectTo == "" { - redirectTo = "https://" + config.CookieDomain[1:] - } - - http.Redirect(w, r, redirectTo, http.StatusTemporaryRedirect) + http.Redirect(w, r, safeRedirect(r.URL.Query().Get("redirect")), http.StatusTemporaryRedirect) } func exchangeCode(code, codeVerifier string) (*TokenResponse, error) { @@ -358,72 +343,6 @@ func extractSubdomain(host string) string { return subdomain } -func getProxyConf(subdomain string) *ProxyConf { - proxyCacheMu.RLock() - conf, ok := proxyCache[subdomain] - proxyCacheMu.RUnlock() - - if ok { - return conf - } - - refreshProxyCache() - - proxyCacheMu.RLock() - conf = proxyCache[subdomain] - proxyCacheMu.RUnlock() - - return conf -} - -func refreshProxyCache() { - proxyCacheMu.Lock() - defer proxyCacheMu.Unlock() - - if time.Since(proxyCacheAt) < 5*time.Second { - return - } - - resp, err := http.Get(config.FrpsAPIURL + "/api/proxy/http") - if err != nil { - log.Printf("Failed to fetch proxy list: %v", err) - return - } - defer resp.Body.Close() - - var proxyList ProxyListResponse - if err := json.NewDecoder(resp.Body).Decode(&proxyList); err != nil { - log.Printf("Failed to decode proxy list: %v", err) - return - } - - newCache := make(map[string]*ProxyConf) - for _, p := range proxyList.Proxies { - if p.Status != "online" { - continue - } - - var conf ProxyConf - if err := json.Unmarshal(p.Conf, &conf); err != nil { - continue - } - - if conf.Subdomain != "" { - newCache[conf.Subdomain] = &conf - } - } - - proxyCache = newCache - proxyCacheAt = time.Now() -} - -func refreshProxyCachePeriodically() { - ticker := time.NewTicker(30 * time.Second) - for range ticker.C { - refreshProxyCache() - } -} - func generateCodeVerifier() string { b := make([]byte, 32) rand.Read(b) @@ -461,28 +380,28 @@ func getEnv(key, defaultVal string) string { func decodeKey(keyStr string) []byte { keyStr = strings.TrimSpace(keyStr) - + // Try standard base64 if decoded, err := base64.StdEncoding.DecodeString(keyStr); err == nil && len(decoded) >= 32 { return decoded[:32] } - + // Try URL-safe base64 if decoded, err := base64.URLEncoding.DecodeString(keyStr); err == nil && len(decoded) >= 32 { return decoded[:32] } - + // Try raw base64 (no padding) if decoded, err := base64.RawStdEncoding.DecodeString(keyStr); err == nil && len(decoded) >= 32 { return decoded[:32] } - + // Use raw bytes, pad or truncate to 32 raw := []byte(keyStr) if len(raw) >= 32 { return raw[:32] } - + // Pad with zeros if too short padded := make([]byte, 32) copy(padded, raw) diff --git a/modules/nixos/services/bore/bore-auth/registry.go b/modules/nixos/services/bore/bore-auth/registry.go new file mode 100644 index 0000000..328a7c9 --- /dev/null +++ b/modules/nixos/services/bore/bore-auth/registry.go @@ -0,0 +1,217 @@ +package main + +import ( + "encoding/json" + "log" + "net/http" + "sync" + "time" +) + +// The registry is the authorization oracle: which subdomains exist, and which +// of them are gated. +// +// frps tells us directly through its server plugin hooks, so this is push, not +// poll. That matters for more than tidiness. When this data came from polling +// the frps admin API, a failed or slow fetch left the map empty, an empty map +// meant "no auth metadata", and "no auth metadata" meant allow: a stats +// request failing would quietly open every protected tunnel. Here, if frps +// accepted a proxy we heard about it, and anything we have not heard about +// does not exist. +type registry struct { + mu sync.RWMutex + bySub map[string]tunnel + synced bool + syncErr error +} + +// tunnel is one proxy as frps described it. +type tunnel struct { + name string + subdomain string + gated bool // metadata asked for authentication +} + +func newRegistry() *registry { + return ®istry{bySub: map[string]tunnel{}} +} + +func (r *registry) add(t tunnel) { + if t.subdomain == "" { + return + } + r.mu.Lock() + defer r.mu.Unlock() + r.bySub[t.subdomain] = t +} + +// removeByName drops a proxy on CloseProxy, which identifies it by name only. +// Tunnels are few, so a scan is cheaper than a second map to keep in step. +func (r *registry) removeByName(name string) { + r.mu.Lock() + defer r.mu.Unlock() + for sub, t := range r.bySub { + if t.name == name { + delete(r.bySub, sub) + return + } + } +} + +// lookup reports what we know about a subdomain. known is false when no proxy +// claims it; ready is false until the startup sync has succeeded, before which +// we know nothing and must not let anything through. +func (r *registry) lookup(subdomain string) (t tunnel, known, ready bool) { + r.mu.RLock() + defer r.mu.RUnlock() + t, known = r.bySub[subdomain] + return t, known, r.synced +} + +// sync seeds the registry from the frps admin API. +// +// Hooks only fire on change, so a restart would otherwise leave us blind to +// every tunnel that already exists. Until this succeeds the registry refuses +// to answer, which is why it retries rather than giving up. +func (r *registry) sync() { + for attempt := 0; ; attempt++ { + proxies, err := fetchProxies() + if err == nil { + seeded := map[string]tunnel{} + for _, p := range proxies { + if t, ok := p.tunnel(); ok { + seeded[t.subdomain] = t + } + } + r.mu.Lock() + // Hooks may have landed while we were fetching; they are newer. + for sub, t := range r.bySub { + seeded[sub] = t + } + r.bySub = seeded + r.synced = true + r.syncErr = nil + r.mu.Unlock() + + log.Printf("registry synced: %d tunnels", len(seeded)) + return + } + + r.mu.Lock() + r.syncErr = err + r.mu.Unlock() + log.Printf("registry sync failed (attempt %d): %v", attempt+1, err) + time.Sleep(min(time.Duration(attempt+1)*2*time.Second, 30*time.Second)) + } +} + +// frp server plugin protocol. frps POSTs to the hook with ?op= and +// expects a verdict; unchange means "accept as submitted". +type hookRequest struct { + Content json.RawMessage `json:"content"` +} + +type hookResponse struct { + Reject bool `json:"reject"` + RejectReason string `json:"reject_reason,omitempty"` + Unchange bool `json:"unchange"` +} + +type newProxyContent struct { + ProxyName string `json:"proxy_name"` + ProxyType string `json:"proxy_type"` + SubDomain string `json:"subdomain"` + Metas map[string]string `json:"metas"` +} + +type closeProxyContent struct { + ProxyName string `json:"proxy_name"` +} + +func (r *registry) handleHook(w http.ResponseWriter, req *http.Request) { + var body hookRequest + if err := json.NewDecoder(req.Body).Decode(&body); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + + switch op := req.URL.Query().Get("op"); op { + case "NewProxy": + var content newProxyContent + if err := json.Unmarshal(body.Content, &content); err != nil { + http.Error(w, "bad content", http.StatusBadRequest) + return + } + t := tunnel{ + name: content.ProxyName, + subdomain: content.SubDomain, + gated: content.Metas["auth"] == "indiko", + } + r.add(t) + log.Printf("hook: proxy %q claimed %q (gated=%v)", t.name, t.subdomain, t.gated) + + case "CloseProxy": + var content closeProxyContent + if err := json.Unmarshal(body.Content, &content); err != nil { + http.Error(w, "bad content", http.StatusBadRequest) + return + } + r.removeByName(content.ProxyName) + log.Printf("hook: proxy %q closed", content.ProxyName) + + default: + log.Printf("hook: ignoring op %q", op) + } + + writeJSON(w, hookResponse{Unchange: true}) +} + +// handleAuthCheck is what Caddy's forward_auth calls for every request to a +// tunnel. 200 lets the request through; a redirect sends the visitor to log in. +func (r *registry) handleAuthCheck(w http.ResponseWriter, req *http.Request) { + host := req.Header.Get("X-Forwarded-Host") + if host == "" { + host = req.Host + } + + subdomain := extractSubdomain(host) + if subdomain == "" { + w.WriteHeader(http.StatusOK) // the base domain, not a tunnel + return + } + + t, known, ready := r.lookup(subdomain) + switch { + case !ready: + // We have not managed to learn what exists yet, so we cannot tell a + // public tunnel from a gated one. Refuse rather than guess. + http.Error(w, "authentication service is starting up", http.StatusServiceUnavailable) + return + case !known: + // No proxy claims this subdomain, so there is nothing to protect. + // frps answers with its 404. + w.WriteHeader(http.StatusOK) + return + case !t.gated: + w.WriteHeader(http.StatusOK) + return + } + + session, err := getSession(req) + if err != nil || session == nil || time.Now().After(session.ExpiresAt) { + redirectToLogin(w, req, host) + return + } + + w.Header().Set("X-Auth-User", session.UserID) + w.Header().Set("X-Auth-Name", session.Name) + w.Header().Set("X-Auth-Email", session.Email) + w.WriteHeader(http.StatusOK) +} + +func writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(v); err != nil { + log.Printf("failed to write response: %v", err) + } +} diff --git a/modules/nixos/services/bore/bore-auth/status.go b/modules/nixos/services/bore/bore-auth/status.go new file mode 100644 index 0000000..203be27 --- /dev/null +++ b/modules/nixos/services/bore/bore-auth/status.go @@ -0,0 +1,133 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "sync" + "time" +) + +// The status page's data and the authorization decision used to share one +// cache, which is why a failed stats fetch could open a gated tunnel. They are +// separate now: the registry decides access, and this only feeds the dashboard. +// If this is stale or missing, the page shows old numbers and nothing else +// changes. +// +// It also exists so the frps admin API does not have to be proxied to the +// public internet. The dashboard reads these fields and no others. +type statusCache struct { + mu sync.RWMutex + body []byte + at time.Time +} + +const statusInterval = 10 * time.Second + +// The projection the dashboard consumes. Field names match what frps used to +// return, so the page reads the same keys from a source we control. +type statusResponse struct { + Server serverStats `json:"server"` + Proxies []proxyStats `json:"proxies"` +} + +type serverStats struct { + ClientCounts int `json:"clientCounts"` + CurConns int `json:"curConns"` + TotalTrafficIn int64 `json:"totalTrafficIn"` + TotalTrafficOut int64 `json:"totalTrafficOut"` +} + +type proxyStats struct { + Name string `json:"name"` + Type string `json:"type"` + Status string `json:"status"` + LastStartTime string `json:"lastStartTime"` + TodayTrafficIn int64 `json:"todayTrafficIn"` + TodayTrafficOut int64 `json:"todayTrafficOut"` + Conf proxyConf `json:"conf"` +} + +type proxyConf struct { + Subdomain string `json:"subdomain"` + RemotePort int `json:"remotePort,omitempty"` + Metadatas map[string]string `json:"metadatas"` +} + +func (s *statusCache) run() { + s.refresh() + for range time.Tick(statusInterval) { + s.refresh() + } +} + +func (s *statusCache) refresh() { + info, err := fetchServerInfo() + if err != nil { + log.Printf("status: server info: %v", err) + return + } + proxies, err := fetchProxies() + if err != nil { + log.Printf("status: proxy list: %v", err) + return + } + + out := statusResponse{ + Server: serverStats{ + ClientCounts: info.ClientCounts, + CurConns: info.CurConns, + TotalTrafficIn: info.TotalTrafficIn, + TotalTrafficOut: info.TotalTrafficOut, + }, + Proxies: make([]proxyStats, 0, len(proxies)), + } + for _, p := range proxies { + conf, err := p.conf() + if err != nil { + continue + } + out.Proxies = append(out.Proxies, proxyStats{ + Name: p.Name, + Type: p.Type, + Status: p.Status, + LastStartTime: p.LastStartTime, + TodayTrafficIn: p.TodayTrafficIn, + TodayTrafficOut: p.TodayTrafficOut, + Conf: proxyConf{ + Subdomain: conf.Subdomain, + RemotePort: conf.RemotePort, + Metadatas: map[string]string{ + "labels": conf.Metadatas["labels"], + "auth": conf.Metadatas["auth"], + }, + }, + }) + } + + body, err := json.Marshal(out) + if err != nil { + log.Printf("status: encode: %v", err) + return + } + + s.mu.Lock() + s.body, s.at = body, time.Now() + s.mu.Unlock() +} + +func (s *statusCache) handle(w http.ResponseWriter, _ *http.Request) { + s.mu.RLock() + body, at := s.body, s.at + s.mu.RUnlock() + + if body == nil { + http.Error(w, `{"error":"no data yet"}`, http.StatusServiceUnavailable) + return + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", fmt.Sprintf("max-age=%d", int(statusInterval.Seconds()))) + w.Header().Set("Last-Modified", at.UTC().Format(http.TimeFormat)) + w.Write(body) +} diff --git a/modules/nixos/services/bore/bore.nix b/modules/nixos/services/bore/bore.nix index 4e5bc92..1d6f18e 100644 --- a/modules/nixos/services/bore/bore.nix +++ b/modules/nixos/services/bore/bore.nix @@ -165,6 +165,18 @@ in # Logging log.to = "console" log.level = "info" + + # Must stay last: in TOML every key after a table header belongs to + # that table, so anything below would be read as plugin config. + ${lib.optionalString cfg.auth.enable '' + # Tell bore-auth about proxies as they come and go, so it never has + # to poll the admin API to find out what is gated. + [[httpPlugins]] + name = "bore-auth" + addr = "127.0.0.1:8401" + path = "/.frp/hook" + ops = ["NewProxy", "CloseProxy"] + ''} ''; in { @@ -242,9 +254,12 @@ in } ''} - # Proxy /api/* to frps dashboard - handle /api/* { - reverse_proxy localhost:7400 + # The dashboard's data. bore-auth serves the handful of fields the + # page renders; the frps admin API stays on localhost, where it can + # keep its unauthenticated proxy list and its DELETE endpoints to + # itself. + handle /tunnels { + reverse_proxy localhost:8401 } # Serve dashboard HTML diff --git a/modules/nixos/services/bore/dashboard.html b/modules/nixos/services/bore/dashboard.html index 5e64321..70c6866 100644 --- a/modules/nixos/services/bore/dashboard.html +++ b/modules/nixos/services/bore/dashboard.html @@ -398,14 +398,15 @@ async function fetchStats() { try { - // Fetch server info - const serverResponse = await fetch('/api/serverinfo'); - if (!serverResponse.ok) throw new Error('API unavailable'); - const serverData = await serverResponse.json(); - - // Fetch HTTP proxies (tunnels) - const proxiesResponse = await fetch('/api/proxy/http'); - const proxiesData = await proxiesResponse.json(); + // One filtered endpoint from bore-auth, rather than the frps admin + // API proxied to the world. + const response = await fetch('/tunnels'); + if (!response.ok) throw new Error('API unavailable'); + const data = await response.json(); + const serverData = data.server; + // The page renders subdomains, so it shows http tunnels; tcp and udp + // are in the payload for the CLI. + const proxiesData = { proxies: (data.proxies || []).filter(p => p.type === 'http') }; // Reset fail count on success fetchFailCount = 0; -- 2.51.2