mavgpud serves the model to the whole LAN with no authentication #224

Merged
kami merged 4 commits from task/673-mavgpud-serves-the-model-to-the-whole-la into master 2026-08-11 11:51:19 +02:00
14 changed files with 483 additions and 7 deletions
+6 -1
View File
@@ -386,8 +386,13 @@ func modelSeam(cfg *config.Config, resident *llm.Client) (router.Completer, *llm
return resident, nil
}
ws := cfg.Workstation
remote := llm.New(ws.URL, time.Duration(ws.Timeout))
remote.SetToken(ws.Token)
if ws.Token == "" {
log.Printf("voice: no workstation.token — mavgpud refuses an unauthenticated request, so this reads as a card that is always busy")
}
pair := llm.NewPair(
llm.New(ws.URL, time.Duration(ws.Timeout)),
remote,
resident,
ws.Health,
time.Duration(ws.Probe),
+145
View File
@@ -0,0 +1,145 @@
package main
import (
"crypto/sha256"
"crypto/subtle"
"fmt"
"net"
"net/http"
"os"
"strings"
)
// The boundary in front of the card.
//
// mavgpud has to listen on the LAN, because homesrv is the client and a
// loopback default takes the model arm down. That makes this the one hop on the
// workstation anything on the network could reach, and until 2026-08-11 it
// reverse-proxied every path to llama-server unauthenticated: any client could
// spend the card, hold the model resident by touching the idle clock, and read
// /slots, which carries the prompts of whoever else was using it.
//
// So: a bearer token every request must carry, read from a file, and a path
// allowlist so a token that leaks buys the model API and not the admin one. The
// CW2 transcriber beside this daemon has worked this way since it shipped; this
// is the same arrangement, not a new one.
// readToken loads the bearer token. The file holds the token and nothing else,
// trailing newline allowed. A path that is set and unreadable is fatal to the
// caller: a supervisor that silently ran without its boundary is the failure
// this exists to prevent.
func readToken(path string) (string, error) {
b, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("token_file: %w", err)
}
tok := strings.TrimSpace(string(b))
if tok == "" {
return "", fmt.Errorf("token_file %s is empty", path)
}
return tok, nil
}
// loopbackListen reports whether addr can only be reached from this machine.
// An empty or wildcard host is not loopback, which is the case that matters:
// ":8080" is the shipped default and it answers the whole LAN.
func loopbackListen(addr string) bool {
host, _, err := net.SplitHostPort(addr)
if err != nil {
host = addr
}
host = strings.Trim(host, "[]")
if host == "" {
return false
}
if host == "localhost" {
return true
}
ip := net.ParseIP(host)
return ip != nil && ip.IsLoopback()
}
// allowed is what a token buys. Everything llama-server exposes beyond this is
// refused, because the endpoints Maven does not call are the expensive ones to
// hand out: /slots returns other callers' prompts, and its save/restore actions
// write files chosen by the request.
//
// Adding a caller means adding its path here. That is deliberate — the list is
// short because Maven's use of the workstation is.
var allowed = map[string]string{
"/v1/chat/completions": http.MethodPost,
"/v1/completions": http.MethodPost,
"/v1/embeddings": http.MethodPost,
"/v1/models": http.MethodGet,
"/props": http.MethodGet,
}
// requireToken authenticates, then bounds. Order matters: an unauthenticated
// client must not be able to make this daemon allocate a body buffer.
//
// /health is not exempt. It reports whether the card is loaded and free, which
// is exactly what someone deciding whether to take it from him would ask.
func requireToken(token string, maxBody int64, next http.Handler) http.Handler {
want := sha256.Sum256([]byte(token))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got := sha256.Sum256([]byte(bearer(r)))
if subtle.ConstantTimeCompare(got[:], want[:]) != 1 {
w.Header().Set("WWW-Authenticate", "Bearer")
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxBody)
next.ServeHTTP(w, r)
})
}
// bearer pulls the credential out of the header. A malformed header yields the
// empty string, which fails the comparison like any other wrong token — there
// is no separate error for it, because telling a caller *how* it was wrong is
// the only thing a probe learns from a 401.
func bearer(r *http.Request) string {
h := r.Header.Get("Authorization")
const prefix = "Bearer "
if len(h) <= len(prefix) || !strings.EqualFold(h[:len(prefix)], prefix) {
return ""
}
return strings.TrimSpace(h[len(prefix):])
}
// allowlist refuses a path the model arm does not use. It answers 404 rather
// than 403 so a scan cannot map llama-server's surface through this hop.
func allowlist(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
method, ok := allowed[r.URL.Path]
if !ok {
http.NotFound(w, r)
return
}
if r.Method != method {
w.Header().Set("Allow", method)
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
next.ServeHTTP(w, r)
})
}
// limitInflight caps concurrent proxied requests. A waiter leaves when its own
// context ends, so a client that gave up does not keep a slot: llama-server
// runs with -np 1 and queueing here is cheaper than queueing inside the child
// with a body held in memory on both sides.
func limitInflight(n int, next http.Handler) http.Handler {
if n <= 0 {
return next
}
slots := make(chan struct{}, n)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case slots <- struct{}{}:
defer func() { <-slots }()
next.ServeHTTP(w, r)
case <-r.Context().Done():
http.Error(w, "client went away", http.StatusServiceUnavailable)
}
})
}
+180
View File
@@ -0,0 +1,180 @@
package main
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
)
// ok is what the boundary is protecting: anything that reaches it has spent
// the card.
func ok(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusTeapot) }
func TestRequireTokenRefusesEveryWrongCredential(t *testing.T) {
h := requireToken("s3cret", 1<<20, http.HandlerFunc(ok))
cases := []struct {
name string
auth string
want int
}{
{"no header", "", http.StatusUnauthorized},
{"wrong token", "Bearer wrong", http.StatusUnauthorized},
{"prefix of the token", "Bearer s3cre", http.StatusUnauthorized},
{"token with no scheme", "s3cret", http.StatusUnauthorized},
{"basic auth", "Basic czNjcmV0", http.StatusUnauthorized},
{"right token", "Bearer s3cret", http.StatusTeapot},
{"scheme is case-insensitive", "bearer s3cret", http.StatusTeapot},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/health", nil)
if tc.auth != "" {
r.Header.Set("Authorization", tc.auth)
}
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != tc.want {
t.Errorf("status %d, want %d", w.Code, tc.want)
}
})
}
}
// The 401 must not say which part was wrong. A probe that can tell a malformed
// header from a wrong token learns the header shape for free.
func TestUnauthorizedSaysNothingUseful(t *testing.T) {
h := requireToken("s3cret", 1<<20, http.HandlerFunc(ok))
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/health", nil))
if got := strings.TrimSpace(w.Body.String()); got != "unauthorized" {
t.Errorf("body %q, want %q", got, "unauthorized")
}
if got := w.Header().Get("WWW-Authenticate"); got != "Bearer" {
t.Errorf("WWW-Authenticate %q, want Bearer", got)
}
}
// The body cap applies to an authenticated request. An unauthenticated one
// never gets far enough to allocate anything.
func TestRequireTokenCapsTheBody(t *testing.T) {
var read error
h := requireToken("t", 8, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
buf := make([]byte, 64)
for read == nil {
if _, read = r.Body.Read(buf); read != nil {
break
}
}
}))
r := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(strings.Repeat("x", 4096)))
r.Header.Set("Authorization", "Bearer t")
h.ServeHTTP(httptest.NewRecorder(), r)
if read == nil || !strings.Contains(read.Error(), "too large") {
t.Errorf("read error %v, want the body cap", read)
}
}
func TestAllowlistRefusesWhatMavenDoesNotCall(t *testing.T) {
h := allowlist(http.HandlerFunc(ok))
cases := []struct {
method, path string
want int
}{
{http.MethodPost, "/v1/chat/completions", http.StatusTeapot},
{http.MethodGet, "/v1/models", http.StatusTeapot},
// /slots returns the prompts of whoever else is using the card, and
// its actions write files the request names.
{http.MethodGet, "/slots", http.StatusNotFound},
{http.MethodPost, "/slots/0?action=save", http.StatusNotFound},
{http.MethodGet, "/", http.StatusNotFound},
{http.MethodGet, "/v1/chat/completions", http.StatusMethodNotAllowed},
}
for _, tc := range cases {
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest(tc.method, tc.path, nil))
if w.Code != tc.want {
t.Errorf("status %d, want %d", w.Code, tc.want)
}
})
}
}
func TestLimitInflightCapsConcurrency(t *testing.T) {
const cap = 2
var mu sync.Mutex
now, peak := 0, 0
release := make(chan struct{})
h := limitInflight(cap, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
now++
if now > peak {
peak = now
}
mu.Unlock()
<-release
mu.Lock()
now--
mu.Unlock()
}))
var wg sync.WaitGroup
for i := 0; i < 8; i++ {
wg.Add(1)
go func() {
defer wg.Done()
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil))
}()
}
// Let the first wave arrive, then drain. The assertion is the peak, and a
// peak that never reached the cap still cannot exceed it.
close(release)
wg.Wait()
if peak > cap {
t.Errorf("%d requests in flight at once, cap is %d", peak, cap)
}
}
func TestLoopbackListen(t *testing.T) {
cases := map[string]bool{
":8080": false, // the shipped default, and the whole LAN
"0.0.0.0:8080": false,
"[::]:8080": false,
"192.168.1.105:8080": false,
"127.0.0.1:8080": true,
"[::1]:8080": true,
"localhost:8080": true,
}
for addr, want := range cases {
if got := loopbackListen(addr); got != want {
t.Errorf("loopbackListen(%q) = %v, want %v", addr, got, want)
}
}
}
func TestReadToken(t *testing.T) {
dir := t.TempDir()
good := filepath.Join(dir, "tok")
if err := os.WriteFile(good, []byte(" abc123\n"), 0o600); err != nil {
t.Fatal(err)
}
got, err := readToken(good)
if err != nil || got != "abc123" {
t.Errorf("readToken = %q, %v; want abc123", got, err)
}
blank := filepath.Join(dir, "blank")
if err := os.WriteFile(blank, []byte("\n\n"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := readToken(blank); err == nil {
t.Error("an empty token file is not a token")
}
if _, err := readToken(filepath.Join(dir, "absent")); err == nil {
t.Error("a missing token file is not a token")
}
}
+4 -2
View File
@@ -104,9 +104,11 @@ func TestHealthAndProxyRefuseWhenNotReady(t *testing.T) {
s := &supervisor{run: newRunner("fake", "/bin/true", nil, "")}
h := s.handler(mustURL(t, "http://127.0.0.1:1"))
for _, path := range []string{"/health", "/v1/chat/completions"} {
// The completion is a POST because the allowlist is in front of the
// readiness check now, and it answers 405 to a method it never serves.
for path, method := range map[string]string{"/health": http.MethodGet, "/v1/chat/completions": http.MethodPost} {
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
h.ServeHTTP(w, httptest.NewRequest(method, path, nil))
if w.Code != http.StatusServiceUnavailable {
t.Errorf("%s with no model: got %d, want 503", path, w.Code)
}
+47 -3
View File
@@ -36,6 +36,21 @@ type config struct {
Listen string `json:"listen"` // what Maven talks to
LlamaAddr string `json:"llama_addr"` // where llama-server binds
LlamaBin string `json:"llama_bin"`
// TokenFile holds the bearer token every request must carry. It is a path
// and never the token itself, the rule mavpoll, mavmaild and the CW2
// transcriber already follow: a secret in a committed config is a secret
// in the history. Empty is allowed only on a loopback Listen, and
// requireToken is where that is decided.
TokenFile string `json:"token_file,omitempty"`
// MaxBody bounds a proxied request body. A completion is a prompt, and a
// prompt that does not fit here would not fit the context window either.
MaxBody int64 `json:"max_body_bytes,omitempty"`
// MaxInflight bounds how many proxied requests reach llama-server at once.
// It runs with -np 1, so anything above a handful only queues inside the
// child while holding a connection and a body in memory here.
MaxInflight int `json:"max_inflight,omitempty"`
// LlamaArgs must include the flags that bind LlamaAddr. They are passed
// through untouched so the model, context size and layer count stay the
// owner's business and not this daemon's schema.
@@ -88,6 +103,8 @@ func defaults() config {
MinFreeVRAM: 15 << 30,
EvictAfter: 2,
StartAfter: 5,
MaxBody: 8 << 20,
MaxInflight: 4,
}
}
@@ -123,6 +140,20 @@ func main() {
log.Fatal("mavgpud: llama_bin is required")
}
// A LAN listener with no token is refused rather than downgraded to
// loopback. Downgrading would look like a safe default and would take the
// model arm down instead: homesrv is the client and it is on the LAN.
var token string
if cfg.TokenFile != "" {
var err error
if token, err = readToken(cfg.TokenFile); err != nil {
log.Fatalf("mavgpud: %v", err)
}
} else if !loopbackListen(cfg.Listen) {
log.Fatalf("mavgpud: listen %s is reachable from the network and token_file is unset — "+
"set token_file, or listen on 127.0.0.1 and accept that Maven cannot reach it", cfg.Listen)
}
base := "http://" + cfg.LlamaAddr
run := newRunner("llama-server", cfg.LlamaBin, cfg.LlamaArgs, base+"/health")
sup := &supervisor{
@@ -145,7 +176,20 @@ func main() {
if err != nil {
log.Fatalf("mavgpud: llama_addr: %v", err)
}
srv := &http.Server{Addr: cfg.Listen, Handler: sup.handler(target)}
var h http.Handler = sup.handler(target)
if token != "" {
h = requireToken(token, cfg.MaxBody, h)
}
srv := &http.Server{
Addr: cfg.Listen,
Handler: h,
// A slow-loris client holds a connection and a header buffer for free
// otherwise. No ReadTimeout or WriteTimeout: a completion legitimately
// takes minutes on this card, and either one would cut it off.
ReadHeaderTimeout: 10 * time.Second,
IdleTimeout: 60 * time.Second,
MaxHeaderBytes: 1 << 16,
}
go func() {
log.Printf("mavgpud: listening on %s, model %s", cfg.Listen, cfg.LlamaBin)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
@@ -203,14 +247,14 @@ func (s *supervisor) handler(target *url.URL) http.Handler {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"status":"ok"}`))
})
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
mux.Handle("/", allowlist(limitInflight(s.cfg.MaxInflight, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !s.run.isReady() {
http.Error(w, "model not loaded", http.StatusServiceUnavailable)
return
}
s.touch()
proxy.ServeHTTP(w, r)
})
}))))
return mux
}
+1
View File
@@ -94,6 +94,7 @@
],
"workstation": {
"url": "http://192.168.1.105:8080",
"token": "${MAVEN_GPU_TOKEN}",
"probe": "15s",
"timeout": "90s",
"stt": {
+9
View File
@@ -1,5 +1,14 @@
{
"listen": ":8080",
"//token_file": [
"The bearer token every request must carry. homesrv is the client and it",
"is on the LAN, so this port cannot be loopback and the token is what",
"stops anything else on the network spending the card or reading /slots.",
"A path, never the token: mavgpud refuses to start when listen is",
"reachable from the network and this is unset.",
"Same value as MAVEN_GPU_TOKEN in homesrv's deploy/telegram.env."
],
"token_file": "/home/kami/.config/mavgpud.token",
"llama_addr": "127.0.0.1:10000",
"llama_bin": "llama-server",
"//llama_args": [
+9
View File
@@ -10,3 +10,12 @@ TELEGRAM_CHAT_ID=
# ntfy token add --expires=never maven
# Read access is not needed — mavend publishes and never subscribes.
NTFY_TOKEN=
# Bearer token for mavgpud, the workstation's GPU supervisor (V-673). It fronts
# the big model on a LAN port, so the token is the whole boundary in front of
# the card. Any long random string; mint one with:
# openssl rand -hex 32
# The same value goes in a file on workpc, named by token_file in
# deploy/mavgpud.json. Unset here and every workstation turn falls back to the
# resident model, because mavgpud answers 401 and Maven reads that as down.
MAVEN_GPU_TOKEN=
+1 -1
View File
@@ -102,7 +102,7 @@ Text-to-speech has not moved. piper on homesrv is the only synthesizer.
| `mavpoll` | Environment poller: netdata alarms, uptime-kuma, zenmoney, wireguard presence. Writes facts, sends nothing. Telegram is `internal/delivery/telegramsink`. |
| `mavcaldav` | CalDAV calendar sync. |
| `mavmaild` | Mail reader (IMAP, read-only). Holds the IMAP password, core never sees it. |
| `mavgpud` | GPU supervisor. **Runs on workpc**, own unit `deploy/mavgpud.service`. Keeps llama-server loaded while the card is free (V-488). Maven never asks it for anything and reads `/health` through `llm.Pair`. |
| `mavgpud` | GPU supervisor. **Runs on workpc**, own unit `deploy/mavgpud.service`. Keeps llama-server loaded while the card is free (V-488). Maven never asks it for anything and reads `/health` through `llm.Pair`. Its LAN port needs a bearer token in the file named by `token_file`, matching `MAVEN_GPU_TOKEN` on homesrv, or it refuses to start (V-673). |
| `mavupdate` | Not a daemon. Operator CLI a human runs on the box to deploy a new build. |
Two binaries have no Makefile target and neither is deployed. `mavseal` encrypts
+13
View File
@@ -105,6 +105,19 @@ how we find out whether the blind spot is real.
untouched. The model, the context size, the layer count and the MTP flags are the
owner's business and not this daemon's schema.
**The port carries a bearer token and cannot be loopback** (V-673). homesrv is
the client, so this hop is on the LAN. Until 2026-08-11 anything on the network
could spend the card, hold the model resident by touching the idle clock, and
read `/slots`, which returns other callers' prompts. mavgpud now reads
`token_file` and refuses to start when the listen address is reachable from the
network without one. Downgrading to loopback instead would look safe and take
the model arm down. Maven sends the same token from `workstation.token`, on the
completion and on the `/health` probe alike. An unsigned probe answers 401,
which Pair reads as a busy card, so a missing token degrades to the resident
model rather than breaking a turn. The proxy also
allowlists the five paths Maven calls, so a leaked token buys the model API and
not llama-server's admin surface.
**Every GPU service on that box belongs under this supervisor**, added to
`cmd/mavgpud` rather than to systemd beside it. The rule was learned on
2026-08-09. The CW2 transcriber ran as its own user unit and registered on the
+7
View File
@@ -27,6 +27,13 @@ type WorkstationConfig struct {
// signal, so it must be the supervisor's endpoint and not llama-server's.
Health string `json:"health,omitempty"`
// Token — the bearer credential mavgpud requires, expanded from the
// environment like every other secret here. It is what stops anything on
// the LAN spending the card, so a URL that is not loopback needs one.
// Wrong or missing reads as a workstation that is down, and Maven falls
// back to the resident model.
Token string `json:"token,omitempty"`
// Probe — how often admission is re-checked. 0 ⇒ DefaultWorkstationProbe.
// Nothing on the hot path waits for it: the answer is cached and read
// atomically, so this only sets how late Maven notices the card came back.
+26
View File
@@ -51,6 +51,10 @@ type Client struct {
base string
swap SwapGate
http *http.Client
// token — the bearer credential for a server that asks for one. Empty for
// the resident model, which is reached over loopback on the same box.
// mavgpud on the workstation requires it: that hop is on the LAN.
token string
// gate / background — priority on the single llama-server slot. Set once
// at wiring time (SetGate), read on every request. nil gate ⇒ no gating,
@@ -101,6 +105,27 @@ func New(baseURL string, timeout time.Duration) *Client {
return &Client{base: baseURL, http: &http.Client{Timeout: timeout}}
}
// SetToken installs the bearer credential this client sends. Wiring-time, like
// SetGate: an empty token means the server is not asking for one.
func (c *Client) SetToken(t string) {
c.mu.Lock()
c.token = t
c.mu.Unlock()
}
// authorize adds the credential when there is one. Exported to the package so
// the Pair prober signs /health with the same token as the completion — a
// probe that answers 401 would otherwise read as a workstation that is down,
// and Maven would fall back forever without saying why.
func (c *Client) authorize(req *http.Request) {
c.mu.RLock()
t := c.token
c.mu.RUnlock()
if t != "" {
req.Header.Set("Authorization", "Bearer "+t)
}
}
// SetBaseURL re-points the client at another llama-server. Safe to call while
// requests are in flight: a request that already read the old base finishes
// against the old base (or fails, and every caller of Complete has a fallback),
@@ -196,6 +221,7 @@ func (c *Client) Complete(ctx context.Context, r Req) (string, error) {
return "", err
}
req.Header.Set("Content-Type", "application/json")
c.authorize(req)
httpResp, err := c.http.Do(req)
if err != nil {
return "", err
+3
View File
@@ -132,6 +132,9 @@ func (p *Pair) probe(ctx context.Context) {
p.set(false)
return
}
if p.remote != nil {
p.remote.authorize(req)
}
resp, err := p.http.Do(req)
if err != nil {
p.set(false)
+32
View File
@@ -250,3 +250,35 @@ func TestNoFloorIsAnError(t *testing.T) {
t.Fatalf("err = %v, want ErrNoFloor", err)
}
}
// The workstation is behind mavgpud, which requires a bearer token on the
// completion and on /health alike. A probe that did not carry it would answer
// 401, Pair would read that as a card that is busy, and every turn would fall
// back to the resident model with nothing in the log naming why.
func TestPairSignsTheProbeAndTheCompletion(t *testing.T) {
seen := make(chan string, 2)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seen <- r.Header.Get("Authorization")
if r.URL.Path == "/health" {
return
}
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"ok"}}]}`))
}))
defer srv.Close()
remote := New(srv.URL, time.Second)
remote.SetToken("s3cret")
p := NewPair(remote, New(srv.URL, time.Second), srv.URL+"/health", time.Hour)
p.probe(context.Background())
if !p.Available() {
t.Fatal("the probe did not admit an answering workstation")
}
if _, err := p.Complete(context.Background(), Req{User: "привет"}); err != nil {
t.Fatalf("complete: %v", err)
}
for i := 0; i < 2; i++ {
if got := <-seen; got != "Bearer s3cret" {
t.Errorf("request %d carried %q, want the bearer token", i, got)
}
}
}