From 31e39bdbef8a32ee64aff606d75b6a895af9aa62 Mon Sep 17 00:00:00 2001 From: Andy Dixon Date: Fri, 9 Oct 2026 17:39:24 +0100 Subject: [PATCH] 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. --- .gitignore | 3 +- cmd/pilens-helper/main.go | 4 +- cmd/pilens/main.go | 509 ++++++++++++++++++++++++++++++++ go.mod | 1 + go.sum | 4 + internal/api/api_test.go | 153 ++++++++++ internal/api/capture.go | 320 ++++++++++++++++++++ internal/api/network.go | 292 +++++++++++++++++++ internal/api/server.go | 562 ++++++++++++++++++++++++++++++++++++ internal/capture/engine.go | 30 +- internal/capture/query.go | 12 +- internal/config/config.go | 4 +- internal/helper/helper.go | 12 +- internal/sysinfo/sysinfo.go | 259 +++++++++++++++++ web/dist/.gitkeep | 0 web/embed.go | 16 + 16 files changed, 2150 insertions(+), 31 deletions(-) create mode 100644 cmd/pilens/main.go create mode 100644 internal/api/api_test.go create mode 100644 internal/api/capture.go create mode 100644 internal/api/network.go create mode 100644 internal/api/server.go create mode 100644 internal/sysinfo/sysinfo.go create mode 100644 web/dist/.gitkeep create mode 100644 web/embed.go diff --git a/.gitignore b/.gitignore index ce9852a..45fa304 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /dist/ /web/node_modules/ -/web/dist/ +/web/dist/* +!/web/dist/.gitkeep *.test diff --git a/cmd/pilens-helper/main.go b/cmd/pilens-helper/main.go index 3641c40..f79e198 100644 --- a/cmd/pilens-helper/main.go +++ b/cmd/pilens-helper/main.go @@ -103,7 +103,9 @@ func peerUID(c *net.UnixConn) (uint32, error) { } var cred *syscall.Ucred var cerr error - raw.Control(func(fd uintptr) { cred, cerr = syscall.GetsockoptUcred(int(fd), syscall.SOL_SOCKET, syscall.SO_PEERCRED) }) + raw.Control(func(fd uintptr) { + cred, cerr = syscall.GetsockoptUcred(int(fd), syscall.SOL_SOCKET, syscall.SO_PEERCRED) + }) if cerr != nil { return 0, cerr } diff --git a/cmd/pilens/main.go b/cmd/pilens/main.go new file mode 100644 index 0000000..386df72 --- /dev/null +++ b/cmd/pilens/main.go @@ -0,0 +1,509 @@ +// pilens is the PiLens management service (runs as the unprivileged "pilens" user). +package main + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/hex" + "encoding/pem" + "errors" + "flag" + "fmt" + "log/slog" + "math/big" + "net" + "net/http" + "os" + "os/exec" + "os/signal" + "path/filepath" + "strconv" + "strings" + "syscall" + "time" + + "gopkg.in/yaml.v3" + + "pilens.dev/pilens/internal/api" + "pilens.dev/pilens/internal/auth" + "pilens.dev/pilens/internal/capture" + "pilens.dev/pilens/internal/config" + "pilens.dev/pilens/internal/helper" + "pilens.dev/pilens/internal/pcapfile" + "pilens.dev/pilens/internal/store" + "pilens.dev/pilens/web" +) + +// Version is set at build time with -ldflags "-X main.Version=...". +var Version = "dev" + +func usage() { + fmt.Fprintf(os.Stderr, `PiLens %s + +usage: + pilens serve [-data DIR] [-listen ADDR] [-captures DIR] + pilens init [-data DIR] -mgmt-iface IF[,IF] -mgmt-subnet CIDR[,CIDR] + pilens setup [-data DIR] -config setup.yaml (non-interactive first-run) + pilens reset-password [-data DIR] -user NAME (reads new password from stdin) + pilens version +`, Version) + os.Exit(2) +} + +func main() { + if len(os.Args) < 2 { + usage() + } + cmd, args := os.Args[1], os.Args[2:] + var err error + switch cmd { + case "serve": + err = serve(args) + case "init": + err = initCmd(args) + case "setup": + err = setupCmd(args) + case "reset-password": + err = resetPassword(args) + case "rlimit-exec": + err = rlimitExec(args) + case "version": + fmt.Println(Version) + default: + usage() + } + if err != nil { + fmt.Fprintln(os.Stderr, "pilens:", err) + os.Exit(1) + } +} + +const defaultData = "/var/lib/pilens" + +func openStore(data string) (*store.Store, error) { + if err := os.MkdirAll(data, 0o750); err != nil { + return nil, err + } + return store.Open(filepath.Join(data, "pilens.db")) +} + +// ---------------------------------------------------------------- serve + +func serve(args []string) error { + fs := flag.NewFlagSet("serve", flag.ExitOnError) + data := fs.String("data", defaultData, "state directory") + listen := fs.String("listen", ":8443", "HTTPS listen address") + caps := fs.String("captures", "", "capture storage directory (default DATA/captures)") + sock := fs.String("helper-socket", helper.DefaultSocket, "root helper socket") + fs.Parse(args) + slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stderr, nil))) + if *caps == "" { + *caps = filepath.Join(*data, "captures") + } + if err := os.MkdirAll(*caps, 0o750); err != nil { + return err + } + db, err := openStore(*data) + if err != nil { + return err + } + defer db.Close() + + self, _ := os.Executable() + tools := &capture.Tools{Self: self} + if tools.TShark, err = exec.LookPath("tshark"); err != nil { + return errors.New("tshark not found: install the tshark package") + } + if tools.Dumpcap, err = exec.LookPath("dumpcap"); err != nil { + return errors.New("dumpcap not found: install wireshark-common") + } + if err := tools.Init(*data); err != nil { + return err + } + eng := &capture.Engine{DB: db, Dir: *caps, Tools: tools, Hub: capture.NewHub(), Version: Version} + lim := capture.DefaultLimits() + db.GetJSON("capture_limits", &lim) + eng.SetLimits(lim) + recoverSessions(db, *caps) + + cert, fp, err := ensureCert(*data) + if err != nil { + return err + } + slog.Info("management certificate", "sha256", fp) + srv := &api.Server{DB: db, Engine: eng, Helper: helper.Client{Socket: *sock}, Version: Version, Web: web.FS(), + SetupTokenPath: filepath.Join(*data, "setup-token"), DataDir: *data} + hs := &http.Server{ + Addr: *listen, + Handler: srv.Handler(), + TLSConfig: &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12}, + ReadHeaderTimeout: 10 * time.Second, + IdleTimeout: 2 * time.Minute, + ErrorLog: slog.NewLogLogger(slog.Default().Handler(), slog.LevelWarn), + } + ln, err := net.Listen("tcp", *listen) + if err != nil { + return err + } + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT) + defer stop() + go func() { + if err := hs.ServeTLS(ln, "", ""); err != nil && err != http.ErrServerClosed { + slog.Error("http server", "err", err) + stop() + } + }() + sdNotify("READY=1") + go watchdog(ctx, db) + slog.Info("pilens serving", "listen", *listen, "version", Version) + <-ctx.Done() + + // Shutdown order: stop accepting requests, finalise any capture (flush, hash), + // then close the database. systemd TimeoutStopSec must cover this. + sdNotify("STOPPING=1") + shCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + hs.Shutdown(shCtx) + if _, running := eng.Live(); running { + slog.Info("stopping capture for shutdown") + eng.Stop() + } + return nil +} + +// recoverSessions finalises sessions left running by a crash or power loss. +func recoverSessions(db *store.Store, dir string) { + rows, err := db.Query(`SELECT s.id, g.seq, g.file, g.format FROM segments g JOIN capture_sessions s ON s.id=g.session_id WHERE g.complete=0 AND g.deleted=0`) + if err == nil { + type seg struct { + id, file, format string + seq int + } + var segs []seg + for rows.Next() { + var s seg + rows.Scan(&s.id, &s.seq, &s.file, &s.format) + segs = append(segs, s) + } + rows.Close() + for _, s := range segs { + p := filepath.Join(dir, s.id, s.file) + n := 0 + scanErr := pcapfile.Scan(p, s.format, func(int, int64, []byte) error { n++; return nil }) + f, err := os.Open(p) + if err != nil { + continue + } + h := sha256.New() + size, _ := f.Seek(0, 2) + f.Seek(0, 0) + buf := make([]byte, 1<<20) + for { + k, err := f.Read(buf) + h.Write(buf[:k]) + if err != nil { + break + } + } + f.Close() + db.Exec(`UPDATE segments SET count=?,bytes=?,sha256=?,complete=1 WHERE session_id=? AND seq=?`, n, size, hex.EncodeToString(h.Sum(nil)), s.id, s.seq) + slog.Warn("recovered interrupted segment", "session", s.id, "segment", s.seq, "packets", n, "truncated_tail", scanErr != nil) + } + } + db.Exec(`UPDATE capture_sessions SET state='failed', error='interrupted: the service stopped unexpectedly (power loss or crash); segments were finalised on restart and summaries may be incomplete', ended_at=? WHERE state IN ('running','importing')`, time.Now().UnixMilli()) +} + +func sdNotify(state string) { + addr := os.Getenv("NOTIFY_SOCKET") + if addr == "" { + return + } + c, err := net.Dial("unixgram", addr) + if err != nil { + return + } + defer c.Close() + c.Write([]byte(state)) +} + +// watchdog pings systemd only while the database answers, so a wedged service is restarted. +func watchdog(ctx context.Context, db *store.Store) { + us, err := strconv.Atoi(os.Getenv("WATCHDOG_USEC")) + if err != nil || us <= 0 { + return + } + t := time.NewTicker(time.Duration(us/3) * time.Microsecond) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + c, cancel := context.WithTimeout(ctx, 5*time.Second) + if db.PingContext(c) == nil { + sdNotify("WATCHDOG=1") + } + cancel() + } + } +} + +// ensureCert loads or creates the self-signed management certificate. It is +// independent of any TLS-interception CA. +func ensureCert(data string) (tls.Certificate, string, error) { + dir := filepath.Join(data, "tls") + cp, kp := filepath.Join(dir, "mgmt.crt"), filepath.Join(dir, "mgmt.key") + if c, err := tls.LoadX509KeyPair(cp, kp); err == nil { + sum := sha256.Sum256(c.Certificate[0]) + return c, fingerprint(sum[:]), nil + } + os.MkdirAll(dir, 0o700) + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return tls.Certificate{}, "", err + } + host, _ := os.Hostname() + serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 127)) + tpl := &x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{CommonName: host + " PiLens management", Organization: []string{"PiLens appliance"}}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().AddDate(5, 0, 0), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + DNSNames: []string{host, host + ".local", "localhost"}, + } + if ifs, err := net.InterfaceAddrs(); err == nil { + for _, a := range ifs { + if ipn, ok := a.(*net.IPNet); ok { + tpl.IPAddresses = append(tpl.IPAddresses, ipn.IP) + } + } + } + der, err := x509.CreateCertificate(rand.Reader, tpl, tpl, &key.PublicKey, key) + if err != nil { + return tls.Certificate{}, "", err + } + kb, _ := x509.MarshalECPrivateKey(key) + if err := os.WriteFile(kp, pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: kb}), 0o600); err != nil { + return tls.Certificate{}, "", err + } + os.WriteFile(cp, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o644) + c, err := tls.LoadX509KeyPair(cp, kp) + sum := sha256.Sum256(der) + return c, fingerprint(sum[:]), err +} + +func fingerprint(b []byte) string { + h := strings.ToUpper(hex.EncodeToString(b)) + var parts []string + for i := 0; i < len(h); i += 2 { + parts = append(parts, h[i:i+2]) + } + return strings.Join(parts, ":") +} + +// ---------------------------------------------------------------- init / setup + +func splitList(s string) []string { + var out []string + for _, x := range strings.Split(s, ",") { + if x = strings.TrimSpace(x); x != "" { + out = append(out, x) + } + } + return out +} + +func initCmd(args []string) error { + fs := flag.NewFlagSet("init", flag.ExitOnError) + data := fs.String("data", defaultData, "state directory") + ifs := fs.String("mgmt-iface", "", "management interface(s), comma separated") + subs := fs.String("mgmt-subnet", "", "management source subnet(s), comma separated") + fs.Parse(args) + m := config.Management{Interfaces: splitList(*ifs), AllowedSubnets: splitList(*subs), Port: 8443} + if len(m.Interfaces) == 0 || len(m.AllowedSubnets) == 0 { + return errors.New("-mgmt-iface and -mgmt-subnet are required") + } + for _, i := range m.Interfaces { + if !config.ValidInterface(i) { + return fmt.Errorf("invalid interface %q", i) + } + } + for _, s := range m.AllowedSubnets { + if _, _, err := net.ParseCIDR(s); err != nil { + return fmt.Errorf("invalid subnet %q", s) + } + } + db, err := openStore(*data) + if err != nil { + return err + } + defer db.Close() + if err := db.PutJSON("bootstrap_management", m); err != nil { + return err + } + _, fp, err := ensureCert(*data) + if err != nil { + return err + } + fmt.Println("certificate_sha256=" + fp) + var users int + db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&users) + if users == 0 { + tp := filepath.Join(*data, "setup-token") + tok, err := os.ReadFile(tp) + if err != nil { + tok = []byte(auth.RandomToken(12)) + if err := os.WriteFile(tp, tok, 0o600); err != nil { + return err + } + } + fmt.Println("setup_token=" + strings.TrimSpace(string(tok))) + } + return nil +} + +type setupFile struct { + Admin struct { + Username string `yaml:"username"` + Password string `yaml:"password"` + PasswordFile string `yaml:"password_file"` + } `yaml:"admin"` + AuthorisedUse bool `yaml:"authorised_use"` + Network config.Network `yaml:"network"` + Storage *capture.Limits `yaml:"storage"` +} + +func setupCmd(args []string) error { + fs := flag.NewFlagSet("setup", flag.ExitOnError) + data := fs.String("data", defaultData, "state directory") + cfgPath := fs.String("config", "", "setup YAML") + sock := fs.String("helper-socket", helper.DefaultSocket, "root helper socket") + fs.Parse(args) + b, err := os.ReadFile(*cfgPath) + if err != nil { + return err + } + var sf setupFile + sf.Network = config.DefaultNetwork() + dec := yaml.NewDecoder(strings.NewReader(string(b))) + dec.KnownFields(true) + if err := dec.Decode(&sf); err != nil { + return fmt.Errorf("%s: %w", *cfgPath, err) + } + if !sf.AuthorisedUse { + return errors.New("authorised_use: true is required (confirms the appliance will only inspect traffic you are authorised to inspect)") + } + pw := sf.Admin.Password + if sf.Admin.PasswordFile != "" { + p, err := os.ReadFile(sf.Admin.PasswordFile) + if err != nil { + return err + } + pw = strings.TrimRight(string(p), "\r\n") + } + if err := auth.PasswordPolicy(pw); err != nil { + return err + } + if err := sf.Network.Validate(true); err != nil { + return err + } + db, err := openStore(*data) + if err != nil { + return err + } + defer db.Close() + h, _ := auth.HashPassword(pw) + res, err := db.Exec(`INSERT INTO users(username,password_hash,created_at) SELECT ?,?,? WHERE NOT EXISTS (SELECT 1 FROM users)`, sf.Admin.Username, h, time.Now().Unix()) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + fmt.Println("an administrator already exists; leaving accounts unchanged") + } else { + os.Remove(filepath.Join(*data, "setup-token")) + db.Audit(sf.Admin.Username, "setup.admin_created", "non-interactive setup; authorised-use confirmation recorded", "local") + } + if sf.Storage != nil { + db.PutJSON("capture_limits", *sf.Storage) + } + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + var r helper.ApplyResult + err = helper.Client{Socket: *sock}.Call(ctx, helper.Request{Op: helper.OpApplyNetwork, Network: &sf.Network}, &r) + for _, s := range r.Steps { + fmt.Println(" " + s) + } + if err != nil { + return fmt.Errorf("network apply failed: %w", err) + } + db.PutJSON("network", sf.Network) + db.PutJSON("setup_complete", true) + db.Audit("local", "setup.completed", "non-interactive", "local") + fmt.Println("setup complete") + return nil +} + +func resetPassword(args []string) error { + fs := flag.NewFlagSet("reset-password", flag.ExitOnError) + data := fs.String("data", defaultData, "state directory") + user := fs.String("user", "", "username") + fs.Parse(args) + var pw string + fmt.Fprint(os.Stderr, "new password: ") + fmt.Fscanln(os.Stdin, &pw) + if err := auth.PasswordPolicy(pw); err != nil { + return err + } + db, err := openStore(*data) + if err != nil { + return err + } + defer db.Close() + h, _ := auth.HashPassword(pw) + res, err := db.Exec(`UPDATE users SET password_hash=?, totp_enabled=0, totp_secret='' WHERE username=?`, h, *user) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return errors.New("no such user") + } + db.Exec(`DELETE FROM sessions`) + db.Audit("local", "auth.password_reset", *user+" (TOTP disabled, all sessions revoked)", "console") + fmt.Println("password reset; TOTP disabled; all sessions revoked") + return nil +} + +// rlimitExec applies resource limits and no_new_privs, then execs tshark. +func rlimitExec(args []string) error { + if len(args) < 4 || args[2] != "--" || filepath.Base(args[3]) != "tshark" { + return errors.New("usage: rlimit-exec MEM_MB CPU_SECONDS -- /path/to/tshark ARGS") + } + mb, err1 := strconv.ParseUint(args[0], 10, 64) + cpu, err2 := strconv.ParseUint(args[1], 10, 64) + if err1 != nil || err2 != nil { + return errors.New("invalid limits") + } + if mb > 0 { + lim := mb << 20 + if err := syscall.Setrlimit(syscall.RLIMIT_AS, &syscall.Rlimit{Cur: lim, Max: lim}); err != nil { + return err + } + } + if cpu > 0 { + syscall.Setrlimit(syscall.RLIMIT_CPU, &syscall.Rlimit{Cur: cpu, Max: cpu + 5}) + } + syscall.Setrlimit(syscall.RLIMIT_CORE, &syscall.Rlimit{}) + const prSetNoNewPrivs = 38 + if _, _, e := syscall.RawSyscall(syscall.SYS_PRCTL, prSetNoNewPrivs, 1, 0); e != 0 { + return e + } + return syscall.Exec(args[3], args[3:], os.Environ()) +} diff --git a/go.mod b/go.mod index 56531c7..304c4da 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.27.1 require ( golang.org/x/crypto v0.57.0 + gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.60.1 ) diff --git a/go.sum b/go.sum index 84f9bec..817d352 100644 --- a/go.sum +++ b/go.sum @@ -22,6 +22,10 @@ golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU= golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= modernc.org/cc/v4 v4.29.7 h1:q+NXGJ0bK3b4TXFYQQVr9pYETGnmwFWkrUzJnMya/Tg= modernc.org/cc/v4 v4.29.7/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= modernc.org/ccgo/v4 v4.36.1 h1:ZNIUZAryN0UgnJwtyxrdEzcFc3yD4Cu4AzjfPXsLsIE= diff --git a/internal/api/api_test.go b/internal/api/api_test.go new file mode 100644 index 0000000..462afa4 --- /dev/null +++ b/internal/api/api_test.go @@ -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 +} diff --git a/internal/api/capture.go b/internal/api/capture.go new file mode 100644 index 0000000..bb0fd24 --- /dev/null +++ b/internal/api/capture.go @@ -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=") + 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}) +} diff --git a/internal/api/network.go b/internal/api/network.go new file mode 100644 index 0000000..35b6260 --- /dev/null +++ b/internal/api/network.go @@ -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}) +} diff --git a/internal/api/server.go b/internal/api/server.go new file mode 100644 index 0000000..d5eb196 --- /dev/null +++ b/internal/api/server.go @@ -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 IPv4 default gateway from /proc/net/route. +func DefaultRoutes() map[string]string { + out := map[string]string{} + f, err := os.Open("/proc/net/route") + if err != nil { + return out + } + defer f.Close() + sc := bufio.NewScanner(f) + sc.Scan() + for sc.Scan() { + x := strings.Fields(sc.Text()) + if len(x) < 3 || x[1] != "00000000" { + continue + } + b, err := hex.DecodeString(x[2]) + if err != nil || len(b) != 4 { + continue + } + if _, ok := out[x[0]]; !ok { + out[x[0]] = net.IPv4(b[3], b[2], b[1], b[0]).String() + } + } + return out +} + +func Interfaces() []Interface { + ifs, _ := net.Interfaces() + routes := DefaultRoutes() + var out []Interface + for _, i := range ifs { + if i.Flags&net.FlagLoopback != 0 { + continue + } + base := "/sys/class/net/" + i.Name + x := Interface{Name: i.Name, MAC: i.HardwareAddr.String(), Up: i.Flags&net.FlagUp != 0, DefaultGW: routes[i.Name]} + x.Carrier = readTrim(base+"/carrier") == "1" + _, err := os.Stat(base + "/wireless") + x.Wireless = err == nil + if _, err := os.Stat(base + "/phy80211"); err == nil { + x.Wireless = true + } + if d, err := os.Readlink(base + "/device/driver"); err == nil { + x.Driver = filepath.Base(d) + } + _, err = os.Stat(base + "/device") + x.Virtual = err != nil + x.SpeedMbps, _ = strconv.Atoi(readTrim(base + "/speed")) + x.RxDropped, _ = strconv.ParseUint(readTrim(base+"/statistics/rx_dropped"), 10, 64) + addrs, _ := i.Addrs() + for _, a := range addrs { + x.Addrs = append(x.Addrs, a.String()) + } + out = append(out, x) + } + return out +} + +type WifiCaps struct { + Phy string `json:"phy"` + AP bool `json:"ap_mode"` + Bands []string `json:"bands"` // "2.4GHz", "5GHz" + Raw string `json:"-"` + Error string `json:"error,omitempty"` +} + +// WifiCapabilities parses `iw list` (runs unprivileged). +func WifiCapabilities() []WifiCaps { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + out, err := exec.CommandContext(ctx, "iw", "list").Output() + if err != nil { + return []WifiCaps{{Error: "iw list failed: " + err.Error()}} + } + return ParseIWList(string(out)) +} + +func ParseIWList(s string) []WifiCaps { + var res []WifiCaps + var cur *WifiCaps + inModes := false + for _, line := range strings.Split(s, "\n") { + t := strings.TrimSpace(line) + if strings.HasPrefix(line, "Wiphy ") { + res = append(res, WifiCaps{Phy: strings.TrimPrefix(line, "Wiphy ")}) + cur = &res[len(res)-1] + inModes = false + continue + } + if cur == nil { + continue + } + switch { + case strings.HasPrefix(t, "Supported interface modes"): + inModes = true + continue + case strings.HasPrefix(t, "Band 1:"): + cur.Bands = append(cur.Bands, "2.4GHz") + case strings.HasPrefix(t, "Band 2:"): + cur.Bands = append(cur.Bands, "5GHz") + } + if inModes { + if strings.HasPrefix(t, "* ") { + if t == "* AP" { + cur.AP = true + } + } else { + inModes = false + } + } + } + return res +} + +type Health struct { + CPUPercent float64 `json:"cpu_percent"` + MemTotal uint64 `json:"mem_total"` + MemAvail uint64 `json:"mem_available"` + TempC *float64 `json:"temp_c,omitempty"` + Throttled string `json:"throttled,omitempty"` // vcgencmd get_throttled, raw + ThrottleBits []string `json:"throttle_flags,omitempty"` + Load string `json:"load"` + UptimeS float64 `json:"uptime_s"` + Disks map[string]Disk `json:"disks"` + ClockSync string `json:"clock_synchronised"` + OS string `json:"os"` + Kernel string `json:"kernel"` + Model string `json:"model"` + NMVersion string `json:"networkmanager"` +} + +type Disk struct { + Total uint64 `json:"total"` + Free uint64 `json:"free"` +} + +func cpuSample() (idle, total uint64) { + f := strings.Fields(strings.SplitN(readTrim("/proc/stat"), "\n", 2)[0]) + for i, v := range f[1:] { + n, _ := strconv.ParseUint(v, 10, 64) + total += n + if i == 3 || i == 4 { + idle += n + } + } + return +} + +func cmdOut(name string, args ...string) string { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + b, err := exec.CommandContext(ctx, name, args...).Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(b)) +} + +// throttle bit meanings from the Raspberry Pi firmware documentation. +var throttleNames = map[int]string{0: "under-voltage now", 1: "ARM frequency capped now", 2: "throttled now", 3: "soft temperature limit now", + 16: "under-voltage has occurred", 17: "frequency capping has occurred", 18: "throttling has occurred", 19: "soft temperature limit has occurred"} + +func ReadHealth(paths map[string]string) Health { + var h Health + i1, t1 := cpuSample() + time.Sleep(200 * time.Millisecond) + i2, t2 := cpuSample() + if t2 > t1 { + h.CPUPercent = 100 * (1 - float64(i2-i1)/float64(t2-t1)) + } + for _, l := range strings.Split(readTrim("/proc/meminfo"), "\n") { + f := strings.Fields(l) + if len(f) < 2 { + continue + } + v, _ := strconv.ParseUint(f[1], 10, 64) + switch f[0] { + case "MemTotal:": + h.MemTotal = v * 1024 + case "MemAvailable:": + h.MemAvail = v * 1024 + } + } + if t, err := strconv.ParseFloat(readTrim("/sys/class/thermal/thermal_zone0/temp"), 64); err == nil { + c := t / 1000 + h.TempC = &c + } + if s := cmdOut("vcgencmd", "get_throttled"); strings.HasPrefix(s, "throttled=") { + h.Throttled = s + if v, err := strconv.ParseUint(strings.TrimPrefix(s, "throttled="), 0, 32); err == nil { + for bit, name := range throttleNames { + if v&(1< 0 { + h.UptimeS, _ = strconv.ParseFloat(up[0], 64) + } + h.Disks = map[string]Disk{} + for name, p := range paths { + var st syscall.Statfs_t + if syscall.Statfs(p, &st) == nil { + h.Disks[name] = Disk{Total: st.Blocks * uint64(st.Bsize), Free: st.Bavail * uint64(st.Bsize)} + } + } + h.ClockSync = cmdOut("timedatectl", "show", "-p", "NTPSynchronized", "--value") + if h.ClockSync == "" { + h.ClockSync = "unknown" + } + for _, l := range strings.Split(readTrim("/etc/os-release"), "\n") { + if v, ok := strings.CutPrefix(l, "PRETTY_NAME="); ok { + h.OS = strings.Trim(v, `"`) + } + } + h.Kernel = readTrim("/proc/sys/kernel/osrelease") + h.Model = strings.TrimRight(readTrim("/proc/device-tree/model"), "\x00") + h.NMVersion = cmdOut("nmcli", "--version") + return h +} diff --git a/web/dist/.gitkeep b/web/dist/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/web/embed.go b/web/embed.go new file mode 100644 index 0000000..8e8ff4f --- /dev/null +++ b/web/embed.go @@ -0,0 +1,16 @@ +// Package web embeds the built management UI (web/dist, produced by `npm run build`). +package web + +import ( + "embed" + "io/fs" +) + +//go:embed all:dist +var dist embed.FS + +// FS returns the built UI rooted at dist/. +func FS() fs.FS { + f, _ := fs.Sub(dist, "dist") + return f +}