Core packages: config validation, network renderers, root helper, auth, SQLite store, capture engine
Capture engine streams dumpcap pcapng into verbatim, hashed segments with per-segment tshark summaries; display filters, PDML dissection with verified byte ranges, export and import. Tests run in a Debian trixie container.
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
/dist/
|
||||||
|
/web/node_modules/
|
||||||
|
/web/dist/
|
||||||
|
*.test
|
||||||
@@ -0,0 +1,484 @@
|
|||||||
|
// pilens-helper is the only root component of PiLens. It accepts a fixed set of
|
||||||
|
// typed requests from the pilens service user over a unix socket and applies
|
||||||
|
// network configuration transactionally. It never executes caller-supplied commands.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"os/signal"
|
||||||
|
"os/user"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"pilens.dev/pilens/internal/config"
|
||||||
|
"pilens.dev/pilens/internal/helper"
|
||||||
|
"pilens.dev/pilens/internal/netgen"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
stateDir = "/var/lib/pilens-helper"
|
||||||
|
keyfilePath = "/etc/NetworkManager/system-connections/" + netgen.ConnName + ".nmconnection"
|
||||||
|
)
|
||||||
|
|
||||||
|
func statePath(n string) string { return filepath.Join(stateDir, n) }
|
||||||
|
|
||||||
|
type server struct {
|
||||||
|
mu sync.Mutex // one network transaction at a time
|
||||||
|
allowUID uint32
|
||||||
|
lastError string
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
socket := flag.String("socket", helper.DefaultSocket, "unix socket path")
|
||||||
|
group := flag.String("user", "pilens", "service user allowed to connect")
|
||||||
|
boot := flag.Bool("boot", false, "load the last good firewall ruleset and exit (ExecStartPre)")
|
||||||
|
render := flag.String("render", "", "render config JSON file to stdout sections and exit (diagnostics)")
|
||||||
|
flag.Parse()
|
||||||
|
log := slog.New(slog.NewJSONHandler(os.Stderr, nil))
|
||||||
|
slog.SetDefault(log)
|
||||||
|
|
||||||
|
if *render != "" {
|
||||||
|
os.Exit(renderCmd(*render))
|
||||||
|
}
|
||||||
|
if *boot {
|
||||||
|
if err := bootLoad(); err != nil {
|
||||||
|
log.Error("boot load failed", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := user.Lookup(*group)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("service user missing", "user", *group, "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
uid, _ := strconv.Atoi(u.Uid)
|
||||||
|
gid, _ := strconv.Atoi(u.Gid)
|
||||||
|
s := &server{allowUID: uint32(uid)}
|
||||||
|
|
||||||
|
os.MkdirAll(filepath.Dir(*socket), 0o750)
|
||||||
|
os.Chown(filepath.Dir(*socket), 0, gid)
|
||||||
|
os.Remove(*socket)
|
||||||
|
ln, err := net.Listen("unix", *socket)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("listen", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
os.Chown(*socket, 0, gid)
|
||||||
|
os.Chmod(*socket, 0o660)
|
||||||
|
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
|
||||||
|
defer stop()
|
||||||
|
go func() { <-ctx.Done(); ln.Close() }()
|
||||||
|
log.Info("pilens-helper ready", "socket", *socket)
|
||||||
|
for {
|
||||||
|
c, err := ln.Accept()
|
||||||
|
if err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
go s.handle(ctx, c.(*net.UnixConn))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func peerUID(c *net.UnixConn) (uint32, error) {
|
||||||
|
raw, err := c.SyscallConn()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
var cred *syscall.Ucred
|
||||||
|
var cerr error
|
||||||
|
raw.Control(func(fd uintptr) { cred, cerr = syscall.GetsockoptUcred(int(fd), syscall.SOL_SOCKET, syscall.SO_PEERCRED) })
|
||||||
|
if cerr != nil {
|
||||||
|
return 0, cerr
|
||||||
|
}
|
||||||
|
return cred.Uid, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) handle(ctx context.Context, c *net.UnixConn) {
|
||||||
|
defer c.Close()
|
||||||
|
uid, err := peerUID(c)
|
||||||
|
if err != nil || (uid != s.allowUID && uid != 0) {
|
||||||
|
slog.Warn("rejected helper connection", "uid", uid, "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.SetDeadline(time.Now().Add(3 * time.Minute))
|
||||||
|
var req helper.Request
|
||||||
|
if err := json.NewDecoder(&limitedReader{c, 1 << 20}).Decode(&req); err != nil {
|
||||||
|
writeResp(c, nil, errors.New("bad request"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Info("helper request", "op", req.Op, "uid", uid)
|
||||||
|
var data any
|
||||||
|
switch req.Op {
|
||||||
|
case helper.OpApplyNetwork:
|
||||||
|
if req.Network == nil {
|
||||||
|
err = errors.New("network config required")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
r := s.apply(ctx, *req.Network)
|
||||||
|
data = r
|
||||||
|
if r.Error != "" {
|
||||||
|
err = errors.New(r.Error)
|
||||||
|
}
|
||||||
|
case helper.OpStatus:
|
||||||
|
data = s.status(ctx)
|
||||||
|
case helper.OpStations:
|
||||||
|
data, err = stations(ctx)
|
||||||
|
case helper.OpLeases:
|
||||||
|
b, rerr := os.ReadFile(netgen.LeaseFile)
|
||||||
|
if rerr != nil && !os.IsNotExist(rerr) {
|
||||||
|
err = rerr
|
||||||
|
}
|
||||||
|
data = helper.ParseLeases(string(b))
|
||||||
|
default:
|
||||||
|
err = fmt.Errorf("unknown op %q", req.Op)
|
||||||
|
}
|
||||||
|
writeResp(c, data, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
type limitedReader struct {
|
||||||
|
c *net.UnixConn
|
||||||
|
n int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *limitedReader) Read(p []byte) (int, error) {
|
||||||
|
if l.n <= 0 {
|
||||||
|
return 0, errors.New("request too large")
|
||||||
|
}
|
||||||
|
if len(p) > l.n {
|
||||||
|
p = p[:l.n]
|
||||||
|
}
|
||||||
|
n, err := l.c.Read(p)
|
||||||
|
l.n -= n
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeResp(c net.Conn, data any, err error) {
|
||||||
|
r := helper.Response{OK: err == nil}
|
||||||
|
if err != nil {
|
||||||
|
r.Error = err.Error()
|
||||||
|
}
|
||||||
|
if data != nil {
|
||||||
|
r.Data, _ = json.Marshal(data)
|
||||||
|
}
|
||||||
|
json.NewEncoder(c).Encode(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// run executes a fixed program with an argv list (never a shell).
|
||||||
|
func run(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||||
|
defer cancel()
|
||||||
|
out, err := exec.CommandContext(ctx, name, args...).CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return string(out), fmt.Errorf("%s %s: %v: %s", name, strings.Join(args, " "), err, strings.TrimSpace(string(out)))
|
||||||
|
}
|
||||||
|
return string(out), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeAtomic(path string, data []byte, mode os.FileMode) error {
|
||||||
|
os.MkdirAll(filepath.Dir(path), 0o755)
|
||||||
|
tmp := path + ".tmp"
|
||||||
|
if err := os.WriteFile(tmp, data, mode); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.Chmod(tmp, mode); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Rename(tmp, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// snapshot of the files a transaction touches; nil content = file absent.
|
||||||
|
type snapshot map[string][]byte
|
||||||
|
|
||||||
|
func takeSnapshot(paths ...string) snapshot {
|
||||||
|
s := snapshot{}
|
||||||
|
for _, p := range paths {
|
||||||
|
b, err := os.ReadFile(p)
|
||||||
|
if err == nil {
|
||||||
|
s[p] = b
|
||||||
|
} else {
|
||||||
|
s[p] = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) apply(ctx context.Context, n config.Network) helper.ApplyResult {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
var r helper.ApplyResult
|
||||||
|
step := func(f string, a ...any) { r.Steps = append(r.Steps, fmt.Sprintf(f, a...)) }
|
||||||
|
|
||||||
|
if err := n.Validate(true); err != nil {
|
||||||
|
r.Error = "validation: " + err.Error()
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
step("validated configuration")
|
||||||
|
ruleset := netgen.Nftables(n)
|
||||||
|
tmp := statePath("ruleset.check.nft")
|
||||||
|
if err := writeAtomic(tmp, []byte(ruleset), 0o600); err != nil {
|
||||||
|
r.Error = err.Error()
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
if _, err := run(ctx, 20*time.Second, "nft", "-c", "-f", tmp); err != nil {
|
||||||
|
r.Error = "firewall ruleset rejected by nft -c: " + err.Error()
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
step("nft dry-run passed")
|
||||||
|
|
||||||
|
files := map[string][]byte{
|
||||||
|
statePath("ruleset.nft"): []byte(ruleset),
|
||||||
|
statePath("dnsmasq.conf"): []byte(netgen.DnsmasqConf(n)),
|
||||||
|
keyfilePath: []byte(netgen.NMKeyfile(n)),
|
||||||
|
}
|
||||||
|
var paths []string
|
||||||
|
for p := range files {
|
||||||
|
paths = append(paths, p)
|
||||||
|
}
|
||||||
|
snap := takeSnapshot(paths...)
|
||||||
|
prevCfg, _ := os.ReadFile(statePath("last-good.json"))
|
||||||
|
step("saved previous configuration")
|
||||||
|
|
||||||
|
err := activate(ctx, n, files, step)
|
||||||
|
if err == nil {
|
||||||
|
err = verify(ctx, n, step)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
r.Error = err.Error()
|
||||||
|
step("FAILED: %v", err)
|
||||||
|
step("rolling back")
|
||||||
|
if rbErr := rollback(ctx, snap, prevCfg); rbErr != nil {
|
||||||
|
r.Error += "; ROLLBACK ALSO FAILED: " + rbErr.Error()
|
||||||
|
step("rollback failed: %v", rbErr)
|
||||||
|
} else {
|
||||||
|
step("previous configuration restored")
|
||||||
|
}
|
||||||
|
r.RolledBack = true
|
||||||
|
s.lastError = r.Error
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(n)
|
||||||
|
writeAtomic(statePath("last-good.json"), b, 0o600)
|
||||||
|
s.lastError = ""
|
||||||
|
r.Applied = true
|
||||||
|
step("configuration committed")
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func activate(ctx context.Context, n config.Network, files map[string][]byte, step func(string, ...any)) error {
|
||||||
|
for p, b := range files {
|
||||||
|
if err := writeAtomic(p, b, 0o600); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := run(ctx, 20*time.Second, "nft", "-f", statePath("ruleset.nft")); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
step("firewall table inet pilens loaded")
|
||||||
|
if n.Forwarding.Mode == "internet" {
|
||||||
|
// Only ever enabled here, after the ruleset is in place. We never disable it:
|
||||||
|
// other host services may rely on it and the pilens table drops AP traffic in deny mode.
|
||||||
|
if err := os.WriteFile("/proc/sys/net/ipv4/ip_forward", []byte("1"), 0o644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
step("IPv4 forwarding enabled")
|
||||||
|
}
|
||||||
|
if _, err := run(ctx, 10*time.Second, "iw", "reg", "set", n.AP.Country); err != nil {
|
||||||
|
step("warning: could not set regulatory domain: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := run(ctx, 20*time.Second, "nmcli", "connection", "load", keyfilePath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n.AP.Enabled {
|
||||||
|
if _, err := run(ctx, 60*time.Second, "nmcli", "--wait", "45", "connection", "up", netgen.ConnName); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
step("access point %q activated", n.AP.SSID)
|
||||||
|
if _, err := run(ctx, 30*time.Second, "systemctl", "restart", "pilens-dnsmasq.service"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
step("DHCP/DNS restarted")
|
||||||
|
} else {
|
||||||
|
run(ctx, 30*time.Second, "nmcli", "connection", "down", netgen.ConnName)
|
||||||
|
run(ctx, 30*time.Second, "systemctl", "stop", "pilens-dnsmasq.service")
|
||||||
|
step("access point disabled")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func verify(ctx context.Context, n config.Network, step func(string, ...any)) error {
|
||||||
|
if _, err := run(ctx, 10*time.Second, "nft", "list", "table", "inet", netgen.TableName); err != nil {
|
||||||
|
return errors.New("firewall table missing after apply")
|
||||||
|
}
|
||||||
|
if !n.AP.Enabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
deadline := time.Now().Add(15 * time.Second)
|
||||||
|
for {
|
||||||
|
st := apState(ctx)
|
||||||
|
dn := unitActive(ctx, "pilens-dnsmasq.service")
|
||||||
|
if st == "activated" && dn {
|
||||||
|
step("verified: AP activated, DHCP/DNS active")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
return fmt.Errorf("health check failed: AP state %q, dnsmasq active=%v", st, dn)
|
||||||
|
}
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func rollback(ctx context.Context, snap snapshot, prevCfg []byte) error {
|
||||||
|
var errs []error
|
||||||
|
for p, b := range snap {
|
||||||
|
if b == nil {
|
||||||
|
os.Remove(p)
|
||||||
|
} else if err := writeAtomic(p, b, 0o600); err != nil {
|
||||||
|
errs = append(errs, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rs := statePath("ruleset.nft")
|
||||||
|
if snap[rs] != nil {
|
||||||
|
if _, err := run(ctx, 20*time.Second, "nft", "-f", rs); err != nil {
|
||||||
|
errs = append(errs, err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No previous policy: fail closed on the AP side.
|
||||||
|
var n config.Network
|
||||||
|
ap := "wlan0"
|
||||||
|
if json.Unmarshal(prevCfg, &n) == nil && n.AP.Interface != "" {
|
||||||
|
ap = n.AP.Interface
|
||||||
|
}
|
||||||
|
writeAtomic(rs, []byte(netgen.DenyAll(ap)), 0o600)
|
||||||
|
if _, err := run(ctx, 20*time.Second, "nft", "-f", rs); err != nil {
|
||||||
|
errs = append(errs, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if snap[keyfilePath] != nil {
|
||||||
|
run(ctx, 20*time.Second, "nmcli", "connection", "load", keyfilePath)
|
||||||
|
var n config.Network
|
||||||
|
if json.Unmarshal(prevCfg, &n) == nil && n.AP.Enabled {
|
||||||
|
if _, err := run(ctx, 60*time.Second, "nmcli", "--wait", "45", "connection", "up", netgen.ConnName); err != nil {
|
||||||
|
errs = append(errs, err)
|
||||||
|
}
|
||||||
|
run(ctx, 30*time.Second, "systemctl", "restart", "pilens-dnsmasq.service")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
run(ctx, 20*time.Second, "nmcli", "connection", "delete", netgen.ConnName)
|
||||||
|
run(ctx, 30*time.Second, "systemctl", "stop", "pilens-dnsmasq.service")
|
||||||
|
}
|
||||||
|
return errors.Join(errs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func apState(ctx context.Context) string {
|
||||||
|
out, err := run(ctx, 10*time.Second, "nmcli", "-g", "GENERAL.STATE", "connection", "show", netgen.ConnName)
|
||||||
|
if err != nil {
|
||||||
|
return "absent"
|
||||||
|
}
|
||||||
|
if s := strings.TrimSpace(out); s != "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return "inactive"
|
||||||
|
}
|
||||||
|
|
||||||
|
func unitActive(ctx context.Context, unit string) bool {
|
||||||
|
_, err := run(ctx, 10*time.Second, "systemctl", "is-active", "--quiet", unit)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) status(ctx context.Context) helper.Status {
|
||||||
|
st := helper.Status{LastError: s.lastError}
|
||||||
|
_, err := run(ctx, 10*time.Second, "nft", "list", "table", "inet", netgen.TableName)
|
||||||
|
st.TableLoaded = err == nil
|
||||||
|
st.APState = apState(ctx)
|
||||||
|
st.DnsmasqActive = unitActive(ctx, "pilens-dnsmasq.service")
|
||||||
|
b, _ := os.ReadFile("/proc/sys/net/ipv4/ip_forward")
|
||||||
|
st.IPForward = strings.TrimSpace(string(b)) == "1"
|
||||||
|
if out, err := run(ctx, 5*time.Second, "iw", "reg", "get"); err == nil {
|
||||||
|
for _, l := range strings.Split(out, "\n") {
|
||||||
|
if strings.HasPrefix(l, "country ") {
|
||||||
|
st.Regdomain = strings.TrimSuffix(strings.Fields(l)[1], ":")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, err = os.Stat(statePath("last-good.json"))
|
||||||
|
st.Configured = err == nil
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
|
||||||
|
func stations(ctx context.Context) ([]helper.Station, error) {
|
||||||
|
b, err := os.ReadFile(statePath("last-good.json"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var n config.Network
|
||||||
|
if err := json.Unmarshal(b, &n); err != nil || !config.ValidInterface(n.AP.Interface) {
|
||||||
|
return nil, errors.New("stored configuration unreadable")
|
||||||
|
}
|
||||||
|
out, err := run(ctx, 10*time.Second, "iw", "dev", n.AP.Interface, "station", "dump")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return helper.ParseStationDump(out), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// bootLoad restores the last good ruleset (or a fail-closed one) before networking starts.
|
||||||
|
func bootLoad() error {
|
||||||
|
ctx := context.Background()
|
||||||
|
rs := statePath("ruleset.nft")
|
||||||
|
if _, err := os.Stat(rs); err != nil {
|
||||||
|
slog.Info("no ruleset yet; nothing to load")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if _, err := run(ctx, 20*time.Second, "nft", "-f", rs); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b, err := os.ReadFile(statePath("last-good.json"))
|
||||||
|
var n config.Network
|
||||||
|
if err == nil && json.Unmarshal(b, &n) == nil {
|
||||||
|
if n.Forwarding.Mode == "internet" {
|
||||||
|
os.WriteFile("/proc/sys/net/ipv4/ip_forward", []byte("1"), 0o644)
|
||||||
|
}
|
||||||
|
if n.AP.Country != "" {
|
||||||
|
run(ctx, 10*time.Second, "iw", "reg", "set", n.AP.Country)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slog.Info("firewall restored at boot")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderCmd(path string) int {
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
var n config.Network
|
||||||
|
if err := json.Unmarshal(b, &n); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if err := n.Validate(true); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
fmt.Printf("### nftables\n%s\n### dnsmasq.conf\n%s\n### %s (psk redacted)\n%s", netgen.Nftables(n), netgen.DnsmasqConf(n), keyfilePath, netgen.NMKeyfile(n.Redacted()))
|
||||||
|
return 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
# PiLens architecture
|
||||||
|
|
||||||
|
PiLens turns a Raspberry Pi into a routed Wi-Fi test network with packet capture
|
||||||
|
and analysis. This document records the decisions and the reasons for them.
|
||||||
|
Anything here that has not been checked on real hardware says so.
|
||||||
|
|
||||||
|
## Processes and privilege
|
||||||
|
|
||||||
|
```
|
||||||
|
management LAN (TLS :8443, allow-listed subnets only)
|
||||||
|
│
|
||||||
|
┌───────────────────────────────▼────────────────────────────────┐
|
||||||
|
│ pilens (user "pilens", no root) │
|
||||||
|
│ HTTP API + embedded React UI, auth, SQLite, SSE │
|
||||||
|
│ capture engine: dumpcap ─stdout─▶ pcapng splitter ─▶ segments │
|
||||||
|
│ └─▶ tshark (per segment) │
|
||||||
|
│ dissection on demand: tshark -T pdml (rlimited child) │
|
||||||
|
└───────────────┬─────────────────────────────────────────────────┘
|
||||||
|
│ unix socket /run/pilens/helper.sock (SO_PEERCRED)
|
||||||
|
┌───────────────▼─────────────────────────────────────────────────┐
|
||||||
|
│ pilens-helper (root, small, fixed operations only) │
|
||||||
|
│ apply_network: NM keyfile + dnsmasq conf + nft table, verify, │
|
||||||
|
│ roll back on failure │
|
||||||
|
│ ap_stations, leases, status │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
pilens-dnsmasq (DHCP + DNS for the AP interface only)
|
||||||
|
NetworkManager (owns the AP connection "pilens-ap")
|
||||||
|
```
|
||||||
|
|
||||||
|
* **The core never runs as root.** It captures by running `dumpcap`, which
|
||||||
|
Debian ships with file capabilities (`cap_net_raw,cap_net_admin`) for members
|
||||||
|
of the `wireshark` group. Because file capabilities are lost under
|
||||||
|
`NoNewPrivileges`, the core unit does not set it. Instead it restricts its
|
||||||
|
`CapabilityBoundingSet` to those two capabilities, so no child can gain more.
|
||||||
|
* **tshark parses hostile input** (live traffic, uploaded files). Every tshark
|
||||||
|
child goes through `pilens rlimit-exec`, which sets `RLIMIT_AS`, `RLIMIT_CPU`
|
||||||
|
and `PR_SET_NO_NEW_PRIVS` and then execs tshark. Every call also has a context
|
||||||
|
timeout.
|
||||||
|
* **The helper is the only root component.** It takes typed JSON requests. It
|
||||||
|
checks the peer uid, validates every request again (it does not trust the
|
||||||
|
core), renders the configuration itself, and runs fixed argv lists. It has no
|
||||||
|
generic command API.
|
||||||
|
|
||||||
|
## Networking
|
||||||
|
|
||||||
|
The Pi is a **router**, not a bridge. The AP interface has a static gateway
|
||||||
|
address; clients get DHCP and DNS from a dedicated dnsmasq instance bound only
|
||||||
|
to that interface.
|
||||||
|
|
||||||
|
| Concern | Owner | Why |
|
||||||
|
|---|---|---|
|
||||||
|
| AP radio | NetworkManager keyfile `pilens-ap` (`mode=ap`, `ipv4.method=manual`, `ipv6.method=disabled`) | NM is the supported network manager on Raspberry Pi OS Bookworm and later. We write a keyfile (0600) and `nmcli connection load` it. The Wi-Fi passphrase never appears in a process argv. |
|
||||||
|
| DHCP + DNS | `pilens-dnsmasq.service` | NM "shared" mode gives no reservations, query logging or overrides, and it adds its own NAT rules. We use `ipv4.method=manual` so NM runs no DHCP server, which means there are never two competing DHCP servers. Only `dnsmasq-base` is installed, so the distro's `dnsmasq.service` does not exist. |
|
||||||
|
| Firewall / NAT | nft table `inet pilens` | This is the only table we create or delete. Other tables are never flushed. |
|
||||||
|
| Forwarding sysctl | helper | It is enabled only after the PiLens table has loaded. It is not persisted in sysctl.d, so after a reboot nothing forwards until the rules are back. |
|
||||||
|
|
||||||
|
### Firewall semantics
|
||||||
|
|
||||||
|
In nftables a packet must survive every base chain on a hook: `accept` only
|
||||||
|
ends the current chain, but `drop` is final. Our chains use `policy accept` for
|
||||||
|
traffic that is not ours, so SSH and other host services are untouched. For
|
||||||
|
anything that enters through the AP interface we decide explicitly and end with
|
||||||
|
a `drop`. A permissive table added by someone else (Docker, NM) therefore cannot
|
||||||
|
open a path that we drop.
|
||||||
|
|
||||||
|
* AP → Pi: DHCP, DNS and ICMP echo only. Everything else is dropped, including
|
||||||
|
the management port and SSH.
|
||||||
|
* AP → forward: only out of the uplink interface. Never to RFC 1918, link-local,
|
||||||
|
CGNAT, multicast or the management subnets, unless the administrator adds an
|
||||||
|
explicit allow CIDR. New connections are permitted only when forwarding mode is
|
||||||
|
`internet`.
|
||||||
|
* IPv6 from AP clients is dropped in `forward`, and IPv6 is disabled on the AP
|
||||||
|
interface. That closes the uninspected IPv6 escape path. IPv6 forwarding is
|
||||||
|
not implemented.
|
||||||
|
* Management port: accepted only on the configured management interfaces from
|
||||||
|
the configured source subnets, and dropped everywhere else. The core also
|
||||||
|
checks the source address in middleware, as defence in depth.
|
||||||
|
* `pilens-helper.service` loads the last good ruleset in `ExecStartPre`, ordered
|
||||||
|
`Before=network-pre.target`, so the rules are in place before NM brings up the
|
||||||
|
AP.
|
||||||
|
|
||||||
|
### Transactional apply
|
||||||
|
|
||||||
|
`apply_network` is one helper operation:
|
||||||
|
|
||||||
|
1. Validate.
|
||||||
|
2. Render the NM keyfile, dnsmasq.conf and nft ruleset.
|
||||||
|
3. `nft -c` dry run.
|
||||||
|
4. Snapshot the current files.
|
||||||
|
5. Write the files atomically, load the ruleset, `nmcli connection load` + `up`,
|
||||||
|
and restart dnsmasq.
|
||||||
|
6. Verify: the connection is `activated`, dnsmasq is active, the table is
|
||||||
|
present, and forwarding matches the configured policy.
|
||||||
|
7. On any failure, restore the snapshot and re-apply it, then report both errors.
|
||||||
|
|
||||||
|
Management and uplink NM profiles are never modified.
|
||||||
|
|
||||||
|
## Capture engine
|
||||||
|
|
||||||
|
```
|
||||||
|
dumpcap -i IF [-f BPF] -w - ──pcapng──▶ splitter ──▶ seg-000001.pcapng (sha256 while writing)
|
||||||
|
│ seg-000002.pcapng …
|
||||||
|
├──▶ tshark -r - -T fields (summary, one process per segment)
|
||||||
|
│ └─▶ SQLite packets(session,num,seg,off,…) ─▶ SSE batches
|
||||||
|
└──▶ ISB drop counters
|
||||||
|
```
|
||||||
|
|
||||||
|
* **Original bytes are never modified.** Every packet block is written verbatim.
|
||||||
|
On rotation, the SHB and IDBs are copied verbatim into the new segment, so
|
||||||
|
each segment is a self-contained, valid pcapng file.
|
||||||
|
* **One tshark per segment** keeps tshark's memory bounded. tshark's
|
||||||
|
conversation state otherwise grows without limit on long captures. The cost:
|
||||||
|
TCP stream numbers and reassembly restart at segment boundaries. Summary rows,
|
||||||
|
filters and the detail view all use the same per-segment dissection, so they
|
||||||
|
agree with each other. Segments default to 64 MiB.
|
||||||
|
* **SQLite holds one summary row per packet** (number, segment, file offset,
|
||||||
|
time, addresses, protocol, length, info). It does not hold dissection trees.
|
||||||
|
The protocol tree comes from `tshark -T pdml` on demand.
|
||||||
|
* **Display filters are evaluated by tshark** (`-Y`), segment by segment. The
|
||||||
|
results are cached per session and filter. PiLens does not implement its own
|
||||||
|
filter language.
|
||||||
|
* **Hex ↔ field mapping.** The core reads frame bytes from the segment file at
|
||||||
|
the indexed offset. A PDML field is highlighted only when the frame bytes at
|
||||||
|
`pos..pos+size` equal its `value`. Fields from reassembled or decompressed
|
||||||
|
data sources fail that check and are not highlighted.
|
||||||
|
* **Backpressure.** Each SSE subscriber has a bounded queue. When it is full,
|
||||||
|
batches for that subscriber are dropped, counted as *UI drops*, and the client
|
||||||
|
is told to resync from SQLite. Capture never waits for a browser. Capture
|
||||||
|
drops (from dumpcap/ISB) and ingestion lag (packets written but not yet
|
||||||
|
summarised) are reported separately.
|
||||||
|
* **Storage.** There is a global byte quota and a free-space reserve. When the
|
||||||
|
quota is reached, the oldest *unpreserved* sessions are deleted. If nothing
|
||||||
|
can be deleted, the capture stops with a visible error. Preserved sessions are
|
||||||
|
never deleted automatically. Per-session ring mode (`ring_segments`) deletes
|
||||||
|
that session's oldest segments and records which ones were rotated out.
|
||||||
|
* **Imports** keep the uploaded file unchanged as the session's only segment
|
||||||
|
(pcap or pcapng), with its SHA-256. Magic number and size are checked before
|
||||||
|
any parsing.
|
||||||
|
|
||||||
|
## Web
|
||||||
|
|
||||||
|
* TLS only, on `:8443`, with a self-signed management certificate generated on
|
||||||
|
first start (ECDSA P-256). It is separate from any interception CA. The
|
||||||
|
installer prints its fingerprint.
|
||||||
|
* Auth: Argon2id (64 MiB, t=2, p=2). Sessions are 256-bit random tokens; only
|
||||||
|
their SHA-256 is stored. Cookies are `Secure; HttpOnly; SameSite=Strict`.
|
||||||
|
CSRF uses a per-session token in the `X-CSRF-Token` header on every
|
||||||
|
state-changing request. Optional TOTP (RFC 6238). Logins are rate-limited per
|
||||||
|
IP and per user.
|
||||||
|
* First run needs a **setup token** that the installer generates and prints.
|
||||||
|
Without it, anyone on the management LAN could race to create the admin.
|
||||||
|
* Live updates use SSE: stdlib only, automatic reconnect, and `Last-Event-ID`
|
||||||
|
resync.
|
||||||
|
* React + TypeScript + Vite. The build is embedded in the Go binary with
|
||||||
|
`go:embed`. There are no CDNs or external fonts.
|
||||||
|
|
||||||
|
## Decisions taken without a spec answer
|
||||||
|
|
||||||
|
| Decision | Default | Reason |
|
||||||
|
|---|---|---|
|
||||||
|
| Live transport | SSE | One-way server push is all that is needed. It's stdlib, and reconnect is built in. |
|
||||||
|
| Config storage | JSON document in SQLite `settings`, with a `last_good` copy | One validated struct, simple rollback. |
|
||||||
|
| WPA2+WPA3 transition mode | Not offered | NM AP-mode support depends on the driver. We offer WPA2 or WPA3, and WPA3 failure triggers rollback. |
|
||||||
|
| SSID characters | Printable ASCII without `\` or `;` | Avoids keyfile escaping ambiguity. |
|
||||||
|
| AP client SSH to Pi | Blocked | The AP network is the untrusted side. |
|
||||||
|
| Max clients | Not offered | NM has no setting for it. The UI says so. |
|
||||||
|
|
||||||
|
## Not yet implemented (phase 3+)
|
||||||
|
|
||||||
|
These pages are not in the navigation until they work: conversations / follow
|
||||||
|
TCP stream, DNS/TLS/HTTP explorers, statistics, findings, TLS interception
|
||||||
|
(modes B and C), and scheduling.
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
module pilens.dev/pilens
|
||||||
|
|
||||||
|
go 1.27.1
|
||||||
|
|
||||||
|
require (
|
||||||
|
golang.org/x/crypto v0.57.0
|
||||||
|
modernc.org/sqlite v1.60.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
golang.org/x/sys v0.48.0 // indirect
|
||||||
|
modernc.org/libc v1.77.1 // indirect
|
||||||
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
modernc.org/memory v1.12.1 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
|
||||||
|
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
|
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||||
|
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||||
|
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
||||||
|
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
|
||||||
|
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
|
||||||
|
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
||||||
|
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||||
|
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=
|
||||||
|
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=
|
||||||
|
modernc.org/ccgo/v4 v4.36.1/go.mod h1:rrtGc2QkS239nYb/mQNuBMyjq3/y3ZXWbBjPoV3wqzA=
|
||||||
|
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||||
|
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||||
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
|
modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc=
|
||||||
|
modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||||
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
|
modernc.org/libc v1.77.1 h1:Ct8j47QtiZ1Enj2DtFXQtUqrPCAjdCmPjtCuvrYQ0Hs=
|
||||||
|
modernc.org/libc v1.77.1/go.mod h1:87/pZ4L6nD1zqW4nItuS12YO7hN1igAah34xjnQo/W0=
|
||||||
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
|
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
|
||||||
|
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
|
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||||
|
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
|
modernc.org/sqlite v1.60.1 h1:/blz53O951KWFOso4QQvEs/Fq6cDBKLtMVrYNSeJVKw=
|
||||||
|
modernc.org/sqlite v1.60.1/go.mod h1:1dIoEagfDE72QytD5scH1lxARtaUgKgHC/NuApA27r0=
|
||||||
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
// Package auth provides password hashing, TOTP, session tokens and login throttling.
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha1"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/base32"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/argon2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Argon2id parameters: ~64 MiB, sized for a Raspberry Pi 4/5.
|
||||||
|
const (
|
||||||
|
argonTime = 2
|
||||||
|
argonMemory = 64 * 1024
|
||||||
|
argonThreads = 2
|
||||||
|
argonKeyLen = 32
|
||||||
|
)
|
||||||
|
|
||||||
|
var b64 = base64.RawStdEncoding
|
||||||
|
|
||||||
|
func HashPassword(pw string) (string, error) {
|
||||||
|
salt := make([]byte, 16)
|
||||||
|
if _, err := rand.Read(salt); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
k := argon2.IDKey([]byte(pw), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
|
||||||
|
return fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s", argonMemory, argonTime, argonThreads, b64.EncodeToString(salt), b64.EncodeToString(k)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func CheckPassword(pw, encoded string) bool {
|
||||||
|
p := strings.Split(encoded, "$")
|
||||||
|
if len(p) != 6 || p[1] != "argon2id" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var m uint32
|
||||||
|
var t uint32
|
||||||
|
var th uint8
|
||||||
|
if _, err := fmt.Sscanf(p[3], "m=%d,t=%d,p=%d", &m, &t, &th); err != nil || m > 1<<20 || t > 10 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
salt, err1 := b64.DecodeString(p[4])
|
||||||
|
want, err2 := b64.DecodeString(p[5])
|
||||||
|
if err1 != nil || err2 != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
got := argon2.IDKey([]byte(pw), salt, t, m, th, uint32(len(want)))
|
||||||
|
return subtle.ConstantTimeCompare(got, want) == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// PasswordPolicy: length is what matters; no composition rules.
|
||||||
|
func PasswordPolicy(pw string) error {
|
||||||
|
if len(pw) < 12 {
|
||||||
|
return errors.New("password must be at least 12 characters")
|
||||||
|
}
|
||||||
|
if len(pw) > 256 {
|
||||||
|
return errors.New("password too long")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RandomToken returns n random bytes hex-encoded.
|
||||||
|
func RandomToken(n int) string {
|
||||||
|
b := make([]byte, n)
|
||||||
|
rand.Read(b)
|
||||||
|
return hex.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TokenHash is what we store for session tokens.
|
||||||
|
func TokenHash(tok string) string {
|
||||||
|
h := sha256.Sum256([]byte(tok))
|
||||||
|
return hex.EncodeToString(h[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- TOTP (RFC 6238, SHA-1, 30 s, 6 digits) ---
|
||||||
|
|
||||||
|
func NewTOTPSecret() string {
|
||||||
|
b := make([]byte, 20)
|
||||||
|
rand.Read(b)
|
||||||
|
return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func totpAt(secret string, counter uint64) (string, error) {
|
||||||
|
key, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(strings.ToUpper(secret))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
var msg [8]byte
|
||||||
|
binary.BigEndian.PutUint64(msg[:], counter)
|
||||||
|
m := hmac.New(sha1.New, key)
|
||||||
|
m.Write(msg[:])
|
||||||
|
sum := m.Sum(nil)
|
||||||
|
off := sum[len(sum)-1] & 0xf
|
||||||
|
v := binary.BigEndian.Uint32(sum[off:]) & 0x7fffffff
|
||||||
|
return fmt.Sprintf("%06d", v%1_000_000), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckTOTP accepts the current code and one step either side for clock skew.
|
||||||
|
func CheckTOTP(secret, code string, now time.Time) bool {
|
||||||
|
c := uint64(now.Unix() / 30)
|
||||||
|
for _, d := range []int64{0, -1, 1} {
|
||||||
|
want, err := totpAt(secret, uint64(int64(c)+d))
|
||||||
|
if err == nil && subtle.ConstantTimeCompare([]byte(want), []byte(code)) == 1 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func TOTPURI(issuer, user, secret string) string {
|
||||||
|
return fmt.Sprintf("otpauth://totp/%s:%s?secret=%s&issuer=%s&algorithm=SHA1&digits=6&period=30", issuer, user, secret, issuer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- login throttling ---
|
||||||
|
|
||||||
|
// Limiter applies exponential lockout per key (IP and username tracked separately).
|
||||||
|
type Limiter struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
m map[string]*entry
|
||||||
|
}
|
||||||
|
|
||||||
|
type entry struct {
|
||||||
|
fails int
|
||||||
|
until time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLimiter() *Limiter { return &Limiter{m: map[string]*entry{}} }
|
||||||
|
|
||||||
|
// Allowed reports whether key may attempt now, and the wait otherwise.
|
||||||
|
func (l *Limiter) Allowed(key string, now time.Time) (bool, time.Duration) {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
e := l.m[key]
|
||||||
|
if e == nil || now.After(e.until) {
|
||||||
|
return true, 0
|
||||||
|
}
|
||||||
|
return false, e.until.Sub(now)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Limiter) Fail(key string, now time.Time) {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
if len(l.m) > 10000 { // ponytail: crude bound on memory; LRU if this ever matters
|
||||||
|
l.m = map[string]*entry{}
|
||||||
|
}
|
||||||
|
e := l.m[key]
|
||||||
|
if e == nil {
|
||||||
|
e = &entry{}
|
||||||
|
l.m[key] = e
|
||||||
|
}
|
||||||
|
e.fails++
|
||||||
|
if e.fails >= 5 {
|
||||||
|
d := time.Duration(1<<min(e.fails-5, 10)) * time.Second
|
||||||
|
e.until = now.Add(d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Limiter) Success(key string) {
|
||||||
|
l.mu.Lock()
|
||||||
|
delete(l.m, key)
|
||||||
|
l.mu.Unlock()
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPassword(t *testing.T) {
|
||||||
|
h, err := HashPassword("correct horse battery")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !CheckPassword("correct horse battery", h) || CheckPassword("wrong", h) || CheckPassword("x", "garbage") {
|
||||||
|
t.Fatal("password check wrong")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTOTPRFC6238(t *testing.T) {
|
||||||
|
// RFC 6238 appendix B, SHA-1 secret "12345678901234567890" -> 8-digit 94287082 at T=59; 6-digit = 287082.
|
||||||
|
secret := "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"
|
||||||
|
if !CheckTOTP(secret, "287082", time.Unix(59, 0)) {
|
||||||
|
t.Fatal("RFC vector failed")
|
||||||
|
}
|
||||||
|
if CheckTOTP(secret, "287082", time.Unix(59+300, 0)) {
|
||||||
|
t.Fatal("stale code accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLimiter(t *testing.T) {
|
||||||
|
l := NewLimiter()
|
||||||
|
now := time.Unix(1000, 0)
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
l.Fail("ip", now)
|
||||||
|
}
|
||||||
|
if ok, _ := l.Allowed("ip", now); ok {
|
||||||
|
t.Fatal("should be locked")
|
||||||
|
}
|
||||||
|
if ok, _ := l.Allowed("ip", now.Add(2*time.Second)); !ok {
|
||||||
|
t.Fatal("lock should expire")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,824 @@
|
|||||||
|
// Package capture runs dumpcap, stores original packets as pcapng segments,
|
||||||
|
// indexes per-packet summaries in SQLite and serves on-demand dissection.
|
||||||
|
package capture
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"hash"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"pilens.dev/pilens/internal/auth"
|
||||||
|
"pilens.dev/pilens/internal/config"
|
||||||
|
"pilens.dev/pilens/internal/pcapfile"
|
||||||
|
"pilens.dev/pilens/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
func DefaultLimits() Limits {
|
||||||
|
return Limits{MaxBytes: 8 << 30, ReserveBytes: 1 << 30, SegmentBytes: 64 << 20, ImportBytes: 1 << 30}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Engine struct {
|
||||||
|
DB *store.Store
|
||||||
|
Dir string // root of capture storage
|
||||||
|
Tools *Tools
|
||||||
|
Hub *Hub
|
||||||
|
Version string
|
||||||
|
|
||||||
|
limMu sync.Mutex
|
||||||
|
limits Limits
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
live *live
|
||||||
|
|
||||||
|
fcMu sync.Mutex
|
||||||
|
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() }
|
||||||
|
|
||||||
|
// Session is the API view of a capture session.
|
||||||
|
type Session struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
State string `json:"state"`
|
||||||
|
Interfaces []string `json:"interfaces"`
|
||||||
|
CaptureFilter string `json:"capture_filter"`
|
||||||
|
StartedAt int64 `json:"started_at"`
|
||||||
|
EndedAt int64 `json:"ended_at"`
|
||||||
|
Packets int64 `json:"packets"`
|
||||||
|
Bytes int64 `json:"bytes"`
|
||||||
|
Drops string `json:"drops"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
Preserved bool `json:"preserved"`
|
||||||
|
Tags string `json:"tags"`
|
||||||
|
Notes string `json:"notes"`
|
||||||
|
RingSegments int `json:"ring_segments"`
|
||||||
|
Meta json.RawMessage `json:"meta"`
|
||||||
|
Paused bool `json:"paused"`
|
||||||
|
Lag int64 `json:"ingest_lag"` // packets written but not yet summarised
|
||||||
|
}
|
||||||
|
|
||||||
|
type Segment struct {
|
||||||
|
Seq int `json:"seq"`
|
||||||
|
File string `json:"file"`
|
||||||
|
Format string `json:"format"`
|
||||||
|
Base int64 `json:"base"`
|
||||||
|
Count int64 `json:"count"`
|
||||||
|
Bytes int64 `json:"bytes"`
|
||||||
|
SHA256 string `json:"sha256"`
|
||||||
|
Complete bool `json:"complete"`
|
||||||
|
Deleted bool `json:"deleted"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func newID() string {
|
||||||
|
return time.Now().UTC().Format("20060102T150405Z") + "-" + auth.RandomToken(3)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessCols = `id,name,description,kind,state,interfaces,capture_filter,started_at,ended_at,packets,bytes,drops,error,preserved,tags,notes,ring_segments,meta`
|
||||||
|
|
||||||
|
func scanSession(sc interface{ Scan(...any) error }) (Session, error) {
|
||||||
|
var s Session
|
||||||
|
var ifs, meta string
|
||||||
|
err := sc.Scan(&s.ID, &s.Name, &s.Description, &s.Kind, &s.State, &ifs, &s.CaptureFilter, &s.StartedAt, &s.EndedAt, &s.Packets, &s.Bytes, &s.Drops, &s.Error, &s.Preserved, &s.Tags, &s.Notes, &s.RingSegments, &meta)
|
||||||
|
json.Unmarshal([]byte(ifs), &s.Interfaces)
|
||||||
|
s.Meta = json.RawMessage(meta)
|
||||||
|
return s, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) withLive(s Session) Session {
|
||||||
|
e.mu.Lock()
|
||||||
|
l := e.live
|
||||||
|
e.mu.Unlock()
|
||||||
|
if l != nil && l.id == s.ID {
|
||||||
|
s.Packets = l.packets.Load()
|
||||||
|
s.Bytes = l.bytes.Load()
|
||||||
|
s.Paused = l.paused.Load()
|
||||||
|
s.Lag = s.Packets - l.summarised.Load()
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) Sessions() ([]Session, error) {
|
||||||
|
rows, err := e.DB.Query(`SELECT ` + sessCols + ` FROM capture_sessions ORDER BY started_at DESC`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := []Session{}
|
||||||
|
for rows.Next() {
|
||||||
|
s, err := scanSession(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, e.withLive(s))
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) Get(id string) (Session, error) {
|
||||||
|
s, err := scanSession(e.DB.QueryRow(`SELECT `+sessCols+` FROM capture_sessions WHERE id=?`, id))
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return s, ErrNotFound
|
||||||
|
}
|
||||||
|
return e.withLive(s), err
|
||||||
|
}
|
||||||
|
|
||||||
|
var ErrNotFound = errors.New("not found")
|
||||||
|
|
||||||
|
func (e *Engine) Segments(id string) ([]Segment, error) {
|
||||||
|
rows, err := e.DB.Query(`SELECT seq,file,format,base,count,bytes,sha256,complete,deleted FROM segments WHERE session_id=? ORDER BY seq`, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []Segment
|
||||||
|
for rows.Next() {
|
||||||
|
var s Segment
|
||||||
|
if err := rows.Scan(&s.Seq, &s.File, &s.Format, &s.Base, &s.Count, &s.Bytes, &s.SHA256, &s.Complete, &s.Deleted); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) sessionDir(id string) string { return filepath.Join(e.Dir, id) }
|
||||||
|
|
||||||
|
// Update edits analyst metadata.
|
||||||
|
func (e *Engine) Update(id, name, desc, tags, notes string, preserved bool) error {
|
||||||
|
r, err := e.DB.Exec(`UPDATE capture_sessions SET name=?,description=?,tags=?,notes=?,preserved=? WHERE id=?`, name, desc, tags, notes, preserved, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n, _ := r.RowsAffected(); n == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) Delete(id string) error {
|
||||||
|
e.mu.Lock()
|
||||||
|
running := e.live != nil && e.live.id == id
|
||||||
|
e.mu.Unlock()
|
||||||
|
if running {
|
||||||
|
return errors.New("stop the capture before deleting it")
|
||||||
|
}
|
||||||
|
if _, err := e.Get(id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.RemoveAll(e.sessionDir(id)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
e.DB.Exec(`DELETE FROM packets WHERE session_id=?`, id)
|
||||||
|
_, err := e.DB.Exec(`DELETE FROM capture_sessions WHERE id=?`, id)
|
||||||
|
e.dropFilterCache(id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- live capture
|
||||||
|
|
||||||
|
type StartOptions struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Interfaces []string `json:"interfaces"`
|
||||||
|
CaptureFilter string `json:"capture_filter"`
|
||||||
|
RingSegments int `json:"ring_segments"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type live struct {
|
||||||
|
id string
|
||||||
|
cmd *exec.Cmd
|
||||||
|
done chan struct{}
|
||||||
|
stopping atomic.Bool
|
||||||
|
paused atomic.Bool
|
||||||
|
discarded atomic.Int64 // packets discarded while paused
|
||||||
|
packets atomic.Int64
|
||||||
|
bytes atomic.Int64
|
||||||
|
summarised atomic.Int64
|
||||||
|
stderr limitedBuffer
|
||||||
|
|
||||||
|
mu sync.Mutex // guards seg
|
||||||
|
seg *segWriter
|
||||||
|
wg sync.WaitGroup // summarisers
|
||||||
|
}
|
||||||
|
|
||||||
|
type segWriter struct {
|
||||||
|
seq int
|
||||||
|
base int64
|
||||||
|
path string
|
||||||
|
f *os.File
|
||||||
|
bw *bufio.Writer
|
||||||
|
h hash.Hash
|
||||||
|
size int64
|
||||||
|
count int64
|
||||||
|
offs []int64
|
||||||
|
|
||||||
|
feed chan []byte
|
||||||
|
feedLost bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type limitedBuffer struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
b bytes.Buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *limitedBuffer) Write(p []byte) (int, error) {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
if l.b.Len() < 64<<10 {
|
||||||
|
l.b.Write(p)
|
||||||
|
}
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *limitedBuffer) String() string { l.mu.Lock(); defer l.mu.Unlock(); return l.b.String() }
|
||||||
|
|
||||||
|
func clockSynced() string {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
out, err := exec.CommandContext(ctx, "timedatectl", "show", "-p", "NTPSynchronized", "--value").Output()
|
||||||
|
if err != nil {
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
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(),
|
||||||
|
"clock_synchronised": clockSynced(),
|
||||||
|
"hostname": host,
|
||||||
|
"recorded_utc": time.Now().UTC().Format(time.RFC3339Nano),
|
||||||
|
}
|
||||||
|
for k, v := range extra {
|
||||||
|
m[k] = v
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(m)
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) Live() (Session, bool) {
|
||||||
|
e.mu.Lock()
|
||||||
|
l := e.live
|
||||||
|
e.mu.Unlock()
|
||||||
|
if l == nil {
|
||||||
|
return Session{}, false
|
||||||
|
}
|
||||||
|
s, err := e.Get(l.id)
|
||||||
|
return s, err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) Start(ctx context.Context, o StartOptions) (Session, error) {
|
||||||
|
if len(o.Interfaces) == 0 || len(o.Interfaces) > 8 {
|
||||||
|
return Session{}, errors.New("choose 1-8 interfaces")
|
||||||
|
}
|
||||||
|
for _, i := range o.Interfaces {
|
||||||
|
if i == "any" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !config.ValidInterface(i) {
|
||||||
|
return Session{}, fmt.Errorf("invalid interface %q", i)
|
||||||
|
}
|
||||||
|
if _, err := net.InterfaceByName(i); err != nil {
|
||||||
|
return Session{}, fmt.Errorf("interface %q not found", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if o.CaptureFilter != "" {
|
||||||
|
if err := e.Tools.ValidateCaptureFilter(ctx, o.Interfaces[0], o.CaptureFilter); err != nil {
|
||||||
|
return Session{}, fmt.Errorf("capture filter: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if o.RingSegments < 0 || o.RingSegments > 100000 {
|
||||||
|
return Session{}, errors.New("invalid ring size")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(o.Name) == "" {
|
||||||
|
o.Name = "Capture " + time.Now().Format("2006-01-02 15:04")
|
||||||
|
}
|
||||||
|
if len(o.Name) > 200 {
|
||||||
|
return Session{}, errors.New("name too long")
|
||||||
|
}
|
||||||
|
|
||||||
|
e.mu.Lock()
|
||||||
|
defer e.mu.Unlock()
|
||||||
|
if e.live != nil {
|
||||||
|
return Session{}, errors.New("a capture is already running")
|
||||||
|
}
|
||||||
|
if err := e.enforceStorage(""); err != nil {
|
||||||
|
return Session{}, err
|
||||||
|
}
|
||||||
|
id := newID()
|
||||||
|
dir := e.sessionDir(id)
|
||||||
|
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||||
|
return Session{}, err
|
||||||
|
}
|
||||||
|
ifs, _ := json.Marshal(o.Interfaces)
|
||||||
|
lim := e.Limits()
|
||||||
|
meta := e.meta(map[string]any{"segment_bytes": lim.SegmentBytes, "snaplen": "dumpcap default (full)"})
|
||||||
|
_, err := e.DB.Exec(`INSERT INTO capture_sessions(id,name,kind,state,interfaces,capture_filter,started_at,dir,meta,ring_segments) VALUES(?,?,?,?,?,?,?,?,?,?)`,
|
||||||
|
id, o.Name, "live", "running", string(ifs), o.CaptureFilter, time.Now().UnixMilli(), dir, meta, o.RingSegments)
|
||||||
|
if err != nil {
|
||||||
|
return Session{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
args := []string{"-q", "-n", "-w", "-"}
|
||||||
|
for _, i := range o.Interfaces {
|
||||||
|
args = append(args, "-i", i)
|
||||||
|
if o.CaptureFilter != "" {
|
||||||
|
args = append(args, "-f", o.CaptureFilter) // applies to the preceding -i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cmd := exec.Command(e.Tools.Dumpcap, args...)
|
||||||
|
l := &live{id: id, cmd: cmd, done: make(chan struct{})}
|
||||||
|
cmd.Stderr = &l.stderr
|
||||||
|
stdout, err := cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
return Session{}, err
|
||||||
|
}
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
e.finishSession(id, "failed", "dumpcap failed to start: "+err.Error(), "")
|
||||||
|
return Session{}, err
|
||||||
|
}
|
||||||
|
e.live = l
|
||||||
|
go e.runLive(l, stdout)
|
||||||
|
slog.Info("capture started", "session", id, "interfaces", o.Interfaces)
|
||||||
|
s, err := scanSession(e.DB.QueryRow(`SELECT `+sessCols+` FROM capture_sessions WHERE id=?`, id))
|
||||||
|
return s, err // e.mu is held: must not call e.Get (withLive locks it)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) SetPaused(p bool) error {
|
||||||
|
e.mu.Lock()
|
||||||
|
l := e.live
|
||||||
|
e.mu.Unlock()
|
||||||
|
if l == nil {
|
||||||
|
return errors.New("no capture running")
|
||||||
|
}
|
||||||
|
if l.paused.Swap(p) != p {
|
||||||
|
ev := "resumed"
|
||||||
|
if p {
|
||||||
|
ev = "paused"
|
||||||
|
}
|
||||||
|
e.DB.Exec(`UPDATE capture_sessions SET meta=json_insert(meta,'$.pause_log[#]',json_object('event',?,'utc',?)) WHERE id=?`, ev, time.Now().UTC().Format(time.RFC3339Nano), l.id)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) Stop() error {
|
||||||
|
e.mu.Lock()
|
||||||
|
l := e.live
|
||||||
|
e.mu.Unlock()
|
||||||
|
if l == nil {
|
||||||
|
return errors.New("no capture running")
|
||||||
|
}
|
||||||
|
l.stopping.Store(true)
|
||||||
|
l.cmd.Process.Signal(syscall.SIGINT) // dumpcap flushes and reports statistics
|
||||||
|
select {
|
||||||
|
case <-l.done:
|
||||||
|
case <-time.After(30 * time.Second):
|
||||||
|
l.cmd.Process.Kill()
|
||||||
|
<-l.done
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) runLive(l *live, stdout io.Reader) {
|
||||||
|
defer close(l.done)
|
||||||
|
rowsC := make(chan []Row, 64)
|
||||||
|
insDone := make(chan struct{})
|
||||||
|
go func() { e.inserter(l, rowsC); close(insDone) }()
|
||||||
|
|
||||||
|
// Periodic flush so on-demand reads of the active segment see recent packets,
|
||||||
|
// and status events for the UI.
|
||||||
|
stopTick := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
t := time.NewTicker(time.Second)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-stopTick:
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
l.mu.Lock()
|
||||||
|
if l.seg != nil {
|
||||||
|
l.seg.bw.Flush()
|
||||||
|
}
|
||||||
|
l.mu.Unlock()
|
||||||
|
e.publishStatus(l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
var failure string
|
||||||
|
br := pcapfile.NewBlockReader(bufio.NewReaderSize(stdout, 1<<20))
|
||||||
|
var hdrs [][]byte
|
||||||
|
for {
|
||||||
|
b, err := br.Next()
|
||||||
|
if err != nil {
|
||||||
|
if err != io.EOF && !l.stopping.Load() {
|
||||||
|
failure = "capture stream error: " + err.Error()
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
switch b.Type {
|
||||||
|
case pcapfile.BlockSHB:
|
||||||
|
hdrs = [][]byte{b.Raw}
|
||||||
|
case pcapfile.BlockIDB:
|
||||||
|
hdrs = append(hdrs, b.Raw)
|
||||||
|
}
|
||||||
|
if b.IsPacket() && l.paused.Load() {
|
||||||
|
l.discarded.Add(1)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := e.writeBlock(l, b, hdrs, rowsC); err != nil {
|
||||||
|
failure = err.Error()
|
||||||
|
l.stopping.Store(true)
|
||||||
|
l.cmd.Process.Signal(syscall.SIGINT)
|
||||||
|
io.Copy(io.Discard, stdout)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
waitErr := l.cmd.Wait()
|
||||||
|
l.mu.Lock()
|
||||||
|
e.closeSegment(l, rowsC)
|
||||||
|
l.mu.Unlock()
|
||||||
|
l.wg.Wait()
|
||||||
|
close(rowsC)
|
||||||
|
<-insDone
|
||||||
|
close(stopTick)
|
||||||
|
|
||||||
|
state := "stopped"
|
||||||
|
if failure == "" && !l.stopping.Load() {
|
||||||
|
failure = "dumpcap exited unexpectedly"
|
||||||
|
if waitErr != nil {
|
||||||
|
failure += ": " + waitErr.Error()
|
||||||
|
}
|
||||||
|
failure += ": " + strings.TrimSpace(l.stderr.String())
|
||||||
|
}
|
||||||
|
if failure != "" {
|
||||||
|
state = "failed"
|
||||||
|
}
|
||||||
|
e.DB.Exec(`UPDATE capture_sessions SET packets=?,bytes=?,meta=json_set(meta,'$.discarded_while_paused',?) WHERE id=?`, l.packets.Load(), l.bytes.Load(), l.discarded.Load(), l.id)
|
||||||
|
e.finishSession(l.id, state, failure, dropLines(l.stderr.String()))
|
||||||
|
e.mu.Lock()
|
||||||
|
e.live = nil
|
||||||
|
e.mu.Unlock()
|
||||||
|
e.publishStatus(l)
|
||||||
|
slog.Info("capture finished", "session", l.id, "state", state, "packets", l.packets.Load(), "error", failure)
|
||||||
|
}
|
||||||
|
|
||||||
|
// dropLines keeps dumpcap's per-interface "Packets received/dropped" report verbatim.
|
||||||
|
func dropLines(s string) string {
|
||||||
|
var out []string
|
||||||
|
for _, l := range strings.Split(s, "\n") {
|
||||||
|
if strings.Contains(l, "received/dropped") {
|
||||||
|
out = append(out, strings.TrimSpace(l))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(out, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) finishSession(id, state, errMsg, drops string) {
|
||||||
|
e.DB.Exec(`UPDATE capture_sessions SET state=?,error=?,drops=?,ended_at=? WHERE id=?`, state, errMsg, drops, time.Now().UnixMilli(), id)
|
||||||
|
e.dropFilterCache(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeBlock is called only from the splitter goroutine.
|
||||||
|
func (e *Engine) writeBlock(l *live, b pcapfile.Block, hdrs [][]byte, rowsC chan []Row) error {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
lim := e.Limits()
|
||||||
|
if b.IsPacket() && l.seg != nil && l.seg.size >= lim.SegmentBytes {
|
||||||
|
e.closeSegment(l, rowsC)
|
||||||
|
if err := e.enforceStorage(l.id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if l.seg == nil {
|
||||||
|
if !b.IsPacket() {
|
||||||
|
return nil // headers are written when the first packet opens a segment
|
||||||
|
}
|
||||||
|
if err := e.openSegment(l, hdrs, rowsC); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s := l.seg
|
||||||
|
if b.IsPacket() {
|
||||||
|
s.offs = append(s.offs, s.size)
|
||||||
|
s.count++
|
||||||
|
l.packets.Add(1)
|
||||||
|
if d, err := pcapfile.PacketData(b); err == nil {
|
||||||
|
l.bytes.Add(int64(len(d)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := s.bw.Write(b.Raw); err != nil {
|
||||||
|
return fmt.Errorf("writing capture file: %w", err)
|
||||||
|
}
|
||||||
|
s.size += int64(len(b.Raw))
|
||||||
|
s.sendFeed(b.Raw)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *segWriter) sendFeed(raw []byte) {
|
||||||
|
if s.feedLost {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case s.feed <- raw:
|
||||||
|
default:
|
||||||
|
// tshark cannot keep up. Never block capture: stop live summaries for this
|
||||||
|
// segment; they are regenerated from the file when the segment closes.
|
||||||
|
s.feedLost = true
|
||||||
|
close(s.feed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) openSegment(l *live, hdrs [][]byte, rowsC chan []Row) error {
|
||||||
|
var seq int
|
||||||
|
var base int64
|
||||||
|
e.DB.QueryRow(`SELECT COALESCE(MAX(seq),0)+1 FROM segments WHERE session_id=?`, l.id).Scan(&seq)
|
||||||
|
base = l.packets.Load() + 1
|
||||||
|
name := fmt.Sprintf("seg-%06d.pcapng", seq)
|
||||||
|
path := filepath.Join(e.sessionDir(l.id), name)
|
||||||
|
f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o640)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
h := sha256.New()
|
||||||
|
s := &segWriter{seq: seq, base: base, path: path, f: f, h: h, bw: bufio.NewWriterSize(io.MultiWriter(f, h), 1<<20), feed: make(chan []byte, 2048)}
|
||||||
|
if _, err := e.DB.Exec(`INSERT INTO segments(session_id,seq,file,format,base) VALUES(?,?,?,?,?)`, l.id, seq, name, "pcapng", base); err != nil {
|
||||||
|
f.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, hb := range hdrs {
|
||||||
|
s.bw.Write(hb)
|
||||||
|
s.size += int64(len(hb))
|
||||||
|
s.sendFeed(hb)
|
||||||
|
}
|
||||||
|
l.seg = s
|
||||||
|
l.wg.Add(1)
|
||||||
|
go e.summarise(l, s, rowsC)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// closeSegment finalises the current segment. Caller holds l.mu.
|
||||||
|
func (e *Engine) closeSegment(l *live, rowsC chan []Row) {
|
||||||
|
s := l.seg
|
||||||
|
if s == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
l.seg = nil
|
||||||
|
s.bw.Flush()
|
||||||
|
s.f.Sync()
|
||||||
|
s.f.Close()
|
||||||
|
if !s.feedLost {
|
||||||
|
close(s.feed)
|
||||||
|
}
|
||||||
|
sum := hex.EncodeToString(s.h.Sum(nil))
|
||||||
|
e.DB.Exec(`UPDATE segments SET count=?,bytes=?,sha256=?,complete=1 WHERE session_id=? AND seq=?`, s.count, s.size, sum, l.id, s.seq)
|
||||||
|
e.DB.Exec(`UPDATE capture_sessions SET packets=?,bytes=? WHERE id=?`, l.packets.Load(), l.bytes.Load(), l.id)
|
||||||
|
e.applyRing(l.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// summarise runs one tshark per segment fed from the splitter, then backfills
|
||||||
|
// from the completed file if the live feed was cut short.
|
||||||
|
func (e *Engine) summarise(l *live, s *segWriter, rowsC chan []Row) {
|
||||||
|
defer l.wg.Done()
|
||||||
|
offAt := func(local int64) int64 {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
if local < 1 || local > int64(len(s.offs)) {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return s.offs[local-1]
|
||||||
|
}
|
||||||
|
var got int64
|
||||||
|
emit := func(batch *[]Row, r Row) {
|
||||||
|
r.Off = offAt(r.Num)
|
||||||
|
if r.Off < 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.Num = s.base + r.Num - 1
|
||||||
|
r.Seg = s.seq
|
||||||
|
got++
|
||||||
|
*batch = append(*batch, r)
|
||||||
|
if len(*batch) >= 500 {
|
||||||
|
rowsC <- *batch
|
||||||
|
*batch = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
cmd := e.Tools.Command(ctx, 0, e.Tools.SummaryArgs("-")...)
|
||||||
|
stdin, _ := cmd.StdinPipe()
|
||||||
|
stdout, _ := cmd.StdoutPipe()
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
slog.Error("tshark start", "err", err)
|
||||||
|
for range s.feed {
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
go func() {
|
||||||
|
for b := range s.feed {
|
||||||
|
if _, err := stdin.Write(b); err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stdin.Close()
|
||||||
|
for range s.feed { // drain if tshark died
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
var batch []Row
|
||||||
|
lines := make(chan Row, 1024)
|
||||||
|
go func() { ReadSummaries(stdout, func(r Row) { lines <- r }); close(lines) }()
|
||||||
|
tick := time.NewTicker(300 * time.Millisecond)
|
||||||
|
loop:
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case r, ok := <-lines:
|
||||||
|
if !ok {
|
||||||
|
break loop
|
||||||
|
}
|
||||||
|
emit(&batch, r)
|
||||||
|
case <-tick.C:
|
||||||
|
if len(batch) > 0 {
|
||||||
|
rowsC <- batch
|
||||||
|
batch = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tick.Stop()
|
||||||
|
if len(batch) > 0 {
|
||||||
|
rowsC <- batch
|
||||||
|
}
|
||||||
|
cmd.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backfill from the finished file if live summaries are incomplete.
|
||||||
|
l.mu.Lock()
|
||||||
|
closed := l.seg != s
|
||||||
|
count := s.count
|
||||||
|
l.mu.Unlock()
|
||||||
|
if closed && got < count {
|
||||||
|
slog.Info("backfilling summaries", "session", l.id, "segment", s.seq, "have", got, "want", count)
|
||||||
|
cmd := e.Tools.Command(ctx, 3600, e.Tools.SummaryArgs(s.path)...)
|
||||||
|
out, _ := cmd.StdoutPipe()
|
||||||
|
if cmd.Start() == nil {
|
||||||
|
var batch []Row
|
||||||
|
ReadSummaries(out, func(r Row) { emit(&batch, r) })
|
||||||
|
if len(batch) > 0 {
|
||||||
|
rowsC <- batch
|
||||||
|
}
|
||||||
|
cmd.Wait()
|
||||||
|
}
|
||||||
|
e.Hub.Publish(Event{Type: "resync", Data: []byte(`{"session":"` + l.id + `"}`)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func insertRows(tx *sql.Tx, session string, rows []Row) error {
|
||||||
|
st, err := tx.Prepare(`INSERT OR IGNORE INTO packets(session_id,num,seg,off,ts,src,dst,proto,len,info,sport,dport,stream,tcpflags,ttl,vlan,color) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer st.Close()
|
||||||
|
for _, r := range rows {
|
||||||
|
if _, err := st.Exec(session, r.Num, r.Seg, r.Off, r.TS, r.Src, r.Dst, r.Proto, r.Len, r.Info, r.SPort, r.DPort, r.Stream, r.TCPFlags, r.TTL, r.VLAN, r.Color); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) inserter(l *live, rowsC chan []Row) {
|
||||||
|
for batch := range rowsC {
|
||||||
|
tx, err := e.DB.Begin()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("index insert", "err", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := insertRows(tx, l.id, batch); err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
slog.Error("index insert", "err", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tx.Commit()
|
||||||
|
l.summarised.Add(int64(len(batch)))
|
||||||
|
data, _ := json.Marshal(map[string]any{"session": l.id, "rows": batch})
|
||||||
|
e.Hub.Publish(Event{Type: "packets", Data: data})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) publishStatus(l *live) {
|
||||||
|
s, err := e.Get(l.id)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(s)
|
||||||
|
e.Hub.Publish(Event{Type: "capture", Data: data})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- storage
|
||||||
|
|
||||||
|
func (e *Engine) usedBytes() int64 {
|
||||||
|
var n int64
|
||||||
|
e.DB.QueryRow(`SELECT COALESCE(SUM(bytes),0) FROM segments WHERE deleted=0`).Scan(&n)
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func freeBytes(dir string) int64 {
|
||||||
|
var st syscall.Statfs_t
|
||||||
|
if syscall.Statfs(dir, &st) != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return int64(st.Bavail) * int64(st.Bsize)
|
||||||
|
}
|
||||||
|
|
||||||
|
type StorageInfo struct {
|
||||||
|
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 {
|
||||||
|
lim := e.Limits()
|
||||||
|
i := StorageInfo{Used: e.usedBytes(), Free: freeBytes(e.Dir), Limits: lim, Dir: e.Dir}
|
||||||
|
i.LowWarning = i.Free < 2*lim.ReserveBytes || i.Used > lim.MaxBytes*9/10
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
|
||||||
|
// enforceStorage deletes the oldest unpreserved, finished sessions until within
|
||||||
|
// limits. If that is not possible it returns an error and nothing is overwritten.
|
||||||
|
func (e *Engine) enforceStorage(activeID string) error {
|
||||||
|
lim := e.Limits()
|
||||||
|
for {
|
||||||
|
used, free := e.usedBytes(), freeBytes(e.Dir)
|
||||||
|
if used+lim.SegmentBytes <= lim.MaxBytes && free-lim.SegmentBytes >= lim.ReserveBytes {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var victim string
|
||||||
|
e.DB.QueryRow(`SELECT id FROM capture_sessions WHERE preserved=0 AND id!=? AND state NOT IN ('running','importing') ORDER BY started_at LIMIT 1`, activeID).Scan(&victim)
|
||||||
|
if victim == "" {
|
||||||
|
return fmt.Errorf("storage limit reached (used %d of %d bytes, %d free, reserve %d) and no unpreserved sessions remain to delete", used, lim.MaxBytes, free, lim.ReserveBytes)
|
||||||
|
}
|
||||||
|
slog.Warn("storage limit: deleting oldest unpreserved session", "session", victim)
|
||||||
|
e.DB.Audit("system", "capture.retention_delete", victim, "")
|
||||||
|
if err := os.RemoveAll(e.sessionDir(victim)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
e.DB.Exec(`DELETE FROM packets WHERE session_id=?`, victim)
|
||||||
|
e.DB.Exec(`DELETE FROM capture_sessions WHERE id=?`, victim)
|
||||||
|
e.dropFilterCache(victim)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) applyRing(id string) {
|
||||||
|
var ring int
|
||||||
|
var preserved bool
|
||||||
|
e.DB.QueryRow(`SELECT ring_segments,preserved FROM capture_sessions WHERE id=?`, id).Scan(&ring, &preserved)
|
||||||
|
if ring <= 0 || preserved {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
segs, _ := e.Segments(id)
|
||||||
|
var live []Segment
|
||||||
|
for _, s := range segs {
|
||||||
|
if s.Complete && !s.Deleted {
|
||||||
|
live = append(live, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for len(live) > ring {
|
||||||
|
s := live[0]
|
||||||
|
live = live[1:]
|
||||||
|
os.Remove(filepath.Join(e.sessionDir(id), s.File))
|
||||||
|
e.DB.Exec(`UPDATE segments SET deleted=1 WHERE session_id=? AND seq=?`, id, s.Seq)
|
||||||
|
e.DB.Exec(`DELETE FROM packets WHERE session_id=? AND num>=? AND num<?`, id, s.Base, s.Base+s.Count)
|
||||||
|
}
|
||||||
|
e.dropFilterCache(id)
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
package capture
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"pilens.dev/pilens/internal/pcapfile"
|
||||||
|
"pilens.dev/pilens/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
const fixture = "../../tests/fixtures/sample-loopback-http-dns.pcapng"
|
||||||
|
|
||||||
|
func newEngine(t *testing.T) *Engine {
|
||||||
|
ts, err := exec.LookPath("tshark")
|
||||||
|
if err != nil {
|
||||||
|
t.Skip("tshark not installed (run `make test-docker`)")
|
||||||
|
}
|
||||||
|
dir := t.TempDir()
|
||||||
|
db, err := store.Open(filepath.Join(dir, "t.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
tools := &Tools{TShark: ts, Dumpcap: "dumpcap"}
|
||||||
|
if err := tools.Init(dir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
e := &Engine{DB: db, Dir: filepath.Join(dir, "cap"), Tools: tools, Hub: NewHub(), Version: "test"}
|
||||||
|
e.SetLimits(Limits{MaxBytes: 1 << 30, ReserveBytes: 0, SegmentBytes: 1 << 20, ImportBytes: 10 << 20})
|
||||||
|
os.MkdirAll(e.Dir, 0o750)
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImportQueryExport(t *testing.T) {
|
||||||
|
e := newEngine(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
f, err := os.Open(fixture)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
s, err := e.Import(ctx, "fixture", f)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
s, _ = e.Get(s.ID)
|
||||||
|
if s.State != "importing" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if s.State != "ready" || s.Error != "" || s.Packets < 20 {
|
||||||
|
t.Fatalf("import: %+v", s)
|
||||||
|
}
|
||||||
|
p, err := e.Packets(ctx, s.ID, "", 0, 1000)
|
||||||
|
if err != nil || int64(len(p.Rows)) != s.Packets || p.Rows[0].Num != 1 {
|
||||||
|
t.Fatalf("page: %v %+v", err, p)
|
||||||
|
}
|
||||||
|
fp, err := e.Packets(ctx, s.ID, "http.request", 0, 100)
|
||||||
|
if err != nil || fp.Total != 2 || len(fp.Rows) != 2 || !strings.HasPrefix(fp.Rows[0].Info, "GET") {
|
||||||
|
t.Fatalf("filter: %v %+v", err, fp)
|
||||||
|
}
|
||||||
|
if _, err := e.Packets(ctx, s.ID, "http.request ==", 0, 10); err == nil {
|
||||||
|
t.Fatal("bad filter accepted")
|
||||||
|
}
|
||||||
|
if err := e.Tools.ValidateDisplayFilter(ctx, "tcp.port == 443 && dns"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
d, err := e.Packet(ctx, s.ID, fp.Rows[0].Num)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var sawIP, sawHTTP bool
|
||||||
|
var walk func([]Node)
|
||||||
|
walk = func(ns []Node) {
|
||||||
|
for _, n := range ns {
|
||||||
|
if n.Field == "ip.src" && n.Verified && n.Size == 4 {
|
||||||
|
sawIP = true
|
||||||
|
}
|
||||||
|
if n.Field == "http" {
|
||||||
|
sawHTTP = true
|
||||||
|
}
|
||||||
|
walk(n.Children)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(d.Tree)
|
||||||
|
if !sawIP || !sawHTTP || len(d.Bytes) == 0 {
|
||||||
|
t.Fatalf("detail tree incomplete: ip=%v http=%v", sawIP, sawHTTP)
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
n, err := e.Export(ctx, &buf, s.ID, "udp")
|
||||||
|
if err != nil || n != 4 {
|
||||||
|
t.Fatalf("export: %d %v", n, err)
|
||||||
|
}
|
||||||
|
out := filepath.Join(t.TempDir(), "x.pcapng")
|
||||||
|
os.WriteFile(out, buf.Bytes(), 0o600)
|
||||||
|
count := 0
|
||||||
|
if err := pcapfile.Scan(out, "pcapng", func(int, int64, []byte) error { count++; return nil }); err != nil || count != 4 {
|
||||||
|
t.Fatalf("re-read export: %d %v", count, err)
|
||||||
|
}
|
||||||
|
if b, err := exec.Command(e.Tools.TShark, "-r", out).CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("tshark rejects export: %s", b)
|
||||||
|
}
|
||||||
|
v, _ := e.Verify(s.ID)
|
||||||
|
if len(v) != 1 || v[0].Status != "ok" {
|
||||||
|
t.Fatalf("verify: %+v", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImportRejectsGarbage(t *testing.T) {
|
||||||
|
e := newEngine(t)
|
||||||
|
if _, err := e.Import(context.Background(), "x", strings.NewReader("PK\x03\x04 not a capture")); err == nil {
|
||||||
|
t.Fatal("garbage accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLiveCaptureRotation(t *testing.T) {
|
||||||
|
e := newEngine(t)
|
||||||
|
if os.Getenv("PILENS_LIVE_TEST") == "" {
|
||||||
|
t.Skip("set PILENS_LIVE_TEST=1 where dumpcap may capture on lo")
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
sub := e.Hub.Subscribe()
|
||||||
|
defer e.Hub.Unsubscribe(sub)
|
||||||
|
s, err := e.Start(ctx, StartOptions{Name: "live", Interfaces: []string{"lo"}, CaptureFilter: "udp port 9999"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := e.Start(ctx, StartOptions{Interfaces: []string{"lo"}}); err == nil {
|
||||||
|
t.Fatal("second concurrent capture allowed")
|
||||||
|
}
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
c, err := net.Dial("udp", "127.0.0.1:9999")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
payload := bytes.Repeat([]byte("x"), 1000)
|
||||||
|
for i := 0; i < 3000; i++ {
|
||||||
|
c.Write(payload)
|
||||||
|
if i%200 == 0 {
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.Close()
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
if err := e.Stop(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s, _ = e.Get(s.ID)
|
||||||
|
if s.State != "stopped" || s.Packets < 1000 || !strings.Contains(s.Drops, fmt.Sprintf("%d/0", s.Packets)) {
|
||||||
|
t.Fatalf("session: %+v", s)
|
||||||
|
}
|
||||||
|
segs, _ := e.Segments(s.ID)
|
||||||
|
if len(segs) < 2 {
|
||||||
|
t.Fatalf("expected rotation, got %d segments", len(segs))
|
||||||
|
}
|
||||||
|
var total int64
|
||||||
|
for _, sg := range segs {
|
||||||
|
if !sg.Complete || sg.SHA256 == "" {
|
||||||
|
t.Fatalf("segment not finalised: %+v", sg)
|
||||||
|
}
|
||||||
|
total += sg.Count
|
||||||
|
// every segment must be a standalone valid capture
|
||||||
|
if b, err := exec.Command(e.Tools.TShark, "-r", filepath.Join(e.Dir, s.ID, sg.File), "-c", "1").CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("segment %d unreadable: %s", sg.Seq, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p, _ := e.Packets(ctx, s.ID, "", 0, 2000)
|
||||||
|
if total != s.Packets || p.Total != s.Packets {
|
||||||
|
t.Fatalf("counts disagree: segments %d session %d index %d", total, s.Packets, p.Total)
|
||||||
|
}
|
||||||
|
last, err := e.Packet(ctx, s.ID, s.Packets)
|
||||||
|
if err != nil || len(last.Bytes) == 0 {
|
||||||
|
t.Fatalf("last packet detail: %v", err)
|
||||||
|
}
|
||||||
|
if v, _ := e.Verify(s.ID); v[0].Status != "ok" {
|
||||||
|
t.Fatalf("verify %+v", v)
|
||||||
|
}
|
||||||
|
got := 0
|
||||||
|
for len(sub.C) > 0 {
|
||||||
|
if ev := <-sub.C; ev.Type == "packets" {
|
||||||
|
got++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got == 0 && sub.Dropped.Load() == 0 {
|
||||||
|
t.Fatal("no live packet events")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package capture
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Hub fans out live events to browser subscribers. A subscriber that cannot
|
||||||
|
// keep up loses events (counted) and is told to resync; capture never waits.
|
||||||
|
type Hub struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
subs map[*Sub]struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Event struct {
|
||||||
|
Type string // SSE event name
|
||||||
|
Data []byte // JSON
|
||||||
|
}
|
||||||
|
|
||||||
|
type Sub struct {
|
||||||
|
C chan Event
|
||||||
|
Dropped atomic.Int64
|
||||||
|
Resync atomic.Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHub() *Hub { return &Hub{subs: map[*Sub]struct{}{}} }
|
||||||
|
|
||||||
|
func (h *Hub) Subscribe() *Sub {
|
||||||
|
s := &Sub{C: make(chan Event, 64)}
|
||||||
|
h.mu.Lock()
|
||||||
|
h.subs[s] = struct{}{}
|
||||||
|
h.mu.Unlock()
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Hub) Unsubscribe(s *Sub) {
|
||||||
|
h.mu.Lock()
|
||||||
|
delete(h.subs, s)
|
||||||
|
h.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Hub) Publish(e Event) {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
for s := range h.subs {
|
||||||
|
select {
|
||||||
|
case s.C <- e:
|
||||||
|
default:
|
||||||
|
s.Dropped.Add(1)
|
||||||
|
s.Resync.Store(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package capture
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/xml"
|
||||||
|
"errors"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Node is one line of the protocol tree sent to the browser.
|
||||||
|
type Node struct {
|
||||||
|
Label string `json:"label"`
|
||||||
|
Field string `json:"field,omitempty"`
|
||||||
|
Pos int `json:"pos"`
|
||||||
|
Size int `json:"size"`
|
||||||
|
Verified bool `json:"verified"` // bytes at pos..pos+size are proven to belong to this field
|
||||||
|
Children []Node `json:"children,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type pdmlNode struct {
|
||||||
|
XMLName xml.Name
|
||||||
|
Name string `xml:"name,attr"`
|
||||||
|
Show string `xml:"show,attr"`
|
||||||
|
Showname string `xml:"showname,attr"`
|
||||||
|
Value string `xml:"value,attr"`
|
||||||
|
Unmasked string `xml:"unmaskedvalue,attr"`
|
||||||
|
Pos string `xml:"pos,attr"`
|
||||||
|
Size string `xml:"size,attr"`
|
||||||
|
Hide string `xml:"hide,attr"`
|
||||||
|
Children []pdmlNode `xml:",any"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParsePDML converts tshark PDML for a single packet into a tree. frame holds
|
||||||
|
// the original captured bytes, used to verify each field's byte range.
|
||||||
|
func ParsePDML(b []byte, frame []byte) ([]Node, error) {
|
||||||
|
var doc struct {
|
||||||
|
Packets []struct {
|
||||||
|
Protos []pdmlNode `xml:",any"`
|
||||||
|
} `xml:"packet"`
|
||||||
|
}
|
||||||
|
d := xml.NewDecoder(bytes.NewReader(b))
|
||||||
|
d.Strict = false
|
||||||
|
if err := d.Decode(&doc); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(doc.Packets) == 0 {
|
||||||
|
return nil, errors.New("packet not found")
|
||||||
|
}
|
||||||
|
var out []Node
|
||||||
|
for _, p := range doc.Packets[0].Protos {
|
||||||
|
if p.Name == "geninfo" || p.Hide == "yes" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
n, _ := convert(p, frame)
|
||||||
|
out = append(out, n)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// convert returns the node and whether any field in its subtree contradicts the frame bytes.
|
||||||
|
func convert(p pdmlNode, frame []byte) (Node, bool) {
|
||||||
|
n := Node{Field: p.Name}
|
||||||
|
n.Label = p.Showname
|
||||||
|
if n.Label == "" {
|
||||||
|
n.Label = p.Show
|
||||||
|
}
|
||||||
|
if n.Label == "" {
|
||||||
|
n.Label = p.Name
|
||||||
|
}
|
||||||
|
n.Pos, _ = strconv.Atoi(p.Pos)
|
||||||
|
n.Size, _ = strconv.Atoi(p.Size)
|
||||||
|
inRange := n.Size > 0 && n.Pos >= 0 && n.Pos+n.Size <= len(frame)
|
||||||
|
raw := p.Value
|
||||||
|
if p.Unmasked != "" {
|
||||||
|
raw = p.Unmasked // bitfields: value is the masked number, unmaskedvalue the bytes
|
||||||
|
}
|
||||||
|
contradicts := false
|
||||||
|
if v, err := hex.DecodeString(raw); err == nil && len(v) > 0 && len(v) == n.Size {
|
||||||
|
if inRange && bytes.Equal(v, frame[n.Pos:n.Pos+n.Size]) {
|
||||||
|
n.Verified = true
|
||||||
|
} else {
|
||||||
|
contradicts = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
matched, mismatched := false, contradicts
|
||||||
|
for _, c := range p.Children {
|
||||||
|
if c.Hide == "yes" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cn, cm := convert(c, frame)
|
||||||
|
if c.XMLName.Local != "proto" { // a nested (e.g. reassembled) protocol is judged on its own
|
||||||
|
matched = matched || cn.Verified
|
||||||
|
mismatched = mismatched || cm
|
||||||
|
}
|
||||||
|
n.Children = append(n.Children, cn)
|
||||||
|
}
|
||||||
|
// Protocols carry no value: trust their range only if their own fields verify
|
||||||
|
// against the frame bytes and none contradict it (rules out reassembled sources).
|
||||||
|
if raw == "" && inRange && matched && !mismatched {
|
||||||
|
n.Verified = true
|
||||||
|
}
|
||||||
|
if p.Name == "frame" && inRange {
|
||||||
|
n.Verified = true
|
||||||
|
}
|
||||||
|
return n, mismatched
|
||||||
|
}
|
||||||
@@ -0,0 +1,503 @@
|
|||||||
|
package capture
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"pilens.dev/pilens/internal/pcapfile"
|
||||||
|
)
|
||||||
|
|
||||||
|
const rowCols = `num,seg,off,ts,src,dst,proto,len,info,sport,dport,stream,tcpflags,ttl,vlan,color`
|
||||||
|
|
||||||
|
func scanRows(rows *sql.Rows) ([]Row, error) {
|
||||||
|
defer rows.Close()
|
||||||
|
out := []Row{}
|
||||||
|
for rows.Next() {
|
||||||
|
var r Row
|
||||||
|
var flags, color sql.NullString
|
||||||
|
if err := rows.Scan(&r.Num, &r.Seg, &r.Off, &r.TS, &r.Src, &r.Dst, &r.Proto, &r.Len, &r.Info, &r.SPort, &r.DPort, &r.Stream, &flags, &r.TTL, &r.VLAN, &color); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
r.TCPFlags, r.Color = flags.String, color.String
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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"`
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) Packets(ctx context.Context, id, filter string, offset, limit int64) (Page, error) {
|
||||||
|
if limit <= 0 || limit > 2000 {
|
||||||
|
limit = 500
|
||||||
|
}
|
||||||
|
if offset < 0 {
|
||||||
|
offset = 0
|
||||||
|
}
|
||||||
|
p := Page{Offset: offset}
|
||||||
|
if strings.TrimSpace(filter) == "" {
|
||||||
|
var first, last sql.NullInt64
|
||||||
|
e.DB.QueryRow(`SELECT MIN(num),MAX(num) FROM packets WHERE session_id=?`, id).Scan(&first, &last)
|
||||||
|
if !first.Valid {
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
p.First = first.Int64
|
||||||
|
p.Total = last.Int64 - first.Int64 + 1
|
||||||
|
from := first.Int64 + offset
|
||||||
|
rows, err := e.DB.QueryContext(ctx, `SELECT `+rowCols+` FROM packets WHERE session_id=? AND num>=? AND num<? ORDER BY num`, id, from, from+limit)
|
||||||
|
if err != nil {
|
||||||
|
return p, err
|
||||||
|
}
|
||||||
|
p.Rows, err = scanRows(rows)
|
||||||
|
return p, err
|
||||||
|
}
|
||||||
|
nums, err := e.filterNums(ctx, id, filter)
|
||||||
|
if err != nil {
|
||||||
|
return p, err
|
||||||
|
}
|
||||||
|
p.Filtered = true
|
||||||
|
p.Total = int64(len(nums))
|
||||||
|
if offset >= p.Total {
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
end := min(offset+limit, p.Total)
|
||||||
|
p.Nums = nums[offset:end]
|
||||||
|
if len(p.Nums) == 0 {
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
rows, err := e.DB.QueryContext(ctx, `SELECT `+rowCols+` FROM packets WHERE session_id=? AND num>=? AND num<=? ORDER BY num`, id, p.Nums[0], p.Nums[len(p.Nums)-1])
|
||||||
|
if err != nil {
|
||||||
|
return p, err
|
||||||
|
}
|
||||||
|
all, err := scanRows(rows)
|
||||||
|
if err != nil {
|
||||||
|
return p, err
|
||||||
|
}
|
||||||
|
want := map[int64]bool{}
|
||||||
|
for _, n := range p.Nums {
|
||||||
|
want[n] = true
|
||||||
|
}
|
||||||
|
for _, r := range all {
|
||||||
|
if want[r.Num] {
|
||||||
|
p.Rows = append(p.Rows, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- display filter cache
|
||||||
|
|
||||||
|
type filterEntry struct {
|
||||||
|
perSeg map[int]segResult
|
||||||
|
used time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type segResult struct {
|
||||||
|
count int64 // segment packet count when evaluated
|
||||||
|
nums []int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) dropFilterCache(id string) {
|
||||||
|
e.fcMu.Lock()
|
||||||
|
defer e.fcMu.Unlock()
|
||||||
|
for k := range e.fcache {
|
||||||
|
if strings.HasPrefix(k, id+"\x00") {
|
||||||
|
delete(e.fcache, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// filterNums evaluates a display filter with tshark segment by segment. Complete
|
||||||
|
// segments are cached; the segment being written is re-evaluated when it grows.
|
||||||
|
func (e *Engine) filterNums(ctx context.Context, id, filter string) ([]int64, error) {
|
||||||
|
if err := sanityFilter(filter); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
segs, err := e.Segments(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
key := id + "\x00" + filter
|
||||||
|
e.fcMu.Lock()
|
||||||
|
if e.fcache == nil {
|
||||||
|
e.fcache = map[string]*filterEntry{}
|
||||||
|
}
|
||||||
|
ent := e.fcache[key]
|
||||||
|
if ent == nil {
|
||||||
|
if len(e.fcache) >= 16 { // ponytail: evict oldest of a tiny cache
|
||||||
|
var oldK string
|
||||||
|
var oldT time.Time
|
||||||
|
for k, v := range e.fcache {
|
||||||
|
if oldK == "" || v.used.Before(oldT) {
|
||||||
|
oldK, oldT = k, v.used
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delete(e.fcache, oldK)
|
||||||
|
}
|
||||||
|
ent = &filterEntry{perSeg: map[int]segResult{}}
|
||||||
|
e.fcache[key] = ent
|
||||||
|
}
|
||||||
|
ent.used = time.Now()
|
||||||
|
e.fcMu.Unlock()
|
||||||
|
|
||||||
|
var out []int64
|
||||||
|
for _, s := range segs {
|
||||||
|
if s.Deleted {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
count := s.Count
|
||||||
|
if !s.Complete {
|
||||||
|
count = -1 // live: always re-evaluate
|
||||||
|
e.flushLive(id)
|
||||||
|
}
|
||||||
|
e.fcMu.Lock()
|
||||||
|
r, ok := ent.perSeg[s.Seq]
|
||||||
|
e.fcMu.Unlock()
|
||||||
|
if !ok || r.count != count || count == -1 {
|
||||||
|
local, err := e.Tools.MatchFrames(ctx, filepath.Join(e.sessionDir(id), s.File), filter)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
r = segResult{count: count}
|
||||||
|
for _, n := range local {
|
||||||
|
r.nums = append(r.nums, s.Base+n-1)
|
||||||
|
}
|
||||||
|
e.fcMu.Lock()
|
||||||
|
ent.perSeg[s.Seq] = r
|
||||||
|
e.fcMu.Unlock()
|
||||||
|
}
|
||||||
|
out = append(out, r.nums...)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) flushLive(id string) {
|
||||||
|
e.mu.Lock()
|
||||||
|
l := e.live
|
||||||
|
e.mu.Unlock()
|
||||||
|
if l != nil && l.id == id {
|
||||||
|
l.mu.Lock()
|
||||||
|
if l.seg != nil {
|
||||||
|
l.seg.bw.Flush()
|
||||||
|
}
|
||||||
|
l.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- packet detail
|
||||||
|
|
||||||
|
type Detail struct {
|
||||||
|
Row Row `json:"row"`
|
||||||
|
Bytes string `json:"bytes"` // hex of the original captured bytes
|
||||||
|
Tree []Node `json:"tree"`
|
||||||
|
Segment int `json:"segment"`
|
||||||
|
Note string `json:"note,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) segmentFor(id string, seq int) (Segment, error) {
|
||||||
|
segs, err := e.Segments(id)
|
||||||
|
if err != nil {
|
||||||
|
return Segment{}, err
|
||||||
|
}
|
||||||
|
for _, s := range segs {
|
||||||
|
if s.Seq == seq && !s.Deleted {
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Segment{}, errors.New("segment no longer stored (ring buffer or retention)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) Packet(ctx context.Context, id string, num int64) (Detail, error) {
|
||||||
|
rows, err := e.DB.QueryContext(ctx, `SELECT `+rowCols+` FROM packets WHERE session_id=? AND num=?`, id, num)
|
||||||
|
if err != nil {
|
||||||
|
return Detail{}, err
|
||||||
|
}
|
||||||
|
rs, err := scanRows(rows)
|
||||||
|
if err != nil {
|
||||||
|
return Detail{}, err
|
||||||
|
}
|
||||||
|
if len(rs) == 0 {
|
||||||
|
return Detail{}, ErrNotFound
|
||||||
|
}
|
||||||
|
d := Detail{Row: rs[0], Segment: rs[0].Seg}
|
||||||
|
seg, err := e.segmentFor(id, d.Row.Seg)
|
||||||
|
if err != nil {
|
||||||
|
return d, err
|
||||||
|
}
|
||||||
|
path := filepath.Join(e.sessionDir(id), seg.File)
|
||||||
|
e.flushLive(id)
|
||||||
|
data, err := pcapfile.ReadPacketAt(path, seg.Format, d.Row.Off)
|
||||||
|
if err != nil {
|
||||||
|
return d, fmt.Errorf("reading packet bytes: %w", err)
|
||||||
|
}
|
||||||
|
d.Bytes = hex.EncodeToString(data)
|
||||||
|
pdml, err := e.Tools.PDML(ctx, path, num-seg.Base+1)
|
||||||
|
if err != nil {
|
||||||
|
return d, fmt.Errorf("dissection failed: %w", err)
|
||||||
|
}
|
||||||
|
d.Tree, err = ParsePDML(pdml, data)
|
||||||
|
if err != nil {
|
||||||
|
return d, fmt.Errorf("dissection output: %w", err)
|
||||||
|
}
|
||||||
|
if seg.Seq > 1 || !seg.Complete {
|
||||||
|
d.Note = "Dissected within segment " + fmt.Sprint(seg.Seq) + ": TCP stream numbers and reassembly restart at segment boundaries."
|
||||||
|
}
|
||||||
|
return d, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- export
|
||||||
|
|
||||||
|
// ExportFormat reports the container format an export of id will use.
|
||||||
|
func (e *Engine) ExportFormat(id string) string {
|
||||||
|
segs, _ := e.Segments(id)
|
||||||
|
for _, s := range segs {
|
||||||
|
if s.Format == "pcap" {
|
||||||
|
return "pcap"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "pcapng"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export writes original packet records (all, or those matching filter) to w.
|
||||||
|
// Packet data is copied byte-for-byte from the stored segments.
|
||||||
|
func (e *Engine) Export(ctx context.Context, w io.Writer, id, filter string) (int, error) {
|
||||||
|
segs, err := e.Segments(id)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
var keep map[int64]bool
|
||||||
|
if strings.TrimSpace(filter) != "" {
|
||||||
|
nums, err := e.filterNums(ctx, id, filter)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
keep = make(map[int64]bool, len(nums))
|
||||||
|
for _, n := range nums {
|
||||||
|
keep[n] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
e.flushLive(id)
|
||||||
|
bw := bufio.NewWriterSize(w, 1<<20)
|
||||||
|
total, first := 0, true
|
||||||
|
for _, s := range segs {
|
||||||
|
if s.Deleted {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
base := s.Base
|
||||||
|
n, err := pcapfile.CopySelected(bw, filepath.Join(e.sessionDir(id), s.File), s.Format, first, func(idx int) bool {
|
||||||
|
return keep == nil || keep[base+int64(idx)]
|
||||||
|
})
|
||||||
|
total += n
|
||||||
|
first = false
|
||||||
|
if err != nil && !s.Complete && errors.Is(err, io.ErrUnexpectedEOF) {
|
||||||
|
continue // live segment: the last block is still being written
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
bw.Flush()
|
||||||
|
return total, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total, bw.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- integrity
|
||||||
|
|
||||||
|
type Manifest struct {
|
||||||
|
Session Session `json:"session"`
|
||||||
|
Segments []Segment `json:"segments"`
|
||||||
|
Generated string `json:"generated_utc"`
|
||||||
|
Note string `json:"note"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) Manifest(id string) (Manifest, error) {
|
||||||
|
s, err := e.Get(id)
|
||||||
|
if err != nil {
|
||||||
|
return Manifest{}, err
|
||||||
|
}
|
||||||
|
segs, err := e.Segments(id)
|
||||||
|
return Manifest{Session: s, Segments: segs, Generated: time.Now().UTC().Format(time.RFC3339Nano),
|
||||||
|
Note: "Segment files are original capture data written verbatim from dumpcap output (live) or the uploaded file (import). SHA-256 covers each complete file. Summary rows and dissections are derived data and are not part of the evidence."}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
type VerifyResult struct {
|
||||||
|
Seq int `json:"seq"`
|
||||||
|
File string `json:"file"`
|
||||||
|
Status string `json:"status"` // ok | mismatch | missing | incomplete | deleted
|
||||||
|
Actual string `json:"actual,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) Verify(id string) ([]VerifyResult, error) {
|
||||||
|
segs, err := e.Segments(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var out []VerifyResult
|
||||||
|
for _, s := range segs {
|
||||||
|
r := VerifyResult{Seq: s.Seq, File: s.File}
|
||||||
|
switch {
|
||||||
|
case s.Deleted:
|
||||||
|
r.Status = "deleted"
|
||||||
|
case !s.Complete:
|
||||||
|
r.Status = "incomplete"
|
||||||
|
default:
|
||||||
|
f, err := os.Open(filepath.Join(e.sessionDir(id), s.File))
|
||||||
|
if err != nil {
|
||||||
|
r.Status = "missing"
|
||||||
|
break
|
||||||
|
}
|
||||||
|
h := sha256.New()
|
||||||
|
io.Copy(h, f)
|
||||||
|
f.Close()
|
||||||
|
r.Actual = hex.EncodeToString(h.Sum(nil))
|
||||||
|
r.Status = "ok"
|
||||||
|
if r.Actual != s.SHA256 {
|
||||||
|
r.Status = "mismatch"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].Seq < out[j].Seq })
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- import
|
||||||
|
|
||||||
|
// Import stores an uploaded capture unchanged and indexes it in the background.
|
||||||
|
// The upload is hostile input: size-limited, magic-checked, and parsed only by
|
||||||
|
// bounds-checked code and resource-limited tshark.
|
||||||
|
func (e *Engine) Import(ctx context.Context, name string, r io.Reader) (Session, error) {
|
||||||
|
lim := e.Limits()
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if name == "" || len(name) > 200 {
|
||||||
|
name = "Import " + time.Now().Format("2006-01-02 15:04")
|
||||||
|
}
|
||||||
|
if err := e.enforceStorage(""); err != nil {
|
||||||
|
return Session{}, err
|
||||||
|
}
|
||||||
|
id := newID()
|
||||||
|
dir := e.sessionDir(id)
|
||||||
|
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||||
|
return Session{}, err
|
||||||
|
}
|
||||||
|
br := bufio.NewReader(io.LimitReader(r, lim.ImportBytes+1))
|
||||||
|
head, _ := br.Peek(4)
|
||||||
|
format, err := pcapfile.Sniff(head)
|
||||||
|
if err != nil {
|
||||||
|
os.RemoveAll(dir)
|
||||||
|
return Session{}, errors.New("file is not pcap or pcapng (compressed captures are not accepted)")
|
||||||
|
}
|
||||||
|
file := "original." + format
|
||||||
|
f, err := os.OpenFile(filepath.Join(dir, file), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o640)
|
||||||
|
if err != nil {
|
||||||
|
os.RemoveAll(dir)
|
||||||
|
return Session{}, err
|
||||||
|
}
|
||||||
|
h := sha256.New()
|
||||||
|
n, err := io.Copy(io.MultiWriter(f, h), br)
|
||||||
|
f.Sync()
|
||||||
|
f.Close()
|
||||||
|
if err != nil || n > lim.ImportBytes {
|
||||||
|
os.RemoveAll(dir)
|
||||||
|
if err == nil {
|
||||||
|
err = fmt.Errorf("file exceeds import limit of %d bytes", lim.ImportBytes)
|
||||||
|
}
|
||||||
|
return Session{}, err
|
||||||
|
}
|
||||||
|
sum := hex.EncodeToString(h.Sum(nil))
|
||||||
|
meta := e.meta(map[string]any{"import_sha256": sum, "import_bytes": n})
|
||||||
|
tx, err := e.DB.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return Session{}, err
|
||||||
|
}
|
||||||
|
tx.Exec(`INSERT INTO capture_sessions(id,name,kind,state,started_at,dir,meta,bytes) VALUES(?,?,?,?,?,?,?,?)`, id, name, "import", "importing", time.Now().UnixMilli(), dir, meta, n)
|
||||||
|
tx.Exec(`INSERT INTO segments(session_id,seq,file,format,base,bytes,sha256,complete) VALUES(?,?,?,?,?,?,?,1)`, id, 1, file, format, 1, n, sum)
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return Session{}, err
|
||||||
|
}
|
||||||
|
go e.indexImport(id, filepath.Join(dir, file), format)
|
||||||
|
return e.Get(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) indexImport(id, path, format string) {
|
||||||
|
fail := func(err error) { e.finishSession(id, "failed", err.Error(), "") }
|
||||||
|
var offs []int64
|
||||||
|
if err := pcapfile.Scan(path, format, func(idx int, off int64, _ []byte) error {
|
||||||
|
if len(offs) >= 50_000_000 {
|
||||||
|
return errors.New("too many packets")
|
||||||
|
}
|
||||||
|
offs = append(offs, off)
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
fail(fmt.Errorf("malformed capture after packet %d: %v", len(offs), err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Hour)
|
||||||
|
defer cancel()
|
||||||
|
cmd := e.Tools.Command(ctx, 3600, e.Tools.SummaryArgs(path)...)
|
||||||
|
out, err := cmd.StdoutPipe()
|
||||||
|
var stderr limitedBuffer
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
if err == nil {
|
||||||
|
err = cmd.Start()
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
fail(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var batch []Row
|
||||||
|
var count int64
|
||||||
|
flush := func() {
|
||||||
|
tx, err := e.DB.Begin()
|
||||||
|
if err == nil {
|
||||||
|
insertRows(tx, id, batch)
|
||||||
|
tx.Commit()
|
||||||
|
}
|
||||||
|
batch = batch[:0]
|
||||||
|
}
|
||||||
|
ReadSummaries(out, func(r Row) {
|
||||||
|
if r.Num < 1 || r.Num > int64(len(offs)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.Off, r.Seg = offs[r.Num-1], 1
|
||||||
|
batch = append(batch, r)
|
||||||
|
count++
|
||||||
|
if len(batch) >= 2000 {
|
||||||
|
flush()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
flush()
|
||||||
|
werr := cmd.Wait()
|
||||||
|
e.DB.Exec(`UPDATE segments SET count=? WHERE session_id=? AND seq=1`, len(offs), id)
|
||||||
|
e.DB.Exec(`UPDATE capture_sessions SET packets=? WHERE id=?`, len(offs), id)
|
||||||
|
msg := ""
|
||||||
|
if werr != nil {
|
||||||
|
msg = "tshark: " + strings.TrimSpace(stderr.String())
|
||||||
|
}
|
||||||
|
if count < int64(len(offs)) {
|
||||||
|
msg = strings.TrimSpace(fmt.Sprintf("%s; summarised %d of %d packets", msg, count, len(offs)))
|
||||||
|
}
|
||||||
|
e.finishSession(id, "ready", msg, "")
|
||||||
|
data, _ := json.Marshal(map[string]string{"session": id})
|
||||||
|
e.Hub.Publish(Event{Type: "resync", Data: data})
|
||||||
|
}
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
package capture
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/binary"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Tools locates and runs Wireshark command-line tools. All tshark children run
|
||||||
|
// through `pilens rlimit-exec` so hostile input cannot exhaust the appliance.
|
||||||
|
type Tools struct {
|
||||||
|
Self string // path to the pilens binary (for rlimit-exec)
|
||||||
|
TShark string
|
||||||
|
Dumpcap string
|
||||||
|
MemMB int // RLIMIT_AS for tshark children
|
||||||
|
|
||||||
|
cols []string // summary -e fields, version dependent
|
||||||
|
emptyNG string // path to an empty pcapng used for filter validation
|
||||||
|
}
|
||||||
|
|
||||||
|
// summaryFields; the column fields are filled in by Init (names changed in Wireshark 4.2).
|
||||||
|
var summaryTail = []string{
|
||||||
|
"frame.len", "tcp.srcport", "udp.srcport", "tcp.dstport", "udp.dstport",
|
||||||
|
"tcp.stream", "tcp.flags.str", "ip.ttl", "ipv6.hlim", "vlan.id", "frame.coloring_rule.name",
|
||||||
|
}
|
||||||
|
|
||||||
|
// EmptyPcapng is a valid pcapng with one Ethernet interface and no packets.
|
||||||
|
func EmptyPcapng() []byte {
|
||||||
|
var b bytes.Buffer
|
||||||
|
le := binary.LittleEndian
|
||||||
|
w32 := func(v uint32) { binary.Write(&b, le, v) }
|
||||||
|
// SHB
|
||||||
|
w32(0x0A0D0D0A)
|
||||||
|
w32(28)
|
||||||
|
w32(0x1A2B3C4D)
|
||||||
|
binary.Write(&b, le, uint16(1))
|
||||||
|
binary.Write(&b, le, uint16(0))
|
||||||
|
binary.Write(&b, le, int64(-1))
|
||||||
|
w32(28)
|
||||||
|
// IDB: linktype 1 (Ethernet), snaplen 262144
|
||||||
|
w32(1)
|
||||||
|
w32(20)
|
||||||
|
binary.Write(&b, le, uint16(1))
|
||||||
|
binary.Write(&b, le, uint16(0))
|
||||||
|
w32(262144)
|
||||||
|
w32(20)
|
||||||
|
return b.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Tools) Init(stateDir string) error {
|
||||||
|
if t.MemMB == 0 {
|
||||||
|
t.MemMB = 1536
|
||||||
|
}
|
||||||
|
p := stateDir + "/empty.pcapng"
|
||||||
|
if err := os.WriteFile(p, EmptyPcapng(), 0o640); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
t.emptyNG = p
|
||||||
|
newCols := []string{"_ws.col.def_src", "_ws.col.def_dst", "_ws.col.protocol", "_ws.col.info"}
|
||||||
|
oldCols := []string{"_ws.col.Source", "_ws.col.Destination", "_ws.col.Protocol", "_ws.col.Info"}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if _, err := t.run(ctx, "-n", "-r", p, "-T", "fields", "-e", newCols[3]); err == nil {
|
||||||
|
t.cols = newCols
|
||||||
|
} else if _, err2 := t.run(ctx, "-n", "-r", p, "-T", "fields", "-e", oldCols[3]); err2 == nil {
|
||||||
|
t.cols = oldCols
|
||||||
|
} else {
|
||||||
|
return fmt.Errorf("tshark unusable: %v", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Command builds a resource-limited tshark command. cpuSec 0 = no CPU limit.
|
||||||
|
func (t *Tools) Command(ctx context.Context, cpuSec int, args ...string) *exec.Cmd {
|
||||||
|
var cmd *exec.Cmd
|
||||||
|
if t.Self == "" { // tests only
|
||||||
|
cmd = exec.CommandContext(ctx, t.TShark, args...)
|
||||||
|
} else {
|
||||||
|
a := append([]string{"rlimit-exec", strconv.Itoa(t.MemMB), strconv.Itoa(cpuSec), "--", t.TShark}, args...)
|
||||||
|
cmd = exec.CommandContext(ctx, t.Self, a...)
|
||||||
|
}
|
||||||
|
cmd.Env = []string{"PATH=/usr/bin:/bin", "HOME=/nonexistent", "LANG=C.UTF-8"}
|
||||||
|
cmd.WaitDelay = 5 * time.Second
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Tools) run(ctx context.Context, args ...string) ([]byte, error) {
|
||||||
|
cmd := t.Command(ctx, 300, args...)
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
msg := strings.TrimSpace(stderr.String())
|
||||||
|
if msg == "" {
|
||||||
|
msg = err.Error()
|
||||||
|
}
|
||||||
|
return out, errors.New(msg)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SummaryArgs returns tshark arguments that print one tab-separated summary row per packet.
|
||||||
|
func (t *Tools) SummaryArgs(input string) []string {
|
||||||
|
a := []string{"-n", "-l", "--color", "-r", input, "-T", "fields", "-E", "separator=/t", "-E", "occurrence=f", "-E", "quote=n",
|
||||||
|
"-e", "frame.number", "-e", "frame.time_epoch", "-e", t.cols[0], "-e", t.cols[1], "-e", t.cols[2]}
|
||||||
|
for _, f := range summaryTail {
|
||||||
|
a = append(a, "-e", f)
|
||||||
|
}
|
||||||
|
return append(a, "-e", t.cols[3]) // info last: it may contain anything
|
||||||
|
}
|
||||||
|
|
||||||
|
// Row is one packet summary.
|
||||||
|
type Row struct {
|
||||||
|
Num int64 `json:"n"`
|
||||||
|
TS float64 `json:"t"`
|
||||||
|
Src string `json:"s"`
|
||||||
|
Dst string `json:"d"`
|
||||||
|
Proto string `json:"p"`
|
||||||
|
Len int `json:"l"`
|
||||||
|
Info string `json:"i"`
|
||||||
|
SPort *int `json:"sp,omitempty"`
|
||||||
|
DPort *int `json:"dp,omitempty"`
|
||||||
|
Stream *int `json:"st,omitempty"`
|
||||||
|
TCPFlags string `json:"tf,omitempty"`
|
||||||
|
TTL *int `json:"ttl,omitempty"`
|
||||||
|
VLAN *int `json:"vl,omitempty"`
|
||||||
|
Color string `json:"c,omitempty"`
|
||||||
|
Seg int `json:"-"`
|
||||||
|
Off int64 `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func atoiPtr(s string) *int {
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
v, err := strconv.Atoi(s)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &v
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmpty(a, b string) string {
|
||||||
|
if a != "" {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseSummaryLine parses one line of SummaryArgs output. Frame numbers are local to the input.
|
||||||
|
func ParseSummaryLine(line string) (Row, error) {
|
||||||
|
f := strings.SplitN(line, "\t", 17)
|
||||||
|
if len(f) != 17 {
|
||||||
|
return Row{}, fmt.Errorf("unexpected tshark summary line (%d fields)", len(f))
|
||||||
|
}
|
||||||
|
var r Row
|
||||||
|
var err error
|
||||||
|
if r.Num, err = strconv.ParseInt(f[0], 10, 64); err != nil {
|
||||||
|
return r, err
|
||||||
|
}
|
||||||
|
r.TS, _ = strconv.ParseFloat(f[1], 64)
|
||||||
|
r.Src, r.Dst, r.Proto = f[2], f[3], f[4]
|
||||||
|
r.Len, _ = strconv.Atoi(f[5])
|
||||||
|
r.SPort = atoiPtr(firstNonEmpty(f[6], f[7]))
|
||||||
|
r.DPort = atoiPtr(firstNonEmpty(f[8], f[9]))
|
||||||
|
r.Stream = atoiPtr(f[10])
|
||||||
|
r.TCPFlags = f[11]
|
||||||
|
r.TTL = atoiPtr(firstNonEmpty(f[12], f[13]))
|
||||||
|
r.VLAN = atoiPtr(f[14])
|
||||||
|
r.Color = f[15]
|
||||||
|
r.Info = strings.TrimSpace(f[16])
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadSummaries parses tshark summary output, calling fn per row.
|
||||||
|
func ReadSummaries(r io.Reader, fn func(Row)) error {
|
||||||
|
sc := bufio.NewScanner(r)
|
||||||
|
sc.Buffer(make([]byte, 64*1024), 4<<20)
|
||||||
|
for sc.Scan() {
|
||||||
|
row, err := ParseSummaryLine(sc.Text())
|
||||||
|
if err != nil {
|
||||||
|
continue // ponytail: skip malformed lines rather than abort a capture; count if it ever matters
|
||||||
|
}
|
||||||
|
fn(row)
|
||||||
|
}
|
||||||
|
return sc.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateDisplayFilter asks tshark to compile the filter against an empty file.
|
||||||
|
func (t *Tools) ValidateDisplayFilter(ctx context.Context, f string) error {
|
||||||
|
if err := sanityFilter(f); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err := t.run(ctx, "-n", "-r", t.emptyNG, "-Y", f)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateCaptureFilter asks dumpcap to compile a BPF filter for iface.
|
||||||
|
func (t *Tools) ValidateCaptureFilter(ctx context.Context, iface, f string) error {
|
||||||
|
if err := sanityFilter(f); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
out, err := exec.CommandContext(ctx, t.Dumpcap, "-d", "-i", iface, "-f", f).CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return errors.New(strings.TrimSpace(string(out)))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanityFilter(f string) error {
|
||||||
|
if len(f) > 2048 {
|
||||||
|
return errors.New("filter too long")
|
||||||
|
}
|
||||||
|
for _, r := range f {
|
||||||
|
if r < 0x20 && r != '\t' {
|
||||||
|
return errors.New("filter contains control characters")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MatchFrames returns the local frame numbers in file that match a display filter.
|
||||||
|
// Truncated trailing data (a segment still being written) is tolerated.
|
||||||
|
func (t *Tools) MatchFrames(ctx context.Context, file, filter string) ([]int64, error) {
|
||||||
|
cmd := t.Command(ctx, 600, "-n", "-r", file, "-Y", filter, "-T", "fields", "-e", "frame.number")
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err != nil && !strings.Contains(stderr.String(), "cut short") {
|
||||||
|
return nil, errors.New(strings.TrimSpace(stderr.String() + " " + err.Error()))
|
||||||
|
}
|
||||||
|
var nums []int64
|
||||||
|
for _, l := range strings.Split(string(out), "\n") {
|
||||||
|
if n, err := strconv.ParseInt(strings.TrimSpace(l), 10, 64); err == nil {
|
||||||
|
nums = append(nums, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nums, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PDML returns the PDML dissection for one local frame of file.
|
||||||
|
func (t *Tools) PDML(ctx context.Context, file string, local int64) ([]byte, error) {
|
||||||
|
cmd := t.Command(ctx, 120, "-n", "-r", file, "-Y", "frame.number=="+strconv.FormatInt(local, 10), "-T", "pdml")
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err != nil && !strings.Contains(stderr.String(), "cut short") {
|
||||||
|
return nil, errors.New(strings.TrimSpace(stderr.String() + " " + err.Error()))
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Tools) Version() string {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
out, err := exec.CommandContext(ctx, t.TShark, "--version").Output()
|
||||||
|
if err != nil {
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
l, _, _ := strings.Cut(string(out), "\n")
|
||||||
|
return l
|
||||||
|
}
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
// Package config holds the appliance configuration and its validation.
|
||||||
|
// The same validation runs in the unprivileged core and again in the root helper.
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/netip"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Network is everything the root helper needs to render AP, DHCP/DNS and firewall state.
|
||||||
|
type Network struct {
|
||||||
|
Management Management `json:"management" yaml:"management"`
|
||||||
|
Uplink string `json:"uplink" yaml:"uplink"` // interface name, e.g. eth0
|
||||||
|
AP AP `json:"ap" yaml:"ap"`
|
||||||
|
LAN LAN `json:"lan" yaml:"lan"`
|
||||||
|
Forwarding Forwarding `json:"forwarding" yaml:"forwarding"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Management struct {
|
||||||
|
Interfaces []string `json:"interfaces" yaml:"interfaces"`
|
||||||
|
AllowedSubnets []string `json:"allowed_subnets" yaml:"allowed_subnets"`
|
||||||
|
Port int `json:"port" yaml:"port"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AP struct {
|
||||||
|
Enabled bool `json:"enabled" yaml:"enabled"`
|
||||||
|
Interface string `json:"interface" yaml:"interface"`
|
||||||
|
SSID string `json:"ssid" yaml:"ssid"`
|
||||||
|
Passphrase string `json:"passphrase,omitempty" yaml:"passphrase"`
|
||||||
|
Security string `json:"security" yaml:"security"` // wpa2 | wpa3
|
||||||
|
Band string `json:"band" yaml:"band"` // bg (2.4 GHz) | a (5 GHz)
|
||||||
|
Channel int `json:"channel" yaml:"channel"` // 0 = automatic
|
||||||
|
Hidden bool `json:"hidden" yaml:"hidden"`
|
||||||
|
Country string `json:"country" yaml:"country"`
|
||||||
|
ClientIsolation bool `json:"client_isolation" yaml:"client_isolation"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LAN struct {
|
||||||
|
Address string `json:"address" yaml:"address"` // gateway CIDR, e.g. 192.168.50.1/24
|
||||||
|
DHCPStart string `json:"dhcp_start" yaml:"dhcp_start"`
|
||||||
|
DHCPEnd string `json:"dhcp_end" yaml:"dhcp_end"`
|
||||||
|
LeaseMinutes int `json:"lease_minutes" yaml:"lease_minutes"`
|
||||||
|
UpstreamDNS []string `json:"upstream_dns" yaml:"upstream_dns"` // empty = system resolvers
|
||||||
|
Reservations []Reservation `json:"reservations" yaml:"reservations"`
|
||||||
|
DNSOverrides []DNSOverride `json:"dns_overrides" yaml:"dns_overrides"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Reservation struct {
|
||||||
|
MAC string `json:"mac" yaml:"mac"`
|
||||||
|
IP string `json:"ip" yaml:"ip"`
|
||||||
|
Hostname string `json:"hostname,omitempty" yaml:"hostname"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DNSOverride struct {
|
||||||
|
Name string `json:"name" yaml:"name"`
|
||||||
|
IP string `json:"ip" yaml:"ip"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Forwarding struct {
|
||||||
|
// Mode: "deny" forwards nothing; "internet" forwards to non-private destinations via the uplink.
|
||||||
|
Mode string `json:"mode" yaml:"mode"`
|
||||||
|
// AllowCIDRs are private destinations explicitly opened to AP clients.
|
||||||
|
AllowCIDRs []string `json:"allow_cidrs" yaml:"allow_cidrs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
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},
|
||||||
|
Forwarding: Forwarding{Mode: "internet"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
ifaceRe = regexp.MustCompile(`^[A-Za-z0-9_.-]{1,15}$`)
|
||||||
|
countryRe = regexp.MustCompile(`^[A-Z]{2}$`)
|
||||||
|
hostnameRe = regexp.MustCompile(`^(?i)[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// PrivateRanges are never reachable from AP clients unless explicitly allowed.
|
||||||
|
var PrivateRanges = []string{"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "169.254.0.0/16", "100.64.0.0/10", "0.0.0.0/8", "224.0.0.0/4", "240.0.0.0/4"}
|
||||||
|
|
||||||
|
func ValidInterface(s string) bool { return ifaceRe.MatchString(s) && s != "." && s != ".." }
|
||||||
|
|
||||||
|
// ValidSSID: 1-32 printable ASCII bytes, no backslash/semicolon, no edge spaces (keeps NM keyfile unambiguous).
|
||||||
|
func ValidSSID(s string) error {
|
||||||
|
if len(s) < 1 || len(s) > 32 {
|
||||||
|
return errors.New("SSID must be 1-32 characters")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(s) != s {
|
||||||
|
return errors.New("SSID must not start or end with a space")
|
||||||
|
}
|
||||||
|
for _, r := range s {
|
||||||
|
if r < 0x20 || r > 0x7e || r == '\\' || r == ';' {
|
||||||
|
return errors.New("SSID may contain printable ASCII except \\ and ;")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidPassphrase(s string) error {
|
||||||
|
if len(s) < 8 || len(s) > 63 {
|
||||||
|
return errors.New("passphrase must be 8-63 characters")
|
||||||
|
}
|
||||||
|
for _, r := range s {
|
||||||
|
if r < 0x20 || r > 0x7e {
|
||||||
|
return errors.New("passphrase must be printable ASCII")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidChannel(band string, ch int) error {
|
||||||
|
if ch == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch band {
|
||||||
|
case "bg":
|
||||||
|
if ch >= 1 && ch <= 13 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
case "a":
|
||||||
|
for _, c := range []int{36, 40, 44, 48, 52, 56, 60, 64, 100, 104, 108, 112, 116, 120, 124, 128, 132, 136, 140, 144, 149, 153, 157, 161, 165} {
|
||||||
|
if c == ch {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("channel %d is not valid for band %q", ch, band)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate checks n for internal consistency. requirePassphrase is false when the
|
||||||
|
// caller keeps the stored passphrase.
|
||||||
|
func (n *Network) Validate(requirePassphrase bool) error {
|
||||||
|
var errs []error
|
||||||
|
add := func(f string, a ...any) { errs = append(errs, fmt.Errorf(f, a...)) }
|
||||||
|
|
||||||
|
if n.Management.Port < 1 || n.Management.Port > 65535 {
|
||||||
|
add("management.port out of range")
|
||||||
|
}
|
||||||
|
if len(n.Management.Interfaces) == 0 {
|
||||||
|
add("at least one management interface is required")
|
||||||
|
}
|
||||||
|
for _, i := range n.Management.Interfaces {
|
||||||
|
if !ValidInterface(i) {
|
||||||
|
add("invalid management interface %q", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(n.Management.AllowedSubnets) == 0 {
|
||||||
|
add("at least one management source subnet is required")
|
||||||
|
}
|
||||||
|
var mgmtNets []netip.Prefix
|
||||||
|
for _, s := range n.Management.AllowedSubnets {
|
||||||
|
p, err := netip.ParsePrefix(s)
|
||||||
|
if err != nil {
|
||||||
|
add("invalid management subnet %q", s)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
mgmtNets = append(mgmtNets, p.Masked())
|
||||||
|
}
|
||||||
|
if !ValidInterface(n.Uplink) {
|
||||||
|
add("invalid uplink interface %q", n.Uplink)
|
||||||
|
}
|
||||||
|
|
||||||
|
a := n.AP
|
||||||
|
if !ValidInterface(a.Interface) {
|
||||||
|
add("invalid AP interface %q", a.Interface)
|
||||||
|
}
|
||||||
|
if a.Interface == n.Uplink {
|
||||||
|
add("AP and uplink must be different interfaces")
|
||||||
|
}
|
||||||
|
for _, m := range n.Management.Interfaces {
|
||||||
|
if m == a.Interface {
|
||||||
|
add("the AP interface cannot be a management interface")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := ValidSSID(a.SSID); err != nil {
|
||||||
|
add("%v", err)
|
||||||
|
}
|
||||||
|
if requirePassphrase || a.Passphrase != "" {
|
||||||
|
if err := ValidPassphrase(a.Passphrase); err != nil {
|
||||||
|
add("%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if a.Security != "wpa2" && a.Security != "wpa3" {
|
||||||
|
add("security must be wpa2 or wpa3")
|
||||||
|
}
|
||||||
|
if a.Band != "bg" && a.Band != "a" {
|
||||||
|
add("band must be bg (2.4 GHz) or a (5 GHz)")
|
||||||
|
}
|
||||||
|
if err := ValidChannel(a.Band, a.Channel); err != nil {
|
||||||
|
add("%v", err)
|
||||||
|
}
|
||||||
|
if !countryRe.MatchString(a.Country) {
|
||||||
|
add("country must be a two-letter ISO 3166 code")
|
||||||
|
}
|
||||||
|
|
||||||
|
l := n.LAN
|
||||||
|
gw, err := netip.ParsePrefix(l.Address)
|
||||||
|
if err != nil || !gw.Addr().Is4() || gw.Bits() < 16 || gw.Bits() > 30 {
|
||||||
|
add("lan.address must be an IPv4 CIDR between /16 and /30, e.g. 192.168.50.1/24")
|
||||||
|
} else {
|
||||||
|
subnet := gw.Masked()
|
||||||
|
if gw.Addr() == subnet.Addr() || gw.Addr() == broadcast(subnet) {
|
||||||
|
add("gateway address cannot be the network or broadcast address")
|
||||||
|
}
|
||||||
|
for _, m := range mgmtNets {
|
||||||
|
if m.Overlaps(subnet) {
|
||||||
|
add("AP subnet %s overlaps management subnet %s", subnet, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
start, e1 := netip.ParseAddr(l.DHCPStart)
|
||||||
|
end, e2 := netip.ParseAddr(l.DHCPEnd)
|
||||||
|
switch {
|
||||||
|
case e1 != nil || e2 != nil:
|
||||||
|
add("invalid DHCP range")
|
||||||
|
case !subnet.Contains(start) || !subnet.Contains(end) || end.Less(start):
|
||||||
|
add("DHCP range must be inside %s and start <= end", subnet)
|
||||||
|
case !gw.Addr().Less(start) && !end.Less(gw.Addr()):
|
||||||
|
add("DHCP range must not include the gateway address")
|
||||||
|
case start == subnet.Addr() || end == broadcast(subnet):
|
||||||
|
add("DHCP range must not include network or broadcast addresses")
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, r := range l.Reservations {
|
||||||
|
hw, err := net.ParseMAC(r.MAC)
|
||||||
|
ip, err2 := netip.ParseAddr(r.IP)
|
||||||
|
if err != nil || len(hw) != 6 {
|
||||||
|
add("invalid reservation MAC %q", r.MAC)
|
||||||
|
} else if seen[hw.String()] {
|
||||||
|
add("duplicate reservation for %s", hw)
|
||||||
|
} else {
|
||||||
|
seen[hw.String()] = true
|
||||||
|
}
|
||||||
|
if err2 != nil || !subnet.Contains(ip) || ip == gw.Addr() {
|
||||||
|
add("reservation IP %q must be inside %s and not the gateway", r.IP, subnet)
|
||||||
|
}
|
||||||
|
if r.Hostname != "" && !hostnameRe.MatchString(r.Hostname) {
|
||||||
|
add("invalid reservation hostname %q", r.Hostname)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if l.LeaseMinutes < 2 || l.LeaseMinutes > 60*24*30 {
|
||||||
|
add("lease duration must be 2 minutes to 30 days")
|
||||||
|
}
|
||||||
|
for _, s := range l.UpstreamDNS {
|
||||||
|
if _, err := netip.ParseAddr(s); err != nil {
|
||||||
|
add("invalid upstream DNS server %q", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, o := range l.DNSOverrides {
|
||||||
|
if !hostnameRe.MatchString(o.Name) || len(o.Name) > 253 {
|
||||||
|
add("invalid DNS override name %q", o.Name)
|
||||||
|
}
|
||||||
|
if _, err := netip.ParseAddr(o.IP); err != nil {
|
||||||
|
add("invalid DNS override address %q", o.IP)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if n.Forwarding.Mode != "deny" && n.Forwarding.Mode != "internet" {
|
||||||
|
add("forwarding.mode must be deny or internet")
|
||||||
|
}
|
||||||
|
for _, c := range n.Forwarding.AllowCIDRs {
|
||||||
|
if p, err := netip.ParsePrefix(c); err != nil || !p.Addr().Is4() {
|
||||||
|
add("invalid IPv4 allow CIDR %q", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errors.Join(errs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func broadcast(p netip.Prefix) netip.Addr {
|
||||||
|
a := p.Masked().Addr().As4()
|
||||||
|
host := uint32(1)<<(32-p.Bits()) - 1
|
||||||
|
v := uint32(a[0])<<24 | uint32(a[1])<<16 | uint32(a[2])<<8 | uint32(a[3]) | host
|
||||||
|
return netip.AddrFrom4([4]byte{byte(v >> 24), byte(v >> 16), byte(v >> 8), byte(v)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subnet returns the AP subnet (masked) for a validated config.
|
||||||
|
func (l LAN) Subnet() netip.Prefix { return netip.MustParsePrefix(l.Address).Masked() }
|
||||||
|
|
||||||
|
// Gateway returns the AP gateway address for a validated config.
|
||||||
|
func (l LAN) Gateway() netip.Addr { return netip.MustParsePrefix(l.Address).Addr() }
|
||||||
|
|
||||||
|
// Redacted returns a copy safe to send to the browser or logs.
|
||||||
|
func (n Network) Redacted() Network {
|
||||||
|
n.AP.Passphrase = ""
|
||||||
|
return n
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
// Package helper defines the request protocol between the unprivileged core and
|
||||||
|
// the root pilens-helper. Every operation is fixed and typed; there is no generic
|
||||||
|
// command execution.
|
||||||
|
package helper
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"pilens.dev/pilens/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
const DefaultSocket = "/run/pilens/helper.sock"
|
||||||
|
|
||||||
|
type Request struct {
|
||||||
|
Op string `json:"op"`
|
||||||
|
Network *config.Network `json:"network,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Response struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
Data json.RawMessage `json:"data,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Operation names.
|
||||||
|
const (
|
||||||
|
OpApplyNetwork = "apply_network"
|
||||||
|
OpStatus = "status"
|
||||||
|
OpStations = "stations"
|
||||||
|
OpLeases = "leases"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ApplyResult struct {
|
||||||
|
Applied bool `json:"applied"`
|
||||||
|
RolledBack bool `json:"rolled_back"`
|
||||||
|
Steps []string `json:"steps"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Status struct {
|
||||||
|
TableLoaded bool `json:"table_loaded"`
|
||||||
|
APState string `json:"ap_state"` // NM GENERAL.STATE or "absent"
|
||||||
|
DnsmasqActive bool `json:"dnsmasq_active"`
|
||||||
|
IPForward bool `json:"ip_forward"`
|
||||||
|
Regdomain string `json:"regdomain"`
|
||||||
|
Configured bool `json:"configured"`
|
||||||
|
LastError string `json:"last_error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Lease struct {
|
||||||
|
Expiry int64 `json:"expiry"` // unix seconds; 0 = infinite
|
||||||
|
MAC string `json:"mac"`
|
||||||
|
IP string `json:"ip"`
|
||||||
|
Hostname string `json:"hostname"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client talks to the helper socket.
|
||||||
|
type Client struct{ Socket string }
|
||||||
|
|
||||||
|
func (c Client) Call(ctx context.Context, req Request, out any) error {
|
||||||
|
d := net.Dialer{}
|
||||||
|
conn, err := d.DialContext(ctx, "unix", c.Socket)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("network helper unavailable: " + err.Error())
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
dl, ok := ctx.Deadline()
|
||||||
|
if !ok {
|
||||||
|
dl = time.Now().Add(2 * time.Minute)
|
||||||
|
}
|
||||||
|
conn.SetDeadline(dl)
|
||||||
|
if err := json.NewEncoder(conn).Encode(req); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var resp Response
|
||||||
|
if err := json.NewDecoder(conn).Decode(&resp); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if out != nil && len(resp.Data) > 0 {
|
||||||
|
if err := json.Unmarshal(resp.Data, out); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !resp.OK {
|
||||||
|
return errors.New(resp.Error)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package helper
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ParseStationDump parses `iw dev X station dump` output.
|
||||||
|
func ParseStationDump(s string) []Station {
|
||||||
|
var out []Station
|
||||||
|
var cur *Station
|
||||||
|
sc := bufio.NewScanner(strings.NewReader(s))
|
||||||
|
for sc.Scan() {
|
||||||
|
line := sc.Text()
|
||||||
|
if strings.HasPrefix(line, "Station ") {
|
||||||
|
f := strings.Fields(line)
|
||||||
|
if len(f) >= 2 {
|
||||||
|
out = append(out, Station{MAC: strings.ToLower(f[1])})
|
||||||
|
cur = &out[len(out)-1]
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if cur == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
k, v, ok := strings.Cut(strings.TrimSpace(line), ":")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
f := strings.Fields(v)
|
||||||
|
if len(f) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
n, err := strconv.ParseInt(f[0], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch k {
|
||||||
|
case "inactive time":
|
||||||
|
cur.InactiveMS = n
|
||||||
|
case "rx bytes":
|
||||||
|
cur.RxBytes = uint64(n)
|
||||||
|
case "tx bytes":
|
||||||
|
cur.TxBytes = uint64(n)
|
||||||
|
case "signal":
|
||||||
|
v := int(n)
|
||||||
|
cur.SignalDBm = &v
|
||||||
|
case "connected time":
|
||||||
|
cur.ConnectedS = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseLeases parses a dnsmasq lease file: "expiry mac ip hostname clientid".
|
||||||
|
func ParseLeases(s string) []Lease {
|
||||||
|
var out []Lease
|
||||||
|
for _, line := range strings.Split(s, "\n") {
|
||||||
|
f := strings.Fields(line)
|
||||||
|
if len(f) < 4 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
exp, err := strconv.ParseInt(f[0], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
h := f[3]
|
||||||
|
if h == "*" {
|
||||||
|
h = ""
|
||||||
|
}
|
||||||
|
out = append(out, Lease{Expiry: exp, MAC: strings.ToLower(f[1]), IP: f[2], Hostname: h})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package helper
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestParseStationDump(t *testing.T) {
|
||||||
|
s := ParseStationDump(`Station AA:bb:cc:dd:ee:ff (on wlan0)
|
||||||
|
inactive time: 1200 ms
|
||||||
|
rx bytes: 5000
|
||||||
|
tx bytes: 7000
|
||||||
|
signal: -47 [-47, -49] dBm
|
||||||
|
connected time: 33 seconds
|
||||||
|
Station 11:22:33:44:55:66 (on wlan0)
|
||||||
|
rx bytes: 1
|
||||||
|
`)
|
||||||
|
if len(s) != 2 || s[0].MAC != "aa:bb:cc:dd:ee:ff" || *s[0].SignalDBm != -47 || s[0].RxBytes != 5000 || s[0].ConnectedS != 33 || s[1].SignalDBm != nil {
|
||||||
|
t.Fatalf("%+v", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseLeases(t *testing.T) {
|
||||||
|
l := ParseLeases("1700000000 AA:BB:CC:DD:EE:FF 192.168.50.101 phone 01:aa\n1700000001 11:22:33:44:55:66 192.168.50.102 * *\ngarbage\n")
|
||||||
|
if len(l) != 2 || l[0].Hostname != "phone" || l[1].Hostname != "" || l[0].MAC != "aa:bb:cc:dd:ee:ff" {
|
||||||
|
t.Fatalf("%+v", l)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
// Package netgen renders NetworkManager, dnsmasq and nftables configuration from
|
||||||
|
// a validated config.Network. Pure functions: no I/O, fully unit-tested.
|
||||||
|
package netgen
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"pilens.dev/pilens/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
ConnName = "pilens-ap"
|
||||||
|
ConnUUID = "6f1c3a2e-9d4b-4e57-8a51-0c7e1b2d9f30" // stable so reloads replace, not duplicate
|
||||||
|
TableName = "pilens"
|
||||||
|
)
|
||||||
|
|
||||||
|
// keyfileEscape escapes a GKeyFile string value (NM keyfile format).
|
||||||
|
func keyfileEscape(s string) string {
|
||||||
|
s = strings.ReplaceAll(s, `\`, `\\`)
|
||||||
|
if strings.HasPrefix(s, " ") {
|
||||||
|
s = `\s` + s[1:]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// NMKeyfile renders /etc/NetworkManager/system-connections/pilens-ap.nmconnection.
|
||||||
|
func NMKeyfile(n config.Network) string {
|
||||||
|
a := n.AP
|
||||||
|
var b strings.Builder
|
||||||
|
w := func(f string, v ...any) { fmt.Fprintf(&b, f+"\n", v...) }
|
||||||
|
w("# Managed by PiLens. Changes are overwritten; use the PiLens UI.")
|
||||||
|
w("[connection]")
|
||||||
|
w("id=%s", ConnName)
|
||||||
|
w("uuid=%s", ConnUUID)
|
||||||
|
w("type=wifi")
|
||||||
|
w("interface-name=%s", a.Interface)
|
||||||
|
w("autoconnect=%t", a.Enabled)
|
||||||
|
w("autoconnect-priority=100")
|
||||||
|
w("")
|
||||||
|
w("[wifi]")
|
||||||
|
w("mode=ap")
|
||||||
|
w("ssid=%s", keyfileEscape(a.SSID))
|
||||||
|
w("band=%s", a.Band)
|
||||||
|
if a.Channel != 0 {
|
||||||
|
w("channel=%d", a.Channel)
|
||||||
|
}
|
||||||
|
w("hidden=%t", a.Hidden)
|
||||||
|
if a.ClientIsolation {
|
||||||
|
w("ap-isolation=1")
|
||||||
|
} else {
|
||||||
|
w("ap-isolation=0")
|
||||||
|
}
|
||||||
|
w("")
|
||||||
|
w("[wifi-security]")
|
||||||
|
if a.Security == "wpa3" {
|
||||||
|
w("key-mgmt=sae")
|
||||||
|
w("pmf=3") // required for SAE
|
||||||
|
} else {
|
||||||
|
w("key-mgmt=wpa-psk")
|
||||||
|
w("pmf=1") // disabled: broadest client compatibility for WPA2-only
|
||||||
|
}
|
||||||
|
w("proto=rsn")
|
||||||
|
w("pairwise=ccmp")
|
||||||
|
w("group=ccmp")
|
||||||
|
w("psk=%s", keyfileEscape(a.Passphrase))
|
||||||
|
w("")
|
||||||
|
w("[ipv4]")
|
||||||
|
w("method=manual")
|
||||||
|
w("address1=%s", n.LAN.Address)
|
||||||
|
w("never-default=true")
|
||||||
|
w("")
|
||||||
|
w("[ipv6]")
|
||||||
|
w("method=disabled")
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paths used by the rendered dnsmasq config.
|
||||||
|
const (
|
||||||
|
LeaseFile = "/var/lib/pilens-helper/dnsmasq.leases"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DnsmasqConf renders the dedicated dnsmasq instance for the AP interface.
|
||||||
|
func DnsmasqConf(n config.Network) string {
|
||||||
|
l := n.LAN
|
||||||
|
sub := l.Subnet()
|
||||||
|
mask := net.CIDRMask(sub.Bits(), 32)
|
||||||
|
gw := l.Gateway().String()
|
||||||
|
var b strings.Builder
|
||||||
|
w := func(f string, v ...any) { fmt.Fprintf(&b, f+"\n", v...) }
|
||||||
|
w("# Managed by PiLens. Changes are overwritten; use the PiLens UI.")
|
||||||
|
w("interface=%s", n.AP.Interface)
|
||||||
|
w("bind-dynamic") // only the AP interface; survives the address appearing late
|
||||||
|
w("no-hosts")
|
||||||
|
w("domain-needed")
|
||||||
|
w("bogus-priv")
|
||||||
|
w("cache-size=1000")
|
||||||
|
if len(l.UpstreamDNS) == 0 {
|
||||||
|
w("resolv-file=/etc/resolv.conf")
|
||||||
|
} else {
|
||||||
|
w("no-resolv")
|
||||||
|
for _, s := range l.UpstreamDNS {
|
||||||
|
w("server=%s", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w("dhcp-authoritative")
|
||||||
|
w("dhcp-range=%s,%s,%s,%dm", l.DHCPStart, l.DHCPEnd, net.IP(mask).String(), l.LeaseMinutes)
|
||||||
|
w("dhcp-option=option:router,%s", gw)
|
||||||
|
w("dhcp-option=option:dns-server,%s", gw)
|
||||||
|
w("dhcp-leasefile=%s", LeaseFile)
|
||||||
|
for _, r := range l.Reservations {
|
||||||
|
hw, _ := net.ParseMAC(r.MAC)
|
||||||
|
if r.Hostname != "" {
|
||||||
|
w("dhcp-host=%s,%s,%s", hw, r.IP, r.Hostname)
|
||||||
|
} else {
|
||||||
|
w("dhcp-host=%s,%s", hw, r.IP)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, o := range l.DNSOverrides {
|
||||||
|
w("host-record=%s,%s", strings.ToLower(o.Name), o.IP) // exact name only, unlike address=/x/
|
||||||
|
}
|
||||||
|
w("log-queries=extra")
|
||||||
|
w("log-dhcp")
|
||||||
|
w("log-facility=-")
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func q(s string) string { return `"` + s + `"` } // inputs are validated interface names
|
||||||
|
|
||||||
|
// Nftables renders the complete inet pilens table. The output begins by
|
||||||
|
// (re)creating and deleting the table so `nft -f` replaces it atomically while
|
||||||
|
// leaving every other table untouched.
|
||||||
|
func Nftables(n config.Network) string {
|
||||||
|
ap, up := q(n.AP.Interface), q(n.Uplink)
|
||||||
|
sub := n.LAN.Subnet().String()
|
||||||
|
var mgmtIf []string
|
||||||
|
for _, i := range n.Management.Interfaces {
|
||||||
|
mgmtIf = append(mgmtIf, q(i))
|
||||||
|
}
|
||||||
|
var v4, v6 []string
|
||||||
|
for _, s := range n.Management.AllowedSubnets {
|
||||||
|
if strings.Contains(s, ":") {
|
||||||
|
v6 = append(v6, s)
|
||||||
|
} else {
|
||||||
|
v4 = append(v4, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
blocked := append(append([]string{}, config.PrivateRanges...), v4...)
|
||||||
|
blocked = append(blocked, "255.255.255.255/32")
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
w := func(f string, v ...any) { fmt.Fprintf(&b, f+"\n", v...) }
|
||||||
|
w("# Managed by PiLens. Only this table is ever created or deleted by PiLens.")
|
||||||
|
w("table inet %s", TableName)
|
||||||
|
w("delete table inet %s", TableName)
|
||||||
|
w("table inet %s {", TableName)
|
||||||
|
set := func(name, typ string, elems []string) {
|
||||||
|
w("\tset %s {", name)
|
||||||
|
w("\t\ttype %s; flags interval; auto-merge", typ)
|
||||||
|
if len(elems) > 0 {
|
||||||
|
w("\t\telements = { %s }", strings.Join(elems, ", "))
|
||||||
|
}
|
||||||
|
w("\t}")
|
||||||
|
}
|
||||||
|
set("mgmt_v4", "ipv4_addr", v4)
|
||||||
|
set("mgmt_v6", "ipv6_addr", v6)
|
||||||
|
set("blocked_v4", "ipv4_addr", blocked)
|
||||||
|
set("allow_v4", "ipv4_addr", n.Forwarding.AllowCIDRs)
|
||||||
|
|
||||||
|
// Input: decide everything from the AP side; restrict only the management port elsewhere.
|
||||||
|
w("\tchain input {")
|
||||||
|
w("\t\ttype filter hook input priority filter - 10; policy accept;")
|
||||||
|
w("\t\tiifname \"lo\" accept")
|
||||||
|
w("\t\tiifname %s jump ap_input", ap)
|
||||||
|
w("\t\ttcp dport %d iifname { %s } ip saddr @mgmt_v4 accept", n.Management.Port, strings.Join(mgmtIf, ", "))
|
||||||
|
w("\t\ttcp dport %d iifname { %s } ip6 saddr @mgmt_v6 accept", n.Management.Port, strings.Join(mgmtIf, ", "))
|
||||||
|
w("\t\ttcp dport %d counter drop comment \"management not allowed from here\"", n.Management.Port)
|
||||||
|
w("\t}")
|
||||||
|
w("\tchain ap_input {")
|
||||||
|
w("\t\tmeta nfproto ipv6 counter drop")
|
||||||
|
w("\t\tct state established,related accept")
|
||||||
|
w("\t\tct state invalid drop")
|
||||||
|
w("\t\tudp dport 67 accept")
|
||||||
|
w("\t\tip daddr %s udp dport 53 accept", n.LAN.Gateway())
|
||||||
|
w("\t\tip daddr %s tcp dport 53 accept", n.LAN.Gateway())
|
||||||
|
w("\t\ticmp type { echo-request, destination-unreachable, time-exceeded } accept")
|
||||||
|
w("\t\tcounter drop comment \"AP clients cannot reach the appliance\"")
|
||||||
|
w("\t}")
|
||||||
|
|
||||||
|
w("\tchain forward {")
|
||||||
|
w("\t\ttype filter hook forward priority filter - 10; policy accept;")
|
||||||
|
w("\t\tiifname %s jump ap_forward", ap)
|
||||||
|
w("\t\toifname %s jump ap_return", ap)
|
||||||
|
w("\t}")
|
||||||
|
w("\tchain ap_forward {")
|
||||||
|
w("\t\tmeta nfproto ipv6 counter drop comment \"IPv6 forwarding not implemented: blocked\"")
|
||||||
|
w("\t\tct state invalid counter drop")
|
||||||
|
w("\t\tip saddr != %s counter drop comment \"spoofed source\"", sub)
|
||||||
|
w("\t\toifname != %s counter drop comment \"only the uplink\"", up)
|
||||||
|
if n.Forwarding.Mode != "internet" {
|
||||||
|
w("\t\tcounter drop comment \"forwarding disabled\"")
|
||||||
|
} else {
|
||||||
|
w("\t\tip daddr @allow_v4 ct state { new, established, related } accept")
|
||||||
|
w("\t\tip daddr @blocked_v4 counter drop comment \"private/management destinations\"")
|
||||||
|
w("\t\tct state { new, established, related } accept")
|
||||||
|
}
|
||||||
|
w("\t\tcounter drop")
|
||||||
|
w("\t}")
|
||||||
|
w("\tchain ap_return {")
|
||||||
|
w("\t\tiifname %s ct state established,related accept", up)
|
||||||
|
w("\t\tcounter drop")
|
||||||
|
w("\t}")
|
||||||
|
|
||||||
|
w("\tchain postrouting {")
|
||||||
|
w("\t\ttype nat hook postrouting priority srcnat; policy accept;")
|
||||||
|
w("\t\tip saddr %s oifname %s masquerade", sub, up)
|
||||||
|
w("\t}")
|
||||||
|
w("}")
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// DenyAll renders the fail-closed table used before any configuration exists:
|
||||||
|
// AP-side traffic is dropped entirely, nothing else is touched.
|
||||||
|
func DenyAll(apIface string) string {
|
||||||
|
ap := q(apIface)
|
||||||
|
return fmt.Sprintf(`# Managed by PiLens: fail-closed placeholder.
|
||||||
|
table inet %[1]s
|
||||||
|
delete table inet %[1]s
|
||||||
|
table inet %[1]s {
|
||||||
|
chain input {
|
||||||
|
type filter hook input priority filter - 10; policy accept;
|
||||||
|
iifname %[2]s counter drop
|
||||||
|
}
|
||||||
|
chain forward {
|
||||||
|
type filter hook forward priority filter - 10; policy accept;
|
||||||
|
iifname %[2]s counter drop
|
||||||
|
oifname %[2]s counter drop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`, TableName, ap)
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package netgen
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"pilens.dev/pilens/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testNet() config.Network {
|
||||||
|
n := config.DefaultNetwork()
|
||||||
|
n.Management.Interfaces = []string{"eth0"}
|
||||||
|
n.Management.AllowedSubnets = []string{"10.1.0.0/24"}
|
||||||
|
n.Uplink = "eth0"
|
||||||
|
n.AP.Interface = "wlan0"
|
||||||
|
n.AP.Passphrase = `pass\word 123`
|
||||||
|
n.LAN.Reservations = []config.Reservation{{MAC: "AA:BB:CC:00:11:22", IP: "192.168.50.20", Hostname: "phone"}}
|
||||||
|
n.LAN.DNSOverrides = []config.DNSOverride{{Name: "Api.Test.Local", IP: "192.168.50.1"}}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidate(t *testing.T) {
|
||||||
|
n := testNet()
|
||||||
|
if err := n.Validate(true); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
bad := []func(*config.Network){
|
||||||
|
func(n *config.Network) { n.AP.Interface = "eth0" },
|
||||||
|
func(n *config.Network) { n.AP.SSID = "a;b" },
|
||||||
|
func(n *config.Network) { n.AP.SSID = "bad\nname" },
|
||||||
|
func(n *config.Network) { n.AP.Passphrase = "short" },
|
||||||
|
func(n *config.Network) { n.AP.Channel = 36 },
|
||||||
|
func(n *config.Network) { n.LAN.DHCPStart = "192.168.50.1" },
|
||||||
|
func(n *config.Network) { n.LAN.DHCPEnd = "192.168.51.10" },
|
||||||
|
func(n *config.Network) { n.Management.AllowedSubnets = []string{"192.168.50.0/25"} },
|
||||||
|
func(n *config.Network) { n.LAN.DNSOverrides[0].Name = "x\nserver=1.2.3.4" },
|
||||||
|
func(n *config.Network) { n.Uplink = "eth0; rm -rf" },
|
||||||
|
func(n *config.Network) { n.Forwarding.Mode = "open" },
|
||||||
|
func(n *config.Network) { n.LAN.Address = "192.168.50.0/24" },
|
||||||
|
}
|
||||||
|
for i, f := range bad {
|
||||||
|
c := testNet()
|
||||||
|
f(&c)
|
||||||
|
if c.Validate(true) == nil {
|
||||||
|
t.Errorf("case %d: expected validation error", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKeyfile(t *testing.T) {
|
||||||
|
k := NMKeyfile(testNet())
|
||||||
|
for _, want := range []string{"mode=ap", "interface-name=wlan0", `psk=pass\\word 123`, "key-mgmt=wpa-psk", "method=disabled", "address1=192.168.50.1/24", "ssid=PiLens-Lab"} {
|
||||||
|
if !strings.Contains(k, want) {
|
||||||
|
t.Errorf("keyfile missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDnsmasq(t *testing.T) {
|
||||||
|
d := DnsmasqConf(testNet())
|
||||||
|
for _, want := range []string{"interface=wlan0", "dhcp-range=192.168.50.100,192.168.50.199,255.255.255.0,720m", "dhcp-host=aa:bb:cc:00:11:22,192.168.50.20,phone", "host-record=api.test.local,192.168.50.1", "option:router,192.168.50.1"} {
|
||||||
|
if !strings.Contains(d, want) {
|
||||||
|
t.Errorf("dnsmasq missing %q\n%s", want, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNftables(t *testing.T) {
|
||||||
|
s := Nftables(testNet())
|
||||||
|
for _, want := range []string{
|
||||||
|
"delete table inet pilens",
|
||||||
|
`iifname "wlan0" jump ap_forward`,
|
||||||
|
"meta nfproto ipv6 counter drop",
|
||||||
|
`ip saddr 192.168.50.0/24 oifname "eth0" masquerade`,
|
||||||
|
"10.1.0.0/24",
|
||||||
|
`tcp dport 8443 counter drop`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(s, want) {
|
||||||
|
t.Errorf("ruleset missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(s, "flush ruleset") {
|
||||||
|
t.Error("must never flush the whole ruleset")
|
||||||
|
}
|
||||||
|
n := testNet()
|
||||||
|
n.Forwarding.Mode = "deny"
|
||||||
|
if !strings.Contains(Nftables(n), `"forwarding disabled"`) {
|
||||||
|
t.Error("deny mode must drop forwarded traffic")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,336 @@
|
|||||||
|
// Package pcapfile reads and copies pcap/pcapng files at the block/record level.
|
||||||
|
// It never re-encodes packet data: blocks are copied byte-for-byte.
|
||||||
|
// All input is treated as hostile: every length is bounds-checked.
|
||||||
|
package pcapfile
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/binary"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
BlockSHB = 0x0A0D0D0A
|
||||||
|
BlockIDB = 1
|
||||||
|
BlockPB = 2 // obsolete Packet Block
|
||||||
|
BlockSPB = 3
|
||||||
|
BlockEPB = 6
|
||||||
|
BlockISB = 5
|
||||||
|
|
||||||
|
MaxBlock = 16 << 20 // refuse blocks larger than this
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrFormat = errors.New("not a pcap or pcapng file")
|
||||||
|
|
||||||
|
// Sniff identifies the format from the first 4 bytes.
|
||||||
|
func Sniff(b []byte) (string, error) {
|
||||||
|
if len(b) < 4 {
|
||||||
|
return "", ErrFormat
|
||||||
|
}
|
||||||
|
switch binary.LittleEndian.Uint32(b) {
|
||||||
|
case BlockSHB:
|
||||||
|
return "pcapng", nil
|
||||||
|
case 0xa1b2c3d4, 0xd4c3b2a1, 0xa1b23c4d, 0x4d3cb2a1:
|
||||||
|
return "pcap", nil
|
||||||
|
}
|
||||||
|
return "", ErrFormat
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block is one raw pcapng block.
|
||||||
|
type Block struct {
|
||||||
|
Type uint32
|
||||||
|
Raw []byte // the complete block, verbatim
|
||||||
|
Order binary.ByteOrder
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b Block) IsPacket() bool {
|
||||||
|
return b.Type == BlockEPB || b.Type == BlockSPB || b.Type == BlockPB
|
||||||
|
}
|
||||||
|
|
||||||
|
// BlockReader reads pcapng blocks from a stream, tracking section byte order.
|
||||||
|
type BlockReader struct {
|
||||||
|
r io.Reader
|
||||||
|
order binary.ByteOrder
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewBlockReader(r io.Reader) *BlockReader { return &BlockReader{r: r} }
|
||||||
|
|
||||||
|
func (br *BlockReader) Next() (Block, error) {
|
||||||
|
var hdr [12]byte
|
||||||
|
if _, err := io.ReadFull(br.r, hdr[:8]); err != nil {
|
||||||
|
return Block{}, err
|
||||||
|
}
|
||||||
|
typ := binary.LittleEndian.Uint32(hdr[:4]) // SHB type is a palindrome
|
||||||
|
if typ == BlockSHB {
|
||||||
|
if _, err := io.ReadFull(br.r, hdr[8:12]); err != nil {
|
||||||
|
return Block{}, io.ErrUnexpectedEOF
|
||||||
|
}
|
||||||
|
switch binary.LittleEndian.Uint32(hdr[8:12]) {
|
||||||
|
case 0x1A2B3C4D:
|
||||||
|
br.order = binary.LittleEndian
|
||||||
|
case 0x4D3C2B1A:
|
||||||
|
br.order = binary.BigEndian
|
||||||
|
default:
|
||||||
|
return Block{}, errors.New("pcapng: bad byte-order magic")
|
||||||
|
}
|
||||||
|
} else if br.order == nil {
|
||||||
|
return Block{}, errors.New("pcapng: data before section header")
|
||||||
|
} else {
|
||||||
|
typ = br.order.Uint32(hdr[:4])
|
||||||
|
}
|
||||||
|
n := br.order.Uint32(hdr[4:8])
|
||||||
|
if n < 12 || n%4 != 0 || n > MaxBlock {
|
||||||
|
return Block{}, fmt.Errorf("pcapng: invalid block length %d", n)
|
||||||
|
}
|
||||||
|
raw := make([]byte, n)
|
||||||
|
got := 8
|
||||||
|
copy(raw, hdr[:8])
|
||||||
|
if typ == BlockSHB {
|
||||||
|
copy(raw[8:], hdr[8:12])
|
||||||
|
got = 12
|
||||||
|
}
|
||||||
|
if _, err := io.ReadFull(br.r, raw[got:]); err != nil {
|
||||||
|
return Block{}, io.ErrUnexpectedEOF
|
||||||
|
}
|
||||||
|
if br.order.Uint32(raw[n-4:]) != n {
|
||||||
|
return Block{}, errors.New("pcapng: trailing length mismatch")
|
||||||
|
}
|
||||||
|
return Block{Type: typ, Raw: raw, Order: br.order}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PacketData extracts captured bytes from a packet block.
|
||||||
|
func PacketData(b Block) ([]byte, error) {
|
||||||
|
r := b.Raw
|
||||||
|
o := b.Order
|
||||||
|
switch b.Type {
|
||||||
|
case BlockEPB, BlockPB:
|
||||||
|
if len(r) < 32 {
|
||||||
|
return nil, errors.New("short packet block")
|
||||||
|
}
|
||||||
|
cl := o.Uint32(r[20:24])
|
||||||
|
if int(cl) > len(r)-32 {
|
||||||
|
return nil, errors.New("captured length exceeds block")
|
||||||
|
}
|
||||||
|
return r[28 : 28+cl], nil
|
||||||
|
case BlockSPB:
|
||||||
|
if len(r) < 16 {
|
||||||
|
return nil, errors.New("short simple packet block")
|
||||||
|
}
|
||||||
|
cl := min(int(o.Uint32(r[8:12])), len(r)-16)
|
||||||
|
return r[12 : 12+cl], nil
|
||||||
|
}
|
||||||
|
return nil, errors.New("not a packet block")
|
||||||
|
}
|
||||||
|
|
||||||
|
// pcap (classic) --------------------------------------------------------------
|
||||||
|
|
||||||
|
type pcapHeader struct {
|
||||||
|
order binary.ByteOrder
|
||||||
|
raw [24]byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func readPcapHeader(r io.Reader) (pcapHeader, error) {
|
||||||
|
var h pcapHeader
|
||||||
|
if _, err := io.ReadFull(r, h.raw[:]); err != nil {
|
||||||
|
return h, ErrFormat
|
||||||
|
}
|
||||||
|
switch binary.LittleEndian.Uint32(h.raw[:4]) {
|
||||||
|
case 0xa1b2c3d4, 0xa1b23c4d:
|
||||||
|
h.order = binary.LittleEndian
|
||||||
|
case 0xd4c3b2a1, 0x4d3cb2a1:
|
||||||
|
h.order = binary.BigEndian
|
||||||
|
default:
|
||||||
|
return h, ErrFormat
|
||||||
|
}
|
||||||
|
return h, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readPcapRecord(r io.Reader, o binary.ByteOrder) ([]byte, error) {
|
||||||
|
var rh [16]byte
|
||||||
|
if _, err := io.ReadFull(r, rh[:]); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
n := o.Uint32(rh[8:12])
|
||||||
|
if n > MaxBlock {
|
||||||
|
return nil, fmt.Errorf("pcap: record length %d too large", n)
|
||||||
|
}
|
||||||
|
rec := make([]byte, 16+n)
|
||||||
|
copy(rec, rh[:])
|
||||||
|
if _, err := io.ReadFull(r, rec[16:]); err != nil {
|
||||||
|
return nil, io.ErrUnexpectedEOF
|
||||||
|
}
|
||||||
|
return rec, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan calls fn for each packet in a file with its 0-based index, byte offset and data.
|
||||||
|
func Scan(path, format string, fn func(idx int, off int64, data []byte) error) error {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
cr := &countingReader{r: bufio.NewReaderSize(f, 1<<16)}
|
||||||
|
idx := 0
|
||||||
|
if format == "pcap" {
|
||||||
|
h, err := readPcapHeader(cr)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
off := cr.n
|
||||||
|
rec, err := readPcapRecord(cr, h.order)
|
||||||
|
if err == io.EOF {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := fn(idx, off, rec[16:]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
idx++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
br := NewBlockReader(cr)
|
||||||
|
for {
|
||||||
|
off := cr.n
|
||||||
|
b, err := br.Next()
|
||||||
|
if err == io.EOF {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if b.IsPacket() {
|
||||||
|
d, err := PacketData(b)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := fn(idx, off, d); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
idx++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadPacketAt returns the captured bytes of the packet stored at off.
|
||||||
|
func ReadPacketAt(path, format string, off int64) ([]byte, error) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
if format == "pcap" {
|
||||||
|
h, err := readPcapHeader(f)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, err := f.Seek(off, io.SeekStart); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rec, err := readPcapRecord(f, h.order)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return rec[16:], nil
|
||||||
|
}
|
||||||
|
var hdr [8]byte
|
||||||
|
if _, err := f.ReadAt(hdr[:], off); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var o binary.ByteOrder = binary.LittleEndian
|
||||||
|
if t := binary.BigEndian.Uint32(hdr[:4]); t == BlockEPB || t == BlockSPB || t == BlockPB {
|
||||||
|
o = binary.BigEndian
|
||||||
|
}
|
||||||
|
t, n := o.Uint32(hdr[:4]), o.Uint32(hdr[4:8])
|
||||||
|
if (t != BlockEPB && t != BlockSPB && t != BlockPB) || n < 12 || n > MaxBlock {
|
||||||
|
return nil, errors.New("no packet block at offset")
|
||||||
|
}
|
||||||
|
raw := make([]byte, n)
|
||||||
|
if _, err := f.ReadAt(raw, off); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return PacketData(Block{Type: t, Raw: raw, Order: o})
|
||||||
|
}
|
||||||
|
|
||||||
|
// CopySelected writes the packets of src whose 0-based index satisfies keep to w,
|
||||||
|
// together with the section and interface headers they depend on. Packet records
|
||||||
|
// are copied verbatim. If writeHeader is false, a pcap global header is not
|
||||||
|
// repeated (used when appending further pcap segments to the same output).
|
||||||
|
func CopySelected(w io.Writer, path, format string, writeHeader bool, keep func(idx int) bool) (int, error) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
r := bufio.NewReaderSize(f, 1<<16)
|
||||||
|
n, idx := 0, 0
|
||||||
|
if format == "pcap" {
|
||||||
|
h, err := readPcapHeader(r)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if writeHeader {
|
||||||
|
if _, err := w.Write(h.raw[:]); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
rec, err := readPcapRecord(r, h.order)
|
||||||
|
if err == io.EOF {
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
if keep(idx) {
|
||||||
|
if _, err := w.Write(rec); err != nil {
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
idx++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
br := NewBlockReader(r)
|
||||||
|
for {
|
||||||
|
b, err := br.Next()
|
||||||
|
if err == io.EOF {
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case b.Type == BlockSHB, b.Type == BlockIDB:
|
||||||
|
// Always emitted, even when appending: repeated sections are valid
|
||||||
|
// pcapng and keep each segment's interface IDs resolvable.
|
||||||
|
if _, err := w.Write(b.Raw); err != nil {
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
case b.IsPacket():
|
||||||
|
if keep(idx) {
|
||||||
|
if _, err := w.Write(b.Raw); err != nil {
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
idx++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type countingReader struct {
|
||||||
|
r io.Reader
|
||||||
|
n int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *countingReader) Read(p []byte) (int, error) {
|
||||||
|
n, err := c.r.Read(p)
|
||||||
|
c.n += int64(n)
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package pcapfile
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
const fixture = "../../tests/fixtures/sample-loopback-http-dns.pcapng"
|
||||||
|
|
||||||
|
func TestScanAndCopy(t *testing.T) {
|
||||||
|
var offs []int64
|
||||||
|
var first []byte
|
||||||
|
if err := Scan(fixture, "pcapng", func(i int, off int64, d []byte) error {
|
||||||
|
offs = append(offs, off)
|
||||||
|
if i == 0 {
|
||||||
|
first = append([]byte(nil), d...)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}); err != nil || len(offs) < 20 {
|
||||||
|
t.Fatalf("scan: %v %d", err, len(offs))
|
||||||
|
}
|
||||||
|
d, err := ReadPacketAt(fixture, "pcapng", offs[0])
|
||||||
|
if err != nil || !bytes.Equal(d, first) {
|
||||||
|
t.Fatalf("ReadPacketAt: %v", err)
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
n, err := CopySelected(&buf, fixture, "pcapng", true, func(i int) bool { return i%2 == 0 })
|
||||||
|
if err != nil || n != (len(offs)+1)/2 {
|
||||||
|
t.Fatalf("copy: %d %v", n, err)
|
||||||
|
}
|
||||||
|
p := filepath.Join(t.TempDir(), "o.pcapng")
|
||||||
|
os.WriteFile(p, buf.Bytes(), 0o600)
|
||||||
|
c := 0
|
||||||
|
if err := Scan(p, "pcapng", func(int, int64, []byte) error { c++; return nil }); err != nil || c != n {
|
||||||
|
t.Fatalf("rescan %d %v", c, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMalformed(t *testing.T) {
|
||||||
|
good, _ := os.ReadFile(fixture)
|
||||||
|
p := filepath.Join(t.TempDir(), "bad.pcapng")
|
||||||
|
// truncated, and a block with an absurd length
|
||||||
|
for _, b := range [][]byte{good[:len(good)-7], append(append([]byte{}, good[:28]...), 6, 0, 0, 0, 0xff, 0xff, 0xff, 0x7f)} {
|
||||||
|
os.WriteFile(p, b, 0o600)
|
||||||
|
if err := Scan(p, "pcapng", func(int, int64, []byte) error { return nil }); err == nil {
|
||||||
|
t.Fatal("malformed file accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := Sniff([]byte("PK\x03\x04")); err == nil {
|
||||||
|
t.Fatal("zip accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
// Package store opens the SQLite database and applies migrations.
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
|
||||||
|
"pilens.dev/pilens/migrations"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Store struct{ *sql.DB }
|
||||||
|
|
||||||
|
func Open(path string) (*Store, error) {
|
||||||
|
dsn := "file:" + path + "?_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)&_pragma=synchronous(NORMAL)"
|
||||||
|
db, err := sql.Open("sqlite", dsn)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
db.SetMaxOpenConns(4)
|
||||||
|
s := &Store{db}
|
||||||
|
if err := s.migrate(); err != nil {
|
||||||
|
db.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) migrate() error {
|
||||||
|
if _, err := s.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (name TEXT PRIMARY KEY, applied_at INTEGER NOT NULL)`); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
names, err := fs.Glob(migrations.FS, "*.sql")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
for _, n := range names {
|
||||||
|
var x int
|
||||||
|
if s.QueryRow(`SELECT 1 FROM schema_migrations WHERE name=?`, n).Scan(&x) == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b, _ := migrations.FS.ReadFile(n)
|
||||||
|
tx, err := s.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(string(b)); err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return fmt.Errorf("migration %s: %w", n, err)
|
||||||
|
}
|
||||||
|
tx.Exec(`INSERT INTO schema_migrations VALUES (?,?)`, n, time.Now().Unix())
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetJSON(key string, v any) (bool, error) {
|
||||||
|
var raw string
|
||||||
|
err := s.QueryRow(`SELECT value FROM settings WHERE key=?`, key).Scan(&raw)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return true, json.Unmarshal([]byte(raw), v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) PutJSON(key string, v any) error {
|
||||||
|
b, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = s.Exec(`INSERT INTO settings(key,value,updated_at) VALUES(?,?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at`, key, string(b), time.Now().Unix())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) Audit(actor, action, detail, remote string) {
|
||||||
|
s.Exec(`INSERT INTO audit(ts,actor,action,detail,remote) VALUES(?,?,?,?,?)`, time.Now().UnixMilli(), actor, action, detail, remote)
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
CREATE TABLE settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE users (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
totp_secret TEXT NOT NULL DEFAULT '',
|
||||||
|
totp_enabled INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE sessions (
|
||||||
|
token_hash TEXT PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
csrf TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
last_seen INTEGER NOT NULL,
|
||||||
|
expires_at INTEGER NOT NULL,
|
||||||
|
remote TEXT NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE audit (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
ts INTEGER NOT NULL,
|
||||||
|
actor TEXT NOT NULL,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
detail TEXT NOT NULL DEFAULT '',
|
||||||
|
remote TEXT NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
CREATE INDEX audit_ts ON audit(ts);
|
||||||
|
|
||||||
|
-- A device is keyed by observed MAC address. Randomised MACs mean a physical
|
||||||
|
-- device may appear as several rows; nothing here merges them automatically.
|
||||||
|
CREATE TABLE devices (
|
||||||
|
mac TEXT PRIMARY KEY,
|
||||||
|
label TEXT NOT NULL DEFAULT '',
|
||||||
|
notes TEXT NOT NULL DEFAULT '',
|
||||||
|
hostname TEXT NOT NULL DEFAULT '',
|
||||||
|
last_ip TEXT NOT NULL DEFAULT '',
|
||||||
|
first_seen INTEGER NOT NULL,
|
||||||
|
last_seen INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE capture_sessions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
kind TEXT NOT NULL, -- live | import
|
||||||
|
state TEXT NOT NULL, -- running | stopped | failed | ready
|
||||||
|
interfaces TEXT NOT NULL DEFAULT '[]',
|
||||||
|
capture_filter TEXT NOT NULL DEFAULT '',
|
||||||
|
started_at INTEGER NOT NULL, -- unix ms UTC
|
||||||
|
ended_at INTEGER NOT NULL DEFAULT 0,
|
||||||
|
packets INTEGER NOT NULL DEFAULT 0,
|
||||||
|
bytes INTEGER NOT NULL DEFAULT 0,
|
||||||
|
drops TEXT NOT NULL DEFAULT '', -- capture-engine drop report, verbatim
|
||||||
|
error TEXT NOT NULL DEFAULT '',
|
||||||
|
preserved INTEGER NOT NULL DEFAULT 0,
|
||||||
|
tags TEXT NOT NULL DEFAULT '',
|
||||||
|
notes TEXT NOT NULL DEFAULT '',
|
||||||
|
dir TEXT NOT NULL,
|
||||||
|
meta TEXT NOT NULL DEFAULT '{}', -- versions, clock sync, parameters
|
||||||
|
ring_segments INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE segments (
|
||||||
|
session_id TEXT NOT NULL REFERENCES capture_sessions(id) ON DELETE CASCADE,
|
||||||
|
seq INTEGER NOT NULL,
|
||||||
|
file TEXT NOT NULL,
|
||||||
|
format TEXT NOT NULL, -- pcapng | pcap
|
||||||
|
base INTEGER NOT NULL, -- global number of the first packet
|
||||||
|
count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
bytes INTEGER NOT NULL DEFAULT 0,
|
||||||
|
sha256 TEXT NOT NULL DEFAULT '',
|
||||||
|
complete INTEGER NOT NULL DEFAULT 0,
|
||||||
|
deleted INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (session_id, seq)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- One summary row per packet. Full dissection is produced on demand by tshark.
|
||||||
|
CREATE TABLE packets (
|
||||||
|
session_id TEXT NOT NULL,
|
||||||
|
num INTEGER NOT NULL,
|
||||||
|
seg INTEGER NOT NULL,
|
||||||
|
off INTEGER NOT NULL,
|
||||||
|
ts REAL NOT NULL,
|
||||||
|
src TEXT NOT NULL,
|
||||||
|
dst TEXT NOT NULL,
|
||||||
|
proto TEXT NOT NULL,
|
||||||
|
len INTEGER NOT NULL,
|
||||||
|
info TEXT NOT NULL,
|
||||||
|
sport INTEGER,
|
||||||
|
dport INTEGER,
|
||||||
|
stream INTEGER,
|
||||||
|
tcpflags TEXT,
|
||||||
|
ttl INTEGER,
|
||||||
|
vlan INTEGER,
|
||||||
|
color TEXT,
|
||||||
|
PRIMARY KEY (session_id, num)
|
||||||
|
) WITHOUT ROWID;
|
||||||
|
|
||||||
|
CREATE TABLE saved_filters (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
kind TEXT NOT NULL, -- display | capture
|
||||||
|
expr TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
// Package migrations embeds the ordered SQL schema migrations.
|
||||||
|
package migrations
|
||||||
|
|
||||||
|
import "embed"
|
||||||
|
|
||||||
|
//go:embed *.sql
|
||||||
|
var FS embed.FS
|
||||||
BIN
Binary file not shown.
@@ -0,0 +1,8 @@
|
|||||||
|
# Test environment mirroring the Raspberry Pi OS (Debian trixie) userland.
|
||||||
|
# Used only for automated tests; the appliance itself does not use Docker.
|
||||||
|
FROM debian:trixie
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
RUN echo "wireshark-common wireshark-common/install-setuid boolean false" | debconf-set-selections && \
|
||||||
|
apt-get update -qq && apt-get install -y -qq --no-install-recommends \
|
||||||
|
tshark dnsmasq-base nftables iproute2 iputils-ping curl bind9-dnsutils \
|
||||||
|
python3 busybox procps ca-certificates >/dev/null && rm -rf /var/lib/apt/lists/*
|
||||||
Reference in New Issue
Block a user