Ask for a token before spending the card (V-673)
mavgpud reverse-proxied every path to llama-server with no authentication on a LAN port. Any client on the network could submit model work, hold the model resident by touching the idle clock, and read /slots, which returns the prompts of whoever else was using the card. It now reads a bearer token from token_file and requires it on every request, /health included: /health reports whether the card is loaded and free, which is what someone deciding to take it would ask. A listen address reachable from the network with no token is a startup failure rather than a downgrade to loopback. homesrv is the client and it is on the LAN, so a loopback default would look safe and take the model arm down. Beyond the token: an allowlist of the five paths Maven calls, so a leaked token buys the model API and not llama-server's admin surface; a body cap and an in-flight cap on the proxy; and header and idle timeouts on the server. No read or write timeout — a completion on this card legitimately takes minutes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ESv8hqNPseYt1CnotZpqDz
This commit is contained in:
+47
-3
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user