TLS-only HTTP API with Argon2id auth, setup token, CSRF, TOTP, login throttling, management source allow-list; network apply via helper; capture endpoints; SSE events; crash recovery of interrupted segments; systemd notify/watchdog.
293 lines
9.6 KiB
Go
293 lines
9.6 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"pilens.dev/pilens/internal/config"
|
|
"pilens.dev/pilens/internal/helper"
|
|
"pilens.dev/pilens/internal/sysinfo"
|
|
)
|
|
|
|
func (s *Server) networkRoutes(priv func(string, http.HandlerFunc)) {
|
|
priv("GET /api/system/interfaces", s.interfaces)
|
|
priv("GET /api/system/health", s.health)
|
|
priv("GET /api/network", s.getNetwork)
|
|
priv("POST /api/network/validate", s.validateNetwork)
|
|
priv("POST /api/network/apply", s.applyNetwork)
|
|
priv("POST /api/setup/complete", s.completeSetup)
|
|
priv("GET /api/ap/clients", s.apClients)
|
|
priv("PUT /api/devices/{mac}", s.updateDevice)
|
|
}
|
|
|
|
func (s *Server) interfaces(w http.ResponseWriter, r *http.Request) {
|
|
var boot config.Management
|
|
s.DB.GetJSON(keyBootstrap, &boot)
|
|
writeJSON(w, map[string]any{
|
|
"interfaces": sysinfo.Interfaces(),
|
|
"wifi": sysinfo.WifiCapabilities(),
|
|
"bootstrap": boot,
|
|
"remote_addr": remoteAddr(r).String(),
|
|
})
|
|
}
|
|
|
|
func (s *Server) helperStatus(ctx context.Context) (helper.Status, string) {
|
|
ctx, cancel := context.WithTimeout(ctx, 20*time.Second)
|
|
defer cancel()
|
|
var st helper.Status
|
|
if err := s.Helper.Call(ctx, helper.Request{Op: helper.OpStatus}, &st); err != nil {
|
|
return st, err.Error()
|
|
}
|
|
return st, ""
|
|
}
|
|
|
|
func (s *Server) health(w http.ResponseWriter, r *http.Request) {
|
|
h := sysinfo.ReadHealth(map[string]string{"data": s.DataDir, "captures": s.Engine.Dir})
|
|
st, herr := s.helperStatus(r.Context())
|
|
live, running := s.Engine.Live()
|
|
var n config.Network
|
|
configured, _ := s.DB.GetJSON(keyNetwork, &n)
|
|
upstream := map[string]any{"configured": configured}
|
|
if configured {
|
|
routes := sysinfo.DefaultRoutes()
|
|
gw, ok := routes[n.Uplink]
|
|
upstream["interface"] = n.Uplink
|
|
upstream["default_route"] = ok
|
|
upstream["gateway"] = gw
|
|
if i, err := net.InterfaceByName(n.Uplink); err == nil {
|
|
upstream["up"] = i.Flags&net.FlagUp != 0
|
|
}
|
|
}
|
|
writeJSON(w, map[string]any{
|
|
"system": h,
|
|
"helper": st,
|
|
"helper_error": herr,
|
|
"capture": map[string]any{"running": running, "session": live, "tshark": s.Engine.Tools.Version()},
|
|
"storage": s.Engine.Storage(),
|
|
"upstream": upstream,
|
|
"version": s.Version,
|
|
"forwarding": forwardingState(configured, n, st, herr),
|
|
})
|
|
}
|
|
|
|
// forwardingState is the single prominent answer to "is AP traffic being forwarded?".
|
|
func forwardingState(configured bool, n config.Network, st helper.Status, herr string) map[string]string {
|
|
switch {
|
|
case herr != "":
|
|
return map[string]string{"state": "unknown", "detail": "network helper unavailable: " + herr + ". The last loaded firewall table remains in force."}
|
|
case !configured || !st.Configured:
|
|
return map[string]string{"state": "blocked", "detail": "No network configuration has been applied. AP traffic is not forwarded."}
|
|
case !st.TableLoaded:
|
|
return map[string]string{"state": "unprotected", "detail": "The PiLens firewall table is not loaded."}
|
|
case n.Forwarding.Mode != "internet":
|
|
return map[string]string{"state": "blocked", "detail": "Forwarding is disabled by policy."}
|
|
case !st.IPForward:
|
|
return map[string]string{"state": "blocked", "detail": "Kernel IPv4 forwarding is off."}
|
|
}
|
|
return map[string]string{"state": "forwarding", "detail": "AP clients reach public destinations via " + n.Uplink + ". Private and management networks are blocked. IPv6 is blocked."}
|
|
}
|
|
|
|
func (s *Server) getNetwork(w http.ResponseWriter, r *http.Request) {
|
|
var n config.Network
|
|
ok, _ := s.DB.GetJSON(keyNetwork, &n)
|
|
if !ok {
|
|
n = config.DefaultNetwork()
|
|
var b config.Management
|
|
if found, _ := s.DB.GetJSON(keyBootstrap, &b); found {
|
|
n.Management = b
|
|
}
|
|
}
|
|
pass := n.AP.Passphrase != ""
|
|
writeJSON(w, map[string]any{"config": n.Redacted(), "saved": ok, "passphrase_set": pass})
|
|
}
|
|
|
|
// mergeNetwork keeps the stored passphrase when the client leaves it blank.
|
|
func (s *Server) mergeNetwork(n *config.Network) {
|
|
if n.AP.Passphrase == "" {
|
|
var old config.Network
|
|
if ok, _ := s.DB.GetJSON(keyNetwork, &old); ok {
|
|
n.AP.Passphrase = old.AP.Passphrase
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Server) validateNetwork(w http.ResponseWriter, r *http.Request) {
|
|
var n config.Network
|
|
if !readJSON(w, r, &n) {
|
|
return
|
|
}
|
|
s.mergeNetwork(&n)
|
|
errs := []string{}
|
|
if err := n.Validate(true); err != nil {
|
|
errs = strings.Split(err.Error(), "\n")
|
|
}
|
|
errs = append(errs, s.managementWarnings(r, n)...)
|
|
writeJSON(w, map[string]any{"valid": len(errs) == 0, "errors": errs})
|
|
}
|
|
|
|
// managementWarnings refuses configurations that would lock out the current admin.
|
|
func (s *Server) managementWarnings(r *http.Request, n config.Network) []string {
|
|
ra := remoteAddr(r)
|
|
if ra.IsLoopback() {
|
|
return nil
|
|
}
|
|
ok := false
|
|
for _, c := range n.Management.AllowedSubnets {
|
|
if p, err := parsePrefix(c); err == nil && p.Contains(ra) {
|
|
ok = true
|
|
}
|
|
}
|
|
if !ok {
|
|
return []string{"your current address " + ra.String() + " is not inside any management subnet: applying would lock you out"}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) applyNetwork(w http.ResponseWriter, r *http.Request) {
|
|
var n config.Network
|
|
if !readJSON(w, r, &n) {
|
|
return
|
|
}
|
|
s.mergeNetwork(&n)
|
|
if err := n.Validate(true); err != nil {
|
|
httpErr(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
if warn := s.managementWarnings(r, n); len(warn) > 0 {
|
|
httpErr(w, http.StatusBadRequest, warn[0])
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Minute)
|
|
defer cancel()
|
|
var res helper.ApplyResult
|
|
err := s.Helper.Call(ctx, helper.Request{Op: helper.OpApplyNetwork, Network: &n}, &res)
|
|
red, _ := json.Marshal(n.Redacted())
|
|
if err != nil {
|
|
s.audit(r, "network.apply_failed", err.Error())
|
|
w.WriteHeader(http.StatusBadGateway)
|
|
writeJSON(w, map[string]any{"error": err.Error(), "result": res})
|
|
return
|
|
}
|
|
s.DB.PutJSON(keyNetwork, n)
|
|
s.reloadMgmt()
|
|
s.audit(r, "network.applied", string(red))
|
|
writeJSON(w, map[string]any{"result": res})
|
|
}
|
|
|
|
func (s *Server) completeSetup(w http.ResponseWriter, r *http.Request) {
|
|
var n config.Network
|
|
if ok, _ := s.DB.GetJSON(keyNetwork, &n); !ok {
|
|
httpErr(w, http.StatusConflict, "apply a network configuration first")
|
|
return
|
|
}
|
|
s.DB.PutJSON(keySetupDone, true)
|
|
s.audit(r, "setup.completed", "")
|
|
writeJSON(w, map[string]bool{"ok": true})
|
|
}
|
|
|
|
type apClient struct {
|
|
MAC string `json:"mac"`
|
|
IP string `json:"ip"`
|
|
Hostname string `json:"hostname"`
|
|
Label string `json:"label"`
|
|
Notes string `json:"notes"`
|
|
Associated bool `json:"associated"`
|
|
SignalDBm *int `json:"signal_dbm,omitempty"`
|
|
RxBytes uint64 `json:"rx_bytes"`
|
|
TxBytes uint64 `json:"tx_bytes"`
|
|
ConnectedS int64 `json:"connected_s"`
|
|
InactiveMS int64 `json:"inactive_ms"`
|
|
LeaseExpiry int64 `json:"lease_expiry"`
|
|
FirstSeen int64 `json:"first_seen"`
|
|
LastSeen int64 `json:"last_seen"`
|
|
Randomised bool `json:"randomised_mac"`
|
|
}
|
|
|
|
func (s *Server) apClients(w http.ResponseWriter, r *http.Request) {
|
|
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
|
defer cancel()
|
|
var leases []helper.Lease
|
|
var stations []helper.Station
|
|
var errs []string
|
|
if err := s.Helper.Call(ctx, helper.Request{Op: helper.OpLeases}, &leases); err != nil {
|
|
errs = append(errs, "leases: "+err.Error())
|
|
}
|
|
if err := s.Helper.Call(ctx, helper.Request{Op: helper.OpStations}, &stations); err != nil {
|
|
errs = append(errs, "stations: "+err.Error())
|
|
}
|
|
now := time.Now().Unix()
|
|
by := map[string]*apClient{}
|
|
get := func(mac string) *apClient {
|
|
if c := by[mac]; c != nil {
|
|
return c
|
|
}
|
|
c := &apClient{MAC: mac}
|
|
if hw, err := net.ParseMAC(mac); err == nil && len(hw) > 0 {
|
|
c.Randomised = hw[0]&0x02 != 0 // locally administered bit
|
|
}
|
|
by[mac] = c
|
|
return c
|
|
}
|
|
for _, l := range leases {
|
|
c := get(l.MAC)
|
|
c.IP, c.Hostname, c.LeaseExpiry = l.IP, l.Hostname, l.Expiry
|
|
}
|
|
for _, st := range stations {
|
|
c := get(st.MAC)
|
|
c.Associated = true
|
|
c.SignalDBm, c.RxBytes, c.TxBytes, c.ConnectedS, c.InactiveMS = st.SignalDBm, st.RxBytes, st.TxBytes, st.ConnectedS, st.InactiveMS
|
|
}
|
|
for mac, c := range by {
|
|
seen := now
|
|
if !c.Associated {
|
|
seen = 0
|
|
}
|
|
s.DB.Exec(`INSERT INTO devices(mac,hostname,last_ip,first_seen,last_seen) VALUES(?,?,?,?,?)
|
|
ON CONFLICT(mac) DO UPDATE SET hostname=CASE WHEN excluded.hostname!='' THEN excluded.hostname ELSE hostname END,
|
|
last_ip=CASE WHEN excluded.last_ip!='' THEN excluded.last_ip ELSE last_ip END,
|
|
last_seen=MAX(last_seen,excluded.last_seen)`, mac, c.Hostname, c.IP, now, seen)
|
|
s.DB.QueryRow(`SELECT label,notes,first_seen,last_seen FROM devices WHERE mac=?`, mac).Scan(&c.Label, &c.Notes, &c.FirstSeen, &c.LastSeen)
|
|
}
|
|
out := []apClient{}
|
|
for _, c := range by {
|
|
out = append(out, *c)
|
|
}
|
|
// Known devices not currently present.
|
|
rows, err := s.DB.Query(`SELECT mac,label,notes,hostname,last_ip,first_seen,last_seen FROM devices ORDER BY last_seen DESC LIMIT 200`)
|
|
if err == nil {
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var c apClient
|
|
rows.Scan(&c.MAC, &c.Label, &c.Notes, &c.Hostname, &c.IP, &c.FirstSeen, &c.LastSeen)
|
|
if by[c.MAC] == nil {
|
|
out = append(out, c)
|
|
}
|
|
}
|
|
}
|
|
writeJSON(w, map[string]any{"clients": out, "errors": errs})
|
|
}
|
|
|
|
func (s *Server) updateDevice(w http.ResponseWriter, r *http.Request) {
|
|
hw, err := net.ParseMAC(r.PathValue("mac"))
|
|
if err != nil {
|
|
httpErr(w, http.StatusBadRequest, "invalid MAC")
|
|
return
|
|
}
|
|
var req struct{ Label, Notes string }
|
|
if !readJSON(w, r, &req) {
|
|
return
|
|
}
|
|
if len(req.Label) > 100 || len(req.Notes) > 10000 {
|
|
httpErr(w, http.StatusBadRequest, "label or notes too long")
|
|
return
|
|
}
|
|
now := time.Now().Unix()
|
|
s.DB.Exec(`INSERT INTO devices(mac,label,notes,first_seen,last_seen) VALUES(?,?,?,?,0) ON CONFLICT(mac) DO UPDATE SET label=excluded.label, notes=excluded.notes`, hw.String(), req.Label, req.Notes, now)
|
|
s.audit(r, "device.updated", hw.String())
|
|
writeJSON(w, map[string]bool{"ok": true})
|
|
}
|