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:
+2
-1
@@ -1,4 +1,5 @@
|
||||
/dist/
|
||||
/web/node_modules/
|
||||
/web/dist/
|
||||
/web/dist/*
|
||||
!/web/dist/.gitkeep
|
||||
*.test
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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")
|
||||
+15
-15
@@ -32,10 +32,10 @@ import (
|
||||
)
|
||||
|
||||
type Limits struct {
|
||||
MaxBytes int64 `json:"max_bytes"` // total bytes of capture files
|
||||
ReserveBytes int64 `json:"reserve_bytes"` // free space that must remain on the filesystem
|
||||
SegmentBytes int64 `json:"segment_bytes"` // rotate segment files at this size
|
||||
ImportBytes int64 `json:"import_bytes"` // largest accepted upload
|
||||
MaxBytes int64 `json:"max_bytes" yaml:"max_bytes"` // total bytes of capture files
|
||||
ReserveBytes int64 `json:"reserve_bytes" yaml:"reserve_bytes"` // free space that must remain on the filesystem
|
||||
SegmentBytes int64 `json:"segment_bytes" yaml:"segment_bytes"` // rotate segment files at this size
|
||||
ImportBytes int64 `json:"import_bytes" yaml:"import_bytes"` // largest accepted upload
|
||||
}
|
||||
|
||||
func DefaultLimits() Limits {
|
||||
@@ -59,8 +59,8 @@ type Engine struct {
|
||||
fcache map[string]*filterEntry
|
||||
}
|
||||
|
||||
func (e *Engine) Limits() Limits { e.limMu.Lock(); defer e.limMu.Unlock(); return e.limits }
|
||||
func (e *Engine) SetLimits(l Limits) { e.limMu.Lock(); e.limits = l; e.limMu.Unlock() }
|
||||
func (e *Engine) Limits() Limits { e.limMu.Lock(); defer e.limMu.Unlock(); return e.limits }
|
||||
func (e *Engine) SetLimits(l Limits) { e.limMu.Lock(); e.limits = l; e.limMu.Unlock() }
|
||||
|
||||
// Session is the API view of a capture session.
|
||||
type Session struct {
|
||||
@@ -273,11 +273,11 @@ func clockSynced() string {
|
||||
func (e *Engine) meta(extra map[string]any) string {
|
||||
host, _ := os.Hostname()
|
||||
m := map[string]any{
|
||||
"pilens_version": e.Version,
|
||||
"tshark_version": e.Tools.Version(),
|
||||
"pilens_version": e.Version,
|
||||
"tshark_version": e.Tools.Version(),
|
||||
"clock_synchronised": clockSynced(),
|
||||
"hostname": host,
|
||||
"recorded_utc": time.Now().UTC().Format(time.RFC3339Nano),
|
||||
"hostname": host,
|
||||
"recorded_utc": time.Now().UTC().Format(time.RFC3339Nano),
|
||||
}
|
||||
for k, v := range extra {
|
||||
m[k] = v
|
||||
@@ -760,11 +760,11 @@ func freeBytes(dir string) int64 {
|
||||
}
|
||||
|
||||
type StorageInfo struct {
|
||||
Used int64 `json:"used"`
|
||||
Free int64 `json:"free"`
|
||||
Limits Limits `json:"limits"`
|
||||
Dir string `json:"dir"`
|
||||
LowWarning bool `json:"low_warning"`
|
||||
Used int64 `json:"used"`
|
||||
Free int64 `json:"free"`
|
||||
Limits Limits `json:"limits"`
|
||||
Dir string `json:"dir"`
|
||||
LowWarning bool `json:"low_warning"`
|
||||
}
|
||||
|
||||
func (e *Engine) Storage() StorageInfo {
|
||||
|
||||
@@ -39,13 +39,13 @@ func scanRows(rows *sql.Rows) ([]Row, error) {
|
||||
// Page is a window of packet rows. Position i in the (filtered) list is
|
||||
// Offset+i; rows may be missing where summaries are still being generated.
|
||||
type Page struct {
|
||||
Total int64 `json:"total"`
|
||||
First int64 `json:"first"` // packet number at position 0 (unfiltered views)
|
||||
Offset int64 `json:"offset"`
|
||||
Rows []Row `json:"rows"`
|
||||
Total int64 `json:"total"`
|
||||
First int64 `json:"first"` // packet number at position 0 (unfiltered views)
|
||||
Offset int64 `json:"offset"`
|
||||
Rows []Row `json:"rows"`
|
||||
Nums []int64 `json:"nums,omitempty"` // packet numbers for each position (filtered views)
|
||||
Filtered bool `json:"filtered"`
|
||||
Partial bool `json:"partial"` // filter results cover only part of the capture
|
||||
Filtered bool `json:"filtered"`
|
||||
Partial bool `json:"partial"` // filter results cover only part of the capture
|
||||
}
|
||||
|
||||
func (e *Engine) Packets(ctx context.Context, id, filter string, offset, limit int64) (Page, error) {
|
||||
|
||||
@@ -70,8 +70,8 @@ type Forwarding struct {
|
||||
func DefaultNetwork() Network {
|
||||
return Network{
|
||||
Management: Management{Port: 8443},
|
||||
AP: AP{Enabled: true, SSID: "PiLens-Lab", Security: "wpa2", Band: "bg", Channel: 0, Country: "GB"},
|
||||
LAN: LAN{Address: "192.168.50.1/24", DHCPStart: "192.168.50.100", DHCPEnd: "192.168.50.199", LeaseMinutes: 720},
|
||||
AP: AP{Enabled: true, SSID: "PiLens-Lab", Security: "wpa2", Band: "bg", Channel: 0, Country: "GB"},
|
||||
LAN: LAN{Address: "192.168.50.1/24", DHCPStart: "192.168.50.100", DHCPEnd: "192.168.50.199", LeaseMinutes: 720},
|
||||
Forwarding: Forwarding{Mode: "internet"},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,12 +52,12 @@ type Status struct {
|
||||
}
|
||||
|
||||
type Station struct {
|
||||
MAC string `json:"mac"`
|
||||
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"`
|
||||
MAC string `json:"mac"`
|
||||
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"`
|
||||
}
|
||||
|
||||
type Lease struct {
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
// Package sysinfo discovers interfaces and reads host health without privileges.
|
||||
package sysinfo
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Interface struct {
|
||||
Name string `json:"name"`
|
||||
MAC string `json:"mac"`
|
||||
Addrs []string `json:"addrs"`
|
||||
Up bool `json:"up"`
|
||||
Carrier bool `json:"carrier"`
|
||||
Wireless bool `json:"wireless"`
|
||||
Driver string `json:"driver"`
|
||||
DefaultGW string `json:"default_gateway,omitempty"`
|
||||
SpeedMbps int `json:"speed_mbps,omitempty"`
|
||||
Virtual bool `json:"virtual"`
|
||||
RxDropped uint64 `json:"rx_dropped"`
|
||||
}
|
||||
|
||||
func readTrim(p string) string {
|
||||
b, _ := os.ReadFile(p)
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
|
||||
// DefaultRoutes maps interface -> 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<<bit) != 0 {
|
||||
h.ThrottleBits = append(h.ThrottleBits, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
h.Load = readTrim("/proc/loadavg")
|
||||
up := strings.Fields(readTrim("/proc/uptime"))
|
||||
if len(up) > 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
|
||||
}
|
||||
Vendored
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user