Files
OwnCord/Server/api/livekit_proxy.go
T
J3vbandClaude Opus 4.8 58005c9c6f feat(auth): revocable API tokens, introspect MCP server, and a Go 1.26 idiom pass (#1266)
* feat(auth): add revocable API tokens (bot/service auth)

Add long-lived, revocable API tokens so headless clients (the introspection
MCP tool, bots, CI) can authenticate without a password. Presented as
"Authorization: Bearer <token>", a token authenticates as a specific user,
inheriting that user's role and permissions.

- migration 018 + dedicated api_tokens table (kept separate from sessions so
  bulk logout and the per-user session cap never touch these); only the
  SHA-256 hash is stored, raw token shown once at creation
- auth.ResolveTokenHash: one shared bearer resolver that both AuthMiddleware
  and adminAuthMiddleware now call. Sessions are matched first so existing
  login behavior is unchanged; API tokens are a fallback only on session miss.
  A DB outage is returned wrapped, never mistaken for a bad token.
- `server token create|list|revoke` CLI: mints directly against the DB with no
  HTTP and no login — the password-free bootstrap path
- tests: resolver (8 cases incl. outage-not-fallthrough), db queries (6),
  api middleware integration (valid + revoked token)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(tools): add owncord-introspect MCP server

A local MCP dev tool that lets Claude Code introspect a running OwnCord
instance: read its logs, query any REST endpoint, and tail the desktop
client's log file. It is a thin wrapper over the existing API plus the
client log — no new product surface.

- tools/mcp-introspect/index.mjs (Node/ESM, one dep: @modelcontextprotocol/sdk)
  exposes api_request (full read-write passthrough), server_logs (admin SSE
  ring-buffer stream), client_logs (reads the desktop log file)
- authenticates with an API token (OWNCORD_API_TOKEN); pins the self-signed
  cert and skips hostname checks (the cert has no SAN)
- registered in .mcp.json (secret-free ${OWNCORD_API_TOKEN})
- un-ignore tools/mcp-introspect/ so this shared dev tool is committed, while
  tools/livekit-server.exe and node_modules stay ignored
- docs/mcp-introspect.md: how it works, tool reference, setup, troubleshooting

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(dependencies): update and add various crate versions in Cargo.lock

* feat(admin): manage API tokens from the admin panel

Add Owner-gated HTTP endpoints and a UI card to create, list, and revoke
API tokens from the web admin panel. Previously only the `server token`
CLI could manage them, which requires shell access to the host.

- POST|GET|DELETE /admin/api/tokens in admin/handlers_tokens.go, wired in
  admin/api.go. All three are Owner-only (ownerOnlyMiddleware, like
  backups/updates): an HTTP token-mint endpoint is a network-reachable
  credential-minting surface, and API tokens deliberately survive password
  change + bulk logout, so a hijacked admin session must not mint one.
- Reuses the same db.*APIToken calls as the CLI; create sources the actor
  from request context (audits who clicked, not the bound user); the raw
  token is returned once in the 201 body, never stored.
- Add json tags to db.APITokenListItem for snake_case wire consistency.
- Admin panel: "API Tokens" nav item + create modal, show-once reveal,
  revoke confirm in admin/static/index.html.
- Tests: 7 in admin/api_test.go (+api_tokens table in the in-memory schema).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor: modernize to Go 1.26 idioms + enable modernize linter

Apply `golangci-lint modernize` autofixes across the server and enable the
linter in .golangci.yml so these stop re-accumulating (they built up only
because modernize was never in the config).

Production code: slices.Contains for hand-rolled membership loops (api
router, ws origin, db/account, plugin manifest); strings.SplitSeq for
allocation-free line/segment iteration (db/migrate, updater, livekit_proxy);
strings.Cut (config); fmt.Appendf (dm_handler); min() (event_pruner);
any (ws client). Tests: range-over-int, t.Context(), WaitGroup.Go,
slices.Sort, maps.Copy, new(expr), interface{}->any.

- plugin/manifest.go parent-traversal check applied by hand: modernize
  skipped it (two conflicting rewrites); used the slices.Contains form.
- Removed the now-dead ptr() test helper after newexpr inlined its callers.
- Dropped dangling sort imports left by the sort.Slice->slices.Sort rewrite.

No behavior change. All four tag variants build, full test suite is green,
and golangci-lint (with modernize enabled) reports 0 issues.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-29 13:25:46 +02:00

229 lines
7.2 KiB
Go

package api
import (
"context"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httputil"
"net/url"
"strings"
"github.com/coder/websocket"
)
// NewLiveKitProxy creates a reverse proxy handler that forwards both HTTP
// and WebSocket requests to the LiveKit server. This allows the client to
// reach LiveKit through OwnCord's existing HTTPS server, avoiding
// mixed-content blocks in WebView2 (secure page → insecure WebSocket).
//
// The client connects to wss://server:8443/livekit/ which is proxied to
// ws://localhost:7880/ on the LiveKit server.
func NewLiveKitProxy(livekitURL string, allowedOrigins []string) http.Handler {
target, err := url.Parse(livekitURL)
if err != nil {
slog.Error("invalid LiveKit URL — falling back to localhost:7880",
"url", livekitURL, "error", err)
target, _ = url.Parse("http://localhost:7880")
}
// Normalise scheme for HTTP proxy target.
httpTarget := *target
switch httpTarget.Scheme {
case "ws":
httpTarget.Scheme = "http"
case "wss":
httpTarget.Scheme = "https"
}
// Normalise scheme for WebSocket proxy target.
wsTarget := *target
switch wsTarget.Scheme {
case "http":
wsTarget.Scheme = "ws"
case "https":
wsTarget.Scheme = "wss"
}
httpProxy := &httputil.ReverseProxy{
Director: func(req *http.Request) {
req.URL.Scheme = httpTarget.Scheme
req.URL.Host = httpTarget.Host
req.Host = httpTarget.Host
},
}
// Paths that must never be forwarded to LiveKit (internal/admin endpoints).
// Matched as exact path segments to avoid false positives (e.g. "/user-metrics").
blockedSegments := map[string]bool{"admin": true, "metrics": true, "debug": true, "twirp": true}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Enforce the path allowlist and Origin check for EVERY request,
// including WebSocket upgrades — otherwise a client could reach a
// blocked/admin endpoint simply by sending an Upgrade header.
// Block sensitive LiveKit endpoints (exact segment match).
for seg := range strings.SplitSeq(strings.ToLower(r.URL.Path), "/") {
if blockedSegments[seg] {
writeJSON(w, http.StatusForbidden, errorResponse{
Error: "FORBIDDEN",
Message: "access denied",
})
return
}
}
// Validate Origin header (mirrors WS OriginPatterns).
if !isOriginAllowed(r, allowedOrigins) {
writeJSON(w, http.StatusForbidden, errorResponse{
Error: "FORBIDDEN",
Message: "access denied",
})
return
}
// Detect WebSocket upgrade requests.
if isWebSocketUpgrade(r) {
proxyWebSocket(w, r, &wsTarget, allowedOrigins)
return
}
httpProxy.ServeHTTP(w, r)
})
}
func isWebSocketUpgrade(r *http.Request) bool {
for _, v := range r.Header.Values("Connection") {
if strings.EqualFold(strings.TrimSpace(v), "upgrade") {
return strings.EqualFold(r.Header.Get("Upgrade"), "websocket")
}
}
return false
}
// isOriginAllowed checks whether the request's Origin header matches one of the
// allowed origins. Requests with no Origin header (e.g. same-origin or non-browser)
// are permitted. An empty allowedOrigins list denies all cross-origin requests
// (require explicit "*" wildcard to allow all).
func isOriginAllowed(r *http.Request, allowedOrigins []string) bool {
origin := r.Header.Get("Origin")
if origin == "" {
return true // non-browser or same-origin requests
}
if len(allowedOrigins) == 0 {
return false // no allowlist configured — deny cross-origin
}
for _, pattern := range allowedOrigins {
if pattern == "*" {
return true
}
if strings.EqualFold(origin, pattern) {
return true
}
}
return false
}
// proxyWebSocket opens a backend WS connection and shovels data in both
// directions until either side closes.
func proxyWebSocket(w http.ResponseWriter, r *http.Request, target *url.URL, allowedOrigins []string) {
// Build backend URL preserving the request path and query.
backendURL := *target
backendURL.Path = r.URL.Path
backendURL.RawQuery = r.URL.RawQuery
// Connect to LiveKit backend.
backConn, dialResp, err := websocket.Dial(r.Context(), backendURL.String(), &websocket.DialOptions{
Subprotocols: r.Header.Values("Sec-WebSocket-Protocol"),
})
if dialResp != nil && dialResp.Body != nil {
defer dialResp.Body.Close() //nolint:errcheck // best-effort close
}
if err != nil {
// websocket.Dial wraps a *url.Error, which embeds the full request URL —
// including the access_token query parameter, a live LiveKit room-join
// JWT. Anyone with log access (stdout, a shipper, or the admin panel's
// live view, whose ring buffer captures DEBUG+ regardless of the
// configured stdout level) could replay it inside its 5-minute TTL as
// the victim's participant identity. Strip the credential before the
// error reaches slog: the raw query blob first, so an encoded form is
// caught too, then the decoded token.
safeErr := redactKey(err.Error(), backendURL.RawQuery)
safeErr = redactKey(safeErr, backendURL.Query().Get("access_token"))
slog.Warn("livekit proxy: backend dial failed", "host", backendURL.Host, "path", backendURL.Path, "err", safeErr)
writeJSON(w, http.StatusBadGateway, errorResponse{
Error: "BAD_GATEWAY",
Message: "backend unavailable",
})
return
}
defer backConn.Close(websocket.StatusNormalClosure, "") //nolint:errcheck // best-effort close on defer
// Accept the frontend WebSocket.
frontConn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
Subprotocols: []string{backConn.Subprotocol()},
OriginPatterns: allowedOrigins,
})
if err != nil {
slog.Warn("livekit proxy: frontend accept failed", "err", err)
return
}
defer frontConn.Close(websocket.StatusNormalClosure, "") //nolint:errcheck // best-effort close on defer
// Use a cancellable context so when one direction finishes, the other
// goroutine's copyWS read/write is unblocked and can drain cleanly.
ctx, cancel := context.WithCancel(r.Context())
defer cancel()
errc := make(chan error, 2)
// Frontend → Backend
go func() {
errc <- copyWS(ctx, backConn, frontConn)
}()
// Backend → Frontend
go func() {
errc <- copyWS(ctx, frontConn, backConn)
}()
// Wait for either direction to finish, then cancel+drain both.
<-errc
cancel()
<-errc
}
// wsProxyMaxMessageSize is the maximum WebSocket message size the LiveKit
// proxy will forward. Messages exceeding this are dropped to prevent OOM.
// 256 KB is generous for LiveKit signaling (typically < 10 KB).
const wsProxyMaxMessageSize = 256 * 1024
// copyWS reads messages from src and writes them to dst until an error or
// context cancellation. H-5: Messages exceeding wsProxyMaxMessageSize are
// rejected to prevent memory exhaustion via oversized frames.
func copyWS(ctx context.Context, dst, src *websocket.Conn) error {
for {
msgType, reader, err := src.Reader(ctx)
if err != nil {
return err
}
// Wrap reader with a size limit to prevent OOM from oversized messages.
limited := io.LimitReader(reader, wsProxyMaxMessageSize+1)
writer, err := dst.Writer(ctx, msgType)
if err != nil {
return err
}
n, copyErr := io.Copy(writer, limited)
if copyErr != nil {
return copyErr
}
if n > wsProxyMaxMessageSize {
return fmt.Errorf("livekit proxy: message exceeds %d byte limit", wsProxyMaxMessageSize)
}
if closeErr := writer.Close(); closeErr != nil {
return closeErr
}
}
}