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.
This commit is contained in:
Andy Dixon
2026-10-09 17:39:24 +01:00
parent 52da3c13f6
commit 31e39bdbef
16 changed files with 2150 additions and 31 deletions
+153
View File
@@ -0,0 +1,153 @@
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
}
+320
View File
@@ -0,0 +1,320 @@
package api
import (
"errors"
"fmt"
"net/http"
"net/netip"
"regexp"
"strconv"
"strings"
"time"
"pilens.dev/pilens/internal/capture"
)
func parsePrefix(s string) (netip.Prefix, error) {
p, err := netip.ParsePrefix(s)
return p.Masked(), err
}
var idRe = regexp.MustCompile(`^[0-9TZ]{16}-[0-9a-f]{6}$`)
func (s *Server) captureRoutes(priv func(string, http.HandlerFunc)) {
priv("GET /api/captures", s.listCaptures)
priv("POST /api/captures", s.startCapture)
priv("POST /api/captures/live/stop", s.stopCapture)
priv("POST /api/captures/live/pause", s.pauseCapture)
priv("POST /api/captures/import", s.importCapture)
priv("GET /api/captures/{id}", s.withSession(s.getCapture))
priv("PATCH /api/captures/{id}", s.withSession(s.patchCapture))
priv("DELETE /api/captures/{id}", s.withSession(s.deleteCapture))
priv("GET /api/captures/{id}/packets", s.withSession(s.packets))
priv("GET /api/captures/{id}/packets/{num}", s.withSession(s.packet))
priv("GET /api/captures/{id}/export", s.withSession(s.export))
priv("GET /api/captures/{id}/manifest", s.withSession(s.manifest))
priv("POST /api/captures/{id}/verify", s.withSession(s.verify))
priv("POST /api/filters/validate", s.validateFilter)
priv("GET /api/filters/saved", s.savedFilters)
priv("POST /api/filters/saved", s.saveFilter)
priv("DELETE /api/filters/saved/{fid}", s.deleteFilter)
priv("GET /api/storage", s.storage)
priv("PUT /api/storage", s.setStorage)
}
func (s *Server) withSession(h func(http.ResponseWriter, *http.Request, string)) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if !idRe.MatchString(id) { // ids become directory names: strict whitelist
httpErr(w, http.StatusBadRequest, "invalid session id")
return
}
if _, err := s.Engine.Get(id); err != nil {
httpErr(w, http.StatusNotFound, "capture session not found")
return
}
h(w, r, id)
}
}
func engineErr(w http.ResponseWriter, err error) {
if errors.Is(err, capture.ErrNotFound) {
httpErr(w, http.StatusNotFound, err.Error())
return
}
httpErr(w, http.StatusBadRequest, err.Error())
}
func (s *Server) listCaptures(w http.ResponseWriter, r *http.Request) {
ss, err := s.Engine.Sessions()
if err != nil {
engineErr(w, err)
return
}
writeJSON(w, ss)
}
func (s *Server) startCapture(w http.ResponseWriter, r *http.Request) {
var o capture.StartOptions
if !readJSON(w, r, &o) {
return
}
sess, err := s.Engine.Start(r.Context(), o)
if err != nil {
engineErr(w, err)
return
}
s.audit(r, "capture.started", fmt.Sprintf("%s interfaces=%v filter=%q", sess.ID, o.Interfaces, o.CaptureFilter))
writeJSON(w, sess)
}
func (s *Server) stopCapture(w http.ResponseWriter, r *http.Request) {
if err := s.Engine.Stop(); err != nil {
engineErr(w, err)
return
}
s.audit(r, "capture.stopped", "")
writeJSON(w, map[string]bool{"ok": true})
}
func (s *Server) pauseCapture(w http.ResponseWriter, r *http.Request) {
var req struct{ Paused bool }
if !readJSON(w, r, &req) {
return
}
if err := s.Engine.SetPaused(req.Paused); err != nil {
engineErr(w, err)
return
}
s.audit(r, "capture.paused", strconv.FormatBool(req.Paused))
writeJSON(w, map[string]bool{"ok": true})
}
func (s *Server) importCapture(w http.ResponseWriter, r *http.Request) {
lim := s.Engine.Limits()
if r.ContentLength > lim.ImportBytes {
httpErr(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("file exceeds the import limit of %d bytes", lim.ImportBytes))
return
}
r.Body = http.MaxBytesReader(w, r.Body, lim.ImportBytes+1)
sess, err := s.Engine.Import(r.Context(), r.URL.Query().Get("name"), r.Body)
if err != nil {
engineErr(w, err)
return
}
s.audit(r, "capture.imported", sess.ID)
writeJSON(w, sess)
}
func (s *Server) getCapture(w http.ResponseWriter, r *http.Request, id string) {
sess, _ := s.Engine.Get(id)
segs, _ := s.Engine.Segments(id)
writeJSON(w, map[string]any{"session": sess, "segments": segs})
}
func (s *Server) patchCapture(w http.ResponseWriter, r *http.Request, id string) {
var req struct {
Name, Description, Tags, Notes string
Preserved bool
}
if !readJSON(w, r, &req) {
return
}
if strings.TrimSpace(req.Name) == "" || len(req.Name) > 200 || len(req.Description) > 5000 || len(req.Tags) > 500 || len(req.Notes) > 50000 {
httpErr(w, http.StatusBadRequest, "invalid field lengths")
return
}
if err := s.Engine.Update(id, req.Name, req.Description, req.Tags, req.Notes, req.Preserved); err != nil {
engineErr(w, err)
return
}
s.audit(r, "capture.updated", id)
writeJSON(w, map[string]bool{"ok": true})
}
func (s *Server) deleteCapture(w http.ResponseWriter, r *http.Request, id string) {
if r.URL.Query().Get("confirm") != id {
httpErr(w, http.StatusBadRequest, "confirm deletion by passing ?confirm=<session id>")
return
}
if err := s.Engine.Delete(id); err != nil {
engineErr(w, err)
return
}
s.audit(r, "capture.deleted", id)
writeJSON(w, map[string]bool{"ok": true})
}
func (s *Server) packets(w http.ResponseWriter, r *http.Request, id string) {
q := r.URL.Query()
off, _ := strconv.ParseInt(q.Get("offset"), 10, 64)
lim, _ := strconv.ParseInt(q.Get("limit"), 10, 64)
p, err := s.Engine.Packets(r.Context(), id, q.Get("filter"), off, lim)
if err != nil {
engineErr(w, err)
return
}
writeJSON(w, p)
}
func (s *Server) packet(w http.ResponseWriter, r *http.Request, id string) {
n, err := strconv.ParseInt(r.PathValue("num"), 10, 64)
if err != nil {
httpErr(w, http.StatusBadRequest, "invalid packet number")
return
}
d, err := s.Engine.Packet(r.Context(), id, n)
if err != nil && d.Bytes == "" {
engineErr(w, err)
return
}
resp := map[string]any{"detail": d}
if err != nil {
resp["error"] = err.Error() // bytes available, dissection failed
}
writeJSON(w, resp)
}
func (s *Server) export(w http.ResponseWriter, r *http.Request, id string) {
filter := r.URL.Query().Get("filter")
format := s.Engine.ExportFormat(id)
name := id
if filter != "" {
name += "-filtered"
}
if format == "pcap" {
w.Header().Set("Content-Type", "application/vnd.tcpdump.pcap")
} else {
w.Header().Set("Content-Type", "application/x-pcapng")
}
w.Header().Set("Content-Disposition", `attachment; filename="`+name+"."+format+`"`)
s.audit(r, "capture.exported", fmt.Sprintf("%s filter=%q", id, filter))
if _, err := s.Engine.Export(r.Context(), w, id, filter); err != nil {
// Headers are gone; the truncated download is the signal. Log it.
s.audit(r, "capture.export_failed", err.Error())
}
}
func (s *Server) manifest(w http.ResponseWriter, r *http.Request, id string) {
m, err := s.Engine.Manifest(id)
if err != nil {
engineErr(w, err)
return
}
w.Header().Set("Content-Disposition", `attachment; filename="`+id+`-manifest.json"`)
s.audit(r, "capture.manifest_exported", id)
writeJSON(w, m)
}
func (s *Server) verify(w http.ResponseWriter, r *http.Request, id string) {
v, err := s.Engine.Verify(id)
if err != nil {
engineErr(w, err)
return
}
s.audit(r, "capture.verified", id)
writeJSON(w, v)
}
func (s *Server) validateFilter(w http.ResponseWriter, r *http.Request) {
var req struct{ Kind, Filter, Interface string }
if !readJSON(w, r, &req) {
return
}
var err error
switch req.Kind {
case "display":
err = s.Engine.Tools.ValidateDisplayFilter(r.Context(), req.Filter)
case "capture":
if req.Interface == "" {
req.Interface = "any"
}
err = s.Engine.Tools.ValidateCaptureFilter(r.Context(), req.Interface, req.Filter)
default:
httpErr(w, http.StatusBadRequest, "kind must be display or capture")
return
}
resp := map[string]any{"valid": err == nil}
if err != nil {
resp["error"] = err.Error()
}
writeJSON(w, resp)
}
func (s *Server) savedFilters(w http.ResponseWriter, r *http.Request) {
rows, err := s.DB.Query(`SELECT id,name,kind,expr FROM saved_filters ORDER BY name`)
if err != nil {
httpErr(w, 500, err.Error())
return
}
defer rows.Close()
type f struct {
ID int64
Name, Kind, Expr string
}
out := []f{}
for rows.Next() {
var x f
rows.Scan(&x.ID, &x.Name, &x.Kind, &x.Expr)
out = append(out, x)
}
writeJSON(w, out)
}
func (s *Server) saveFilter(w http.ResponseWriter, r *http.Request) {
var req struct{ Name, Kind, Expr string }
if !readJSON(w, r, &req) {
return
}
if req.Name == "" || len(req.Name) > 100 || len(req.Expr) > 2048 || (req.Kind != "display" && req.Kind != "capture") {
httpErr(w, http.StatusBadRequest, "invalid filter")
return
}
s.DB.Exec(`INSERT INTO saved_filters(name,kind,expr,created_at) VALUES(?,?,?,?)`, req.Name, req.Kind, req.Expr, time.Now().Unix())
writeJSON(w, map[string]bool{"ok": true})
}
func (s *Server) deleteFilter(w http.ResponseWriter, r *http.Request) {
s.DB.Exec(`DELETE FROM saved_filters WHERE id=?`, r.PathValue("fid"))
writeJSON(w, map[string]bool{"ok": true})
}
func (s *Server) storage(w http.ResponseWriter, r *http.Request) {
writeJSON(w, s.Engine.Storage())
}
func (s *Server) setStorage(w http.ResponseWriter, r *http.Request) {
var l capture.Limits
if !readJSON(w, r, &l) {
return
}
const mib = 1 << 20
if l.MaxBytes < 64*mib || l.ReserveBytes < 64*mib || l.SegmentBytes < 1*mib || l.SegmentBytes > 1024*mib || l.ImportBytes < mib || l.ImportBytes > 64<<30 {
httpErr(w, http.StatusBadRequest, "limits out of range (quota ≥ 64 MiB, reserve ≥ 64 MiB, segment 1-1024 MiB)")
return
}
s.Engine.SetLimits(l)
s.DB.PutJSON(keyLimits, l)
s.audit(r, "storage.limits_changed", fmt.Sprintf("%+v", l))
writeJSON(w, map[string]bool{"ok": true})
}
+292
View File
@@ -0,0 +1,292 @@
package api
import (
"context"
"encoding/json"
"net"
"net/http"
"strings"
"time"
"pilens.dev/pilens/internal/config"
"pilens.dev/pilens/internal/helper"
"pilens.dev/pilens/internal/sysinfo"
)
func (s *Server) networkRoutes(priv func(string, http.HandlerFunc)) {
priv("GET /api/system/interfaces", s.interfaces)
priv("GET /api/system/health", s.health)
priv("GET /api/network", s.getNetwork)
priv("POST /api/network/validate", s.validateNetwork)
priv("POST /api/network/apply", s.applyNetwork)
priv("POST /api/setup/complete", s.completeSetup)
priv("GET /api/ap/clients", s.apClients)
priv("PUT /api/devices/{mac}", s.updateDevice)
}
func (s *Server) interfaces(w http.ResponseWriter, r *http.Request) {
var boot config.Management
s.DB.GetJSON(keyBootstrap, &boot)
writeJSON(w, map[string]any{
"interfaces": sysinfo.Interfaces(),
"wifi": sysinfo.WifiCapabilities(),
"bootstrap": boot,
"remote_addr": remoteAddr(r).String(),
})
}
func (s *Server) helperStatus(ctx context.Context) (helper.Status, string) {
ctx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
var st helper.Status
if err := s.Helper.Call(ctx, helper.Request{Op: helper.OpStatus}, &st); err != nil {
return st, err.Error()
}
return st, ""
}
func (s *Server) health(w http.ResponseWriter, r *http.Request) {
h := sysinfo.ReadHealth(map[string]string{"data": s.DataDir, "captures": s.Engine.Dir})
st, herr := s.helperStatus(r.Context())
live, running := s.Engine.Live()
var n config.Network
configured, _ := s.DB.GetJSON(keyNetwork, &n)
upstream := map[string]any{"configured": configured}
if configured {
routes := sysinfo.DefaultRoutes()
gw, ok := routes[n.Uplink]
upstream["interface"] = n.Uplink
upstream["default_route"] = ok
upstream["gateway"] = gw
if i, err := net.InterfaceByName(n.Uplink); err == nil {
upstream["up"] = i.Flags&net.FlagUp != 0
}
}
writeJSON(w, map[string]any{
"system": h,
"helper": st,
"helper_error": herr,
"capture": map[string]any{"running": running, "session": live, "tshark": s.Engine.Tools.Version()},
"storage": s.Engine.Storage(),
"upstream": upstream,
"version": s.Version,
"forwarding": forwardingState(configured, n, st, herr),
})
}
// forwardingState is the single prominent answer to "is AP traffic being forwarded?".
func forwardingState(configured bool, n config.Network, st helper.Status, herr string) map[string]string {
switch {
case herr != "":
return map[string]string{"state": "unknown", "detail": "network helper unavailable: " + herr + ". The last loaded firewall table remains in force."}
case !configured || !st.Configured:
return map[string]string{"state": "blocked", "detail": "No network configuration has been applied. AP traffic is not forwarded."}
case !st.TableLoaded:
return map[string]string{"state": "unprotected", "detail": "The PiLens firewall table is not loaded."}
case n.Forwarding.Mode != "internet":
return map[string]string{"state": "blocked", "detail": "Forwarding is disabled by policy."}
case !st.IPForward:
return map[string]string{"state": "blocked", "detail": "Kernel IPv4 forwarding is off."}
}
return map[string]string{"state": "forwarding", "detail": "AP clients reach public destinations via " + n.Uplink + ". Private and management networks are blocked. IPv6 is blocked."}
}
func (s *Server) getNetwork(w http.ResponseWriter, r *http.Request) {
var n config.Network
ok, _ := s.DB.GetJSON(keyNetwork, &n)
if !ok {
n = config.DefaultNetwork()
var b config.Management
if found, _ := s.DB.GetJSON(keyBootstrap, &b); found {
n.Management = b
}
}
pass := n.AP.Passphrase != ""
writeJSON(w, map[string]any{"config": n.Redacted(), "saved": ok, "passphrase_set": pass})
}
// mergeNetwork keeps the stored passphrase when the client leaves it blank.
func (s *Server) mergeNetwork(n *config.Network) {
if n.AP.Passphrase == "" {
var old config.Network
if ok, _ := s.DB.GetJSON(keyNetwork, &old); ok {
n.AP.Passphrase = old.AP.Passphrase
}
}
}
func (s *Server) validateNetwork(w http.ResponseWriter, r *http.Request) {
var n config.Network
if !readJSON(w, r, &n) {
return
}
s.mergeNetwork(&n)
errs := []string{}
if err := n.Validate(true); err != nil {
errs = strings.Split(err.Error(), "\n")
}
errs = append(errs, s.managementWarnings(r, n)...)
writeJSON(w, map[string]any{"valid": len(errs) == 0, "errors": errs})
}
// managementWarnings refuses configurations that would lock out the current admin.
func (s *Server) managementWarnings(r *http.Request, n config.Network) []string {
ra := remoteAddr(r)
if ra.IsLoopback() {
return nil
}
ok := false
for _, c := range n.Management.AllowedSubnets {
if p, err := parsePrefix(c); err == nil && p.Contains(ra) {
ok = true
}
}
if !ok {
return []string{"your current address " + ra.String() + " is not inside any management subnet: applying would lock you out"}
}
return nil
}
func (s *Server) applyNetwork(w http.ResponseWriter, r *http.Request) {
var n config.Network
if !readJSON(w, r, &n) {
return
}
s.mergeNetwork(&n)
if err := n.Validate(true); err != nil {
httpErr(w, http.StatusBadRequest, err.Error())
return
}
if warn := s.managementWarnings(r, n); len(warn) > 0 {
httpErr(w, http.StatusBadRequest, warn[0])
return
}
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Minute)
defer cancel()
var res helper.ApplyResult
err := s.Helper.Call(ctx, helper.Request{Op: helper.OpApplyNetwork, Network: &n}, &res)
red, _ := json.Marshal(n.Redacted())
if err != nil {
s.audit(r, "network.apply_failed", err.Error())
w.WriteHeader(http.StatusBadGateway)
writeJSON(w, map[string]any{"error": err.Error(), "result": res})
return
}
s.DB.PutJSON(keyNetwork, n)
s.reloadMgmt()
s.audit(r, "network.applied", string(red))
writeJSON(w, map[string]any{"result": res})
}
func (s *Server) completeSetup(w http.ResponseWriter, r *http.Request) {
var n config.Network
if ok, _ := s.DB.GetJSON(keyNetwork, &n); !ok {
httpErr(w, http.StatusConflict, "apply a network configuration first")
return
}
s.DB.PutJSON(keySetupDone, true)
s.audit(r, "setup.completed", "")
writeJSON(w, map[string]bool{"ok": true})
}
type apClient struct {
MAC string `json:"mac"`
IP string `json:"ip"`
Hostname string `json:"hostname"`
Label string `json:"label"`
Notes string `json:"notes"`
Associated bool `json:"associated"`
SignalDBm *int `json:"signal_dbm,omitempty"`
RxBytes uint64 `json:"rx_bytes"`
TxBytes uint64 `json:"tx_bytes"`
ConnectedS int64 `json:"connected_s"`
InactiveMS int64 `json:"inactive_ms"`
LeaseExpiry int64 `json:"lease_expiry"`
FirstSeen int64 `json:"first_seen"`
LastSeen int64 `json:"last_seen"`
Randomised bool `json:"randomised_mac"`
}
func (s *Server) apClients(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
var leases []helper.Lease
var stations []helper.Station
var errs []string
if err := s.Helper.Call(ctx, helper.Request{Op: helper.OpLeases}, &leases); err != nil {
errs = append(errs, "leases: "+err.Error())
}
if err := s.Helper.Call(ctx, helper.Request{Op: helper.OpStations}, &stations); err != nil {
errs = append(errs, "stations: "+err.Error())
}
now := time.Now().Unix()
by := map[string]*apClient{}
get := func(mac string) *apClient {
if c := by[mac]; c != nil {
return c
}
c := &apClient{MAC: mac}
if hw, err := net.ParseMAC(mac); err == nil && len(hw) > 0 {
c.Randomised = hw[0]&0x02 != 0 // locally administered bit
}
by[mac] = c
return c
}
for _, l := range leases {
c := get(l.MAC)
c.IP, c.Hostname, c.LeaseExpiry = l.IP, l.Hostname, l.Expiry
}
for _, st := range stations {
c := get(st.MAC)
c.Associated = true
c.SignalDBm, c.RxBytes, c.TxBytes, c.ConnectedS, c.InactiveMS = st.SignalDBm, st.RxBytes, st.TxBytes, st.ConnectedS, st.InactiveMS
}
for mac, c := range by {
seen := now
if !c.Associated {
seen = 0
}
s.DB.Exec(`INSERT INTO devices(mac,hostname,last_ip,first_seen,last_seen) VALUES(?,?,?,?,?)
ON CONFLICT(mac) DO UPDATE SET hostname=CASE WHEN excluded.hostname!='' THEN excluded.hostname ELSE hostname END,
last_ip=CASE WHEN excluded.last_ip!='' THEN excluded.last_ip ELSE last_ip END,
last_seen=MAX(last_seen,excluded.last_seen)`, mac, c.Hostname, c.IP, now, seen)
s.DB.QueryRow(`SELECT label,notes,first_seen,last_seen FROM devices WHERE mac=?`, mac).Scan(&c.Label, &c.Notes, &c.FirstSeen, &c.LastSeen)
}
out := []apClient{}
for _, c := range by {
out = append(out, *c)
}
// Known devices not currently present.
rows, err := s.DB.Query(`SELECT mac,label,notes,hostname,last_ip,first_seen,last_seen FROM devices ORDER BY last_seen DESC LIMIT 200`)
if err == nil {
defer rows.Close()
for rows.Next() {
var c apClient
rows.Scan(&c.MAC, &c.Label, &c.Notes, &c.Hostname, &c.IP, &c.FirstSeen, &c.LastSeen)
if by[c.MAC] == nil {
out = append(out, c)
}
}
}
writeJSON(w, map[string]any{"clients": out, "errors": errs})
}
func (s *Server) updateDevice(w http.ResponseWriter, r *http.Request) {
hw, err := net.ParseMAC(r.PathValue("mac"))
if err != nil {
httpErr(w, http.StatusBadRequest, "invalid MAC")
return
}
var req struct{ Label, Notes string }
if !readJSON(w, r, &req) {
return
}
if len(req.Label) > 100 || len(req.Notes) > 10000 {
httpErr(w, http.StatusBadRequest, "label or notes too long")
return
}
now := time.Now().Unix()
s.DB.Exec(`INSERT INTO devices(mac,label,notes,first_seen,last_seen) VALUES(?,?,?,?,0) ON CONFLICT(mac) DO UPDATE SET label=excluded.label, notes=excluded.notes`, hw.String(), req.Label, req.Notes, now)
s.audit(r, "device.updated", hw.String())
writeJSON(w, map[string]bool{"ok": true})
}
+562
View File
@@ -0,0 +1,562 @@
// Package api is the authenticated management HTTP API and static UI server.
package api
import (
"context"
"crypto/subtle"
"database/sql"
"encoding/json"
"errors"
"io/fs"
"log/slog"
"net"
"net/http"
"net/netip"
"os"
"strings"
"sync"
"time"
"pilens.dev/pilens/internal/auth"
"pilens.dev/pilens/internal/capture"
"pilens.dev/pilens/internal/config"
"pilens.dev/pilens/internal/helper"
"pilens.dev/pilens/internal/store"
)
const (
cookieName = "pilens_session"
sessionIdle = 60 * time.Minute
sessionMax = 12 * time.Hour
keyNetwork = "network"
keyBootstrap = "bootstrap_management"
keySetupDone = "setup_complete"
keyLimits = "capture_limits"
)
type Server struct {
DB *store.Store
Engine *capture.Engine
Helper helper.Client
Version string
Web fs.FS // built UI; may be nil in tests
SetupTokenPath string
DataDir string
limiter *auth.Limiter
mgmtMu sync.RWMutex
mgmtNets []netip.Prefix
apGW netip.Addr
}
type user struct {
ID int64
Name string
CSRF string
TOTPEnabled bool
TokenHash string
}
type ctxKey struct{}
func (s *Server) Handler() http.Handler {
s.limiter = auth.NewLimiter()
s.reloadMgmt()
mux := http.NewServeMux()
pub := func(pattern string, h http.HandlerFunc) { mux.Handle(pattern, h) }
priv := func(pattern string, h http.HandlerFunc) { mux.Handle(pattern, s.requireAuth(h)) }
pub("GET /api/health", func(w http.ResponseWriter, r *http.Request) { writeJSON(w, map[string]bool{"ok": true}) })
pub("GET /api/setup/status", s.setupStatus)
pub("POST /api/setup/admin", s.setupAdmin)
pub("POST /api/auth/login", s.login)
priv("POST /api/auth/logout", s.logout)
priv("GET /api/auth/session", s.session)
priv("POST /api/auth/password", s.changePassword)
priv("POST /api/auth/totp/begin", s.totpBegin)
priv("POST /api/auth/totp/enable", s.totpEnable)
priv("POST /api/auth/totp/disable", s.totpDisable)
priv("GET /api/audit", s.auditLog)
priv("GET /api/events", s.events)
s.networkRoutes(priv)
s.captureRoutes(priv)
if s.Web != nil {
files := http.FileServerFS(s.Web)
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/api/") {
http.NotFound(w, r)
return
}
if _, err := fs.Stat(s.Web, strings.TrimPrefix(r.URL.Path, "/")); err != nil || r.URL.Path == "/" {
r.URL.Path = "/" // SPA fallback
}
files.ServeHTTP(w, r)
})
}
return s.secureHeaders(s.sourceCheck(mux))
}
// ---------------------------------------------------------------- middleware
func (s *Server) secureHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
// Captured content is never rendered as HTML by the app; previews use sandboxed blob iframes.
h.Set("Content-Security-Policy", "default-src 'self'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; frame-src blob:; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'")
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Referrer-Policy", "no-referrer")
h.Set("X-Frame-Options", "DENY")
h.Set("Strict-Transport-Security", "max-age=31536000")
h.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
if strings.HasPrefix(r.URL.Path, "/api/") {
h.Set("Cache-Control", "no-store")
}
next.ServeHTTP(w, r)
})
}
func (s *Server) reloadMgmt() {
var nets []netip.Prefix
var apGW netip.Addr
var n config.Network
if ok, _ := s.DB.GetJSON(keyNetwork, &n); ok {
for _, c := range n.Management.AllowedSubnets {
if p, err := netip.ParsePrefix(c); err == nil {
nets = append(nets, p.Masked())
}
}
if p, err := netip.ParsePrefix(n.LAN.Address); err == nil {
apGW = p.Addr()
}
} else {
var b config.Management
s.DB.GetJSON(keyBootstrap, &b)
for _, c := range b.AllowedSubnets {
if p, err := netip.ParsePrefix(c); err == nil {
nets = append(nets, p.Masked())
}
}
}
s.mgmtMu.Lock()
s.mgmtNets, s.apGW = nets, apGW
s.mgmtMu.Unlock()
}
func remoteAddr(r *http.Request) netip.Addr {
ap, err := netip.ParseAddrPort(r.RemoteAddr)
if err != nil {
return netip.Addr{}
}
return ap.Addr().Unmap()
}
// sourceCheck is defence in depth behind the nftables rules: only allow-listed
// management subnets, and never a request that arrived on the AP gateway address.
func (s *Server) sourceCheck(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ra := remoteAddr(r)
s.mgmtMu.RLock()
nets, apGW := s.mgmtNets, s.apGW
s.mgmtMu.RUnlock()
if la, ok := r.Context().Value(http.LocalAddrContextKey).(net.Addr); ok && apGW.IsValid() {
if lap, err := netip.ParseAddrPort(la.String()); err == nil && lap.Addr().Unmap() == apGW {
http.Error(w, "management is not available on the access-point network", http.StatusForbidden)
return
}
}
allowed := ra.IsLoopback()
for _, p := range nets {
if p.Contains(ra) {
allowed = true
}
}
if !allowed {
slog.Warn("management request from non-allowed source", "remote", ra)
http.Error(w, "source address not allowed for management", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
func (s *Server) requireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
c, err := r.Cookie(cookieName)
if err != nil {
httpErr(w, http.StatusUnauthorized, "not logged in")
return
}
th := auth.TokenHash(c.Value)
now := time.Now()
var u user
var lastSeen, expires int64
err = s.DB.QueryRow(`SELECT u.id,u.username,s.csrf,u.totp_enabled,s.last_seen,s.expires_at FROM sessions s JOIN users u ON u.id=s.user_id WHERE s.token_hash=?`, th).
Scan(&u.ID, &u.Name, &u.CSRF, &u.TOTPEnabled, &lastSeen, &expires)
if err != nil || now.Unix() > expires || now.Unix()-lastSeen > int64(sessionIdle.Seconds()) {
if err == nil {
s.DB.Exec(`DELETE FROM sessions WHERE token_hash=?`, th)
}
httpErr(w, http.StatusUnauthorized, "session expired")
return
}
u.TokenHash = th
if r.Method != http.MethodGet && r.Method != http.MethodHead {
if subtle.ConstantTimeCompare([]byte(r.Header.Get("X-CSRF-Token")), []byte(u.CSRF)) != 1 {
httpErr(w, http.StatusForbidden, "missing or invalid CSRF token")
return
}
}
if now.Unix()-lastSeen > 30 && r.URL.Path != "/api/events" {
s.DB.Exec(`UPDATE sessions SET last_seen=? WHERE token_hash=?`, now.Unix(), th)
}
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxKey{}, &u)))
})
}
func currentUser(r *http.Request) *user { u, _ := r.Context().Value(ctxKey{}).(*user); return u }
func (s *Server) audit(r *http.Request, action, detail string) {
actor := "anonymous"
if u := currentUser(r); u != nil {
actor = u.Name
}
s.DB.Audit(actor, action, detail, remoteAddr(r).String())
}
// ---------------------------------------------------------------- helpers
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v)
}
func httpErr(w http.ResponseWriter, code int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(map[string]string{"error": msg})
}
func readJSON(w http.ResponseWriter, r *http.Request, v any) bool {
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
d := json.NewDecoder(r.Body)
d.DisallowUnknownFields()
if err := d.Decode(v); err != nil {
httpErr(w, http.StatusBadRequest, "invalid request body: "+err.Error())
return false
}
return true
}
// requireXHR blocks cross-site form posts to unauthenticated endpoints: a custom
// header cannot be sent cross-origin without a CORS preflight, which we never allow.
func requireXHR(w http.ResponseWriter, r *http.Request) bool {
if r.Header.Get("X-Requested-With") != "pilens" {
httpErr(w, http.StatusForbidden, "missing X-Requested-With header")
return false
}
return true
}
// ---------------------------------------------------------------- setup + auth
func (s *Server) adminExists() bool {
var n int
s.DB.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&n)
return n > 0
}
func (s *Server) setupComplete() bool {
var done bool
s.DB.GetJSON(keySetupDone, &done)
return done
}
func (s *Server) setupStatus(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]any{"needs_admin": !s.adminExists(), "setup_complete": s.setupComplete(), "version": s.Version})
}
func (s *Server) setupAdmin(w http.ResponseWriter, r *http.Request) {
if !requireXHR(w, r) {
return
}
var req struct {
Token string `json:"token"`
Username string `json:"username"`
Password string `json:"password"`
Authorised bool `json:"authorised"`
}
if !readJSON(w, r, &req) {
return
}
ra := remoteAddr(r).String()
if ok, wait := s.limiter.Allowed("setup:"+ra, time.Now()); !ok {
httpErr(w, http.StatusTooManyRequests, "too many attempts; retry in "+wait.Round(time.Second).String())
return
}
want, err := os.ReadFile(s.SetupTokenPath)
if err != nil || s.adminExists() {
httpErr(w, http.StatusConflict, "setup already completed")
return
}
if subtle.ConstantTimeCompare([]byte(strings.TrimSpace(string(want))), []byte(strings.TrimSpace(req.Token))) != 1 {
s.limiter.Fail("setup:"+ra, time.Now())
httpErr(w, http.StatusForbidden, "invalid setup token (printed by the installer; also in "+s.SetupTokenPath+")")
return
}
if !req.Authorised {
httpErr(w, http.StatusBadRequest, "you must confirm this appliance will only inspect traffic you are authorised to inspect")
return
}
if !validUsername(req.Username) {
httpErr(w, http.StatusBadRequest, "username must be 1-32 letters, digits, '.', '_' or '-'")
return
}
if err := auth.PasswordPolicy(req.Password); err != nil {
httpErr(w, http.StatusBadRequest, err.Error())
return
}
h, err := auth.HashPassword(req.Password)
if err != nil {
httpErr(w, 500, err.Error())
return
}
// The INSERT is conditional so two racing requests cannot both create an admin.
res, err := s.DB.Exec(`INSERT INTO users(username,password_hash,created_at) SELECT ?,?,? WHERE NOT EXISTS (SELECT 1 FROM users)`, req.Username, h, time.Now().Unix())
if n, _ := res.RowsAffected(); err != nil || n == 0 {
httpErr(w, http.StatusConflict, "setup already completed")
return
}
os.Remove(s.SetupTokenPath)
s.DB.Audit(req.Username, "setup.admin_created", "authorised-use confirmation recorded", ra)
s.startSession(w, r, req.Username)
}
func validUsername(u string) bool {
if len(u) < 1 || len(u) > 32 {
return false
}
for _, c := range u {
if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '.' || c == '_' || c == '-') {
return false
}
}
return true
}
func (s *Server) startSession(w http.ResponseWriter, r *http.Request, username string) {
var uid int64
if err := s.DB.QueryRow(`SELECT id FROM users WHERE username=?`, username).Scan(&uid); err != nil {
httpErr(w, 500, "user lookup failed")
return
}
tok := auth.RandomToken(32)
csrf := auth.RandomToken(32)
now := time.Now().Unix()
s.DB.Exec(`DELETE FROM sessions WHERE expires_at<?`, now)
if _, err := s.DB.Exec(`INSERT INTO sessions(token_hash,user_id,csrf,created_at,last_seen,expires_at,remote) VALUES(?,?,?,?,?,?,?)`,
auth.TokenHash(tok), uid, csrf, now, now, now+int64(sessionMax.Seconds()), remoteAddr(r).String()); err != nil {
httpErr(w, 500, "session failed")
return
}
http.SetCookie(w, &http.Cookie{Name: cookieName, Value: tok, Path: "/", HttpOnly: true, Secure: true, SameSite: http.SameSiteStrictMode, MaxAge: int(sessionMax.Seconds())})
writeJSON(w, map[string]any{"user": username, "csrf": csrf})
}
func (s *Server) login(w http.ResponseWriter, r *http.Request) {
if !requireXHR(w, r) {
return
}
var req struct {
Username string `json:"username"`
Password string `json:"password"`
TOTP string `json:"totp"`
}
if !readJSON(w, r, &req) {
return
}
now := time.Now()
ipKey, userKey := "ip:"+remoteAddr(r).String(), "user:"+strings.ToLower(req.Username)
for _, k := range []string{ipKey, userKey} {
if ok, wait := s.limiter.Allowed(k, now); !ok {
httpErr(w, http.StatusTooManyRequests, "too many failed attempts; retry in "+wait.Round(time.Second).String())
return
}
}
var hash, secret string
var totp bool
err := s.DB.QueryRow(`SELECT password_hash,totp_secret,totp_enabled FROM users WHERE username=?`, req.Username).Scan(&hash, &secret, &totp)
if err == sql.ErrNoRows {
hash = "$argon2id$v=19$m=65536,t=2,p=2$c2FsdHNhbHRzYWx0c2FsdA$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" // equalise timing
}
ok := auth.CheckPassword(req.Password, hash) && err == nil
if ok && totp {
if req.TOTP == "" {
httpErr(w, http.StatusUnauthorized, "totp_required")
return
}
ok = auth.CheckTOTP(secret, req.TOTP, now)
}
if !ok {
s.limiter.Fail(ipKey, now)
s.limiter.Fail(userKey, now)
s.DB.Audit(req.Username, "auth.login_failed", "", remoteAddr(r).String())
httpErr(w, http.StatusUnauthorized, "invalid credentials")
return
}
s.limiter.Success(ipKey)
s.limiter.Success(userKey)
s.DB.Audit(req.Username, "auth.login", "", remoteAddr(r).String())
s.startSession(w, r, req.Username)
}
func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
s.DB.Exec(`DELETE FROM sessions WHERE token_hash=?`, currentUser(r).TokenHash)
http.SetCookie(w, &http.Cookie{Name: cookieName, Value: "", Path: "/", MaxAge: -1, Secure: true, HttpOnly: true, SameSite: http.SameSiteStrictMode})
writeJSON(w, map[string]bool{"ok": true})
}
func (s *Server) session(w http.ResponseWriter, r *http.Request) {
u := currentUser(r)
writeJSON(w, map[string]any{"user": u.Name, "csrf": u.CSRF, "totp_enabled": u.TOTPEnabled, "setup_complete": s.setupComplete(), "version": s.Version})
}
func (s *Server) checkPassword(u *user, pw string) bool {
var hash string
s.DB.QueryRow(`SELECT password_hash FROM users WHERE id=?`, u.ID).Scan(&hash)
return auth.CheckPassword(pw, hash)
}
func (s *Server) changePassword(w http.ResponseWriter, r *http.Request) {
var req struct{ Current, New string }
if !readJSON(w, r, &req) {
return
}
u := currentUser(r)
if !s.checkPassword(u, req.Current) {
httpErr(w, http.StatusForbidden, "current password is wrong")
return
}
if err := auth.PasswordPolicy(req.New); err != nil {
httpErr(w, http.StatusBadRequest, err.Error())
return
}
h, _ := auth.HashPassword(req.New)
s.DB.Exec(`UPDATE users SET password_hash=? WHERE id=?`, h, u.ID)
s.DB.Exec(`DELETE FROM sessions WHERE user_id=? AND token_hash!=?`, u.ID, u.TokenHash)
s.audit(r, "auth.password_changed", "other sessions revoked")
writeJSON(w, map[string]bool{"ok": true})
}
func (s *Server) totpBegin(w http.ResponseWriter, r *http.Request) {
u := currentUser(r)
if u.TOTPEnabled {
httpErr(w, http.StatusConflict, "TOTP already enabled")
return
}
sec := auth.NewTOTPSecret()
s.DB.Exec(`UPDATE users SET totp_secret=? WHERE id=?`, sec, u.ID)
writeJSON(w, map[string]string{"secret": sec, "uri": auth.TOTPURI("PiLens", u.Name, sec)})
}
func (s *Server) totpEnable(w http.ResponseWriter, r *http.Request) {
var req struct{ Code string }
if !readJSON(w, r, &req) {
return
}
u := currentUser(r)
var sec string
s.DB.QueryRow(`SELECT totp_secret FROM users WHERE id=?`, u.ID).Scan(&sec)
if sec == "" || !auth.CheckTOTP(sec, req.Code, time.Now()) {
httpErr(w, http.StatusBadRequest, "code does not match; check the device clock")
return
}
s.DB.Exec(`UPDATE users SET totp_enabled=1 WHERE id=?`, u.ID)
s.audit(r, "auth.totp_enabled", "")
writeJSON(w, map[string]bool{"ok": true})
}
func (s *Server) totpDisable(w http.ResponseWriter, r *http.Request) {
var req struct{ Password string }
if !readJSON(w, r, &req) {
return
}
u := currentUser(r)
if !s.checkPassword(u, req.Password) {
httpErr(w, http.StatusForbidden, "password is wrong")
return
}
s.DB.Exec(`UPDATE users SET totp_enabled=0, totp_secret='' WHERE id=?`, u.ID)
s.audit(r, "auth.totp_disabled", "")
writeJSON(w, map[string]bool{"ok": true})
}
func (s *Server) auditLog(w http.ResponseWriter, r *http.Request) {
rows, err := s.DB.Query(`SELECT ts,actor,action,detail,remote FROM audit ORDER BY id DESC LIMIT 500`)
if err != nil {
httpErr(w, 500, err.Error())
return
}
defer rows.Close()
type ev struct {
TS int64
Actor, Action, Detail, Remote string
}
out := []ev{}
for rows.Next() {
var e ev
rows.Scan(&e.TS, &e.Actor, &e.Action, &e.Detail, &e.Remote)
out = append(out, e)
}
writeJSON(w, out)
}
// events streams live updates (SSE). Slow clients lose events and are told to resync.
func (s *Server) events(w http.ResponseWriter, r *http.Request) {
fl, ok := w.(http.Flusher)
if !ok {
httpErr(w, 500, "streaming unsupported")
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("X-Accel-Buffering", "no")
sub := s.Engine.Hub.Subscribe()
defer s.Engine.Hub.Unsubscribe(sub)
rc := http.NewResponseController(w)
keep := time.NewTicker(15 * time.Second)
defer keep.Stop()
write := func(typ string, data []byte) error {
rc.SetWriteDeadline(time.Now().Add(10 * time.Second))
if _, err := w.Write([]byte("event: " + typ + "\ndata: ")); err != nil {
return err
}
w.Write(data)
_, err := w.Write([]byte("\n\n"))
fl.Flush()
return err
}
write("hello", []byte(`{}`))
for {
select {
case <-r.Context().Done():
return
case ev := <-sub.C:
if sub.Resync.Swap(false) {
d, _ := json.Marshal(map[string]int64{"ui_dropped": sub.Dropped.Load()})
if write("resync", d) != nil {
return
}
}
if write(ev.Type, ev.Data) != nil {
return
}
case <-keep.C:
if write("ping", []byte(`{}`)) != nil {
return
}
}
}
}
var errNoNetwork = errors.New("network not configured")