Files
Andy Dixon 31e39bdbef Management API, service binary and host probes
TLS-only HTTP API with Argon2id auth, setup token, CSRF, TOTP, login throttling,
management source allow-list; network apply via helper; capture endpoints;
SSE events; crash recovery of interrupted segments; systemd notify/watchdog.
2026-10-09 17:39:24 +01:00

154 lines
4.9 KiB
Go

package api
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"pilens.dev/pilens/internal/capture"
"pilens.dev/pilens/internal/config"
"pilens.dev/pilens/internal/store"
)
type client struct {
t *testing.T
h http.Handler
remote string
cookie *http.Cookie
csrf string
}
func (c *client) do(method, path string, body any, hdr map[string]string) (int, map[string]any) {
var b bytes.Buffer
if body != nil {
json.NewEncoder(&b).Encode(body)
}
r := httptest.NewRequest(method, path, &b)
r.RemoteAddr = c.remote
r.Header.Set("X-Requested-With", "pilens")
if c.cookie != nil {
r.AddCookie(c.cookie)
}
if c.csrf != "" {
r.Header.Set("X-CSRF-Token", c.csrf)
}
for k, v := range hdr {
r.Header.Set(k, v)
}
w := httptest.NewRecorder()
c.h.ServeHTTP(w, r)
for _, ck := range w.Result().Cookies() {
if ck.Name == cookieName {
c.cookie = ck
}
}
var out map[string]any
json.Unmarshal(w.Body.Bytes(), &out)
return w.Code, out
}
func newServer(t *testing.T) *Server {
dir := t.TempDir()
db, err := store.Open(filepath.Join(dir, "t.db"))
if err != nil {
t.Fatal(err)
}
db.PutJSON(keyBootstrap, config.Management{Interfaces: []string{"eth0"}, AllowedSubnets: []string{"10.9.0.0/24"}, Port: 8443})
tok := filepath.Join(dir, "setup-token")
os.WriteFile(tok, []byte("secret-token\n"), 0o600)
return &Server{DB: db, Engine: &capture.Engine{DB: db, Hub: capture.NewHub()}, SetupTokenPath: tok, DataDir: dir}
}
func TestSetupAuthFlow(t *testing.T) {
s := newServer(t)
h := s.Handler()
c := &client{t: t, h: h, remote: "10.9.0.5:5555"}
if code, _ := (&client{h: h, remote: "192.168.50.20:1"}).do("GET", "/api/setup/status", nil, nil); code != 403 {
t.Fatalf("non-management source allowed: %d", code)
}
if code, _ := c.do("GET", "/api/network", nil, nil); code != 401 {
t.Fatalf("unauthenticated access: %d", code)
}
admin := map[string]any{"token": "wrong", "username": "admin", "password": "a-very-long-password", "authorised": true}
if code, _ := c.do("POST", "/api/setup/admin", admin, nil); code != 403 {
t.Fatalf("bad token accepted: %d", code)
}
admin["token"] = "secret-token"
admin["authorised"] = false
if code, _ := c.do("POST", "/api/setup/admin", admin, nil); code != 400 {
t.Fatalf("missing authorisation accepted: %d", code)
}
admin["authorised"] = true
admin["password"] = "short"
if code, _ := c.do("POST", "/api/setup/admin", admin, nil); code != 400 {
t.Fatal("weak password accepted")
}
admin["password"] = "a-very-long-password"
code, out := c.do("POST", "/api/setup/admin", admin, nil)
if code != 200 || out["csrf"] == "" {
t.Fatalf("setup failed: %d %v", code, out)
}
if _, err := os.Stat(s.SetupTokenPath); err == nil {
t.Fatal("setup token not consumed")
}
if code, _ := c.do("POST", "/api/setup/admin", admin, nil); code != 409 {
t.Fatal("second admin creation allowed")
}
// Authenticated GET works; state change without CSRF token is refused.
if code, _ := c.do("GET", "/api/network", nil, nil); code != 200 {
t.Fatalf("session not usable: %d", code)
}
if code, _ := c.do("PUT", "/api/devices/aa:bb:cc:dd:ee:ff", map[string]string{"Label": "x"}, nil); code != 403 {
t.Fatalf("CSRF not enforced: %d", code)
}
c.csrf = out["csrf"].(string)
if code, _ := c.do("PUT", "/api/devices/aa:bb:cc:dd:ee:ff", map[string]string{"Label": "phone"}, nil); code != 200 {
t.Fatalf("device label: %d", code)
}
// Lock-out protection: management subnets must include the caller.
n := config.DefaultNetwork()
n.Management = config.Management{Interfaces: []string{"eth0"}, AllowedSubnets: []string{"10.8.0.0/24"}, Port: 8443}
n.Uplink, n.AP.Interface, n.AP.Passphrase = "eth0", "wlan0", "passphrase123"
_, v := c.do("POST", "/api/network/validate", n, nil)
if v["valid"] != false || !strings.Contains(strings.Join(toStrings(v["errors"]), " "), "lock you out") {
t.Fatalf("lock-out not detected: %v", v)
}
// Logout invalidates the session.
c.do("POST", "/api/auth/logout", nil, nil)
if code, _ := c.do("GET", "/api/network", nil, nil); code != 401 {
t.Fatal("session survived logout")
}
// Login throttling.
bad := &client{h: h, remote: "10.9.0.7:1"}
for i := 0; i < 5; i++ {
bad.do("POST", "/api/auth/login", map[string]string{"username": "admin", "password": "nope"}, nil)
}
if code, _ := bad.do("POST", "/api/auth/login", map[string]string{"username": "admin", "password": "a-very-long-password"}, nil); code != 429 {
t.Fatalf("no throttling: %d", code)
}
// Login requires the XHR header (blocks cross-site form posts).
if code, _ := c.do("POST", "/api/auth/login", map[string]string{"username": "admin", "password": "a-very-long-password"}, map[string]string{"X-Requested-With": ""}); code != 403 {
t.Fatalf("form post accepted: %d", code)
}
}
func toStrings(v any) []string {
var out []string
if a, ok := v.([]any); ok {
for _, x := range a {
out = append(out, x.(string))
}
}
return out
}