Files
lpra/internal/server/ufw.go
T
redtea 86cb0226ab feat: machine status page, speed test, live presence, forwards without ufw (0.0.5)
Status page per machine: CPU load, RAM and disk of the client, sampled
by the agent only while the page is open and visible (one reading
serves every open tab), plus a button that has the agent measure its
link to the host: ping, down and up.

Online/offline now comes from the control polls the hub holds open, so
a dropped agent shows offline at once instead of 45 s later, and the
dashboard keeps the status column live.

A forward rule can be created "without ufw": the panel opens nothing in
ufw for it and so revokes nothing when it is deleted. The checkbox is
shown only while the ufw integration is on.

Dashboard row actions are icons; a status action is added.
2026-10-13 18:56:15 +03:00

218 lines
6.0 KiB
Go

package server
import (
"fmt"
"log"
"net"
"os"
"os/exec"
"strings"
"sync"
)
// ufwManager integrates with the host firewall (ufw) when it is installed and
// active. Opening a forward port in the panel also opens it in ufw; the created
// rules are tracked so they can be listed and revoked from Settings.
//
// ufw requires root, so this only works when the server process can run ufw
// (e.g. running as root). When it cannot, integration is simply inactive.
type ufwManager struct {
db *DB
// tunnelPort returns the current direct-tunnel port (0 = tunnel disabled).
tunnelPort func() int
mu sync.Mutex
lastErr string // last failed ufw command, shown in Settings
}
func newUfwManager(db *DB, tunnelPort func() int) *ufwManager {
return &ufwManager{db: db, tunnelPort: tunnelPort}
}
// ufwRun runs ufw with a fixed C locale: its output is translated, and we
// match on the English text.
func ufwRun(args ...string) (string, error) {
cmd := exec.Command("ufw", args...)
cmd.Env = append(os.Environ(), "LC_ALL=C", "LANG=C", "LANGUAGE=C")
out, err := cmd.CombinedOutput()
return strings.TrimSpace(string(out)), err
}
// status reports whether ufw is installed, active, and runnable by this process.
func (u *ufwManager) status() (installed, active, runnable bool) {
if _, err := exec.LookPath("ufw"); err != nil {
return false, false, false
}
installed = true
out, err := ufwRun("status")
if err != nil {
// Typically a permission error (not root) -> not runnable.
return true, false, false
}
runnable = true
active = strings.Contains(out, "Status: active")
return installed, active, runnable
}
// enabled reports whether the integration is switched on and ufw is there to
// drive. Unlike available it runs nothing, so pages can ask it freely.
func (u *ufwManager) enabled() bool {
if !u.db.UfwEnabled() {
return false
}
_, err := exec.LookPath("ufw")
return err == nil
}
// available reports whether we can and should drive ufw now.
func (u *ufwManager) available() bool {
if !u.db.UfwEnabled() {
return false
}
installed, active, runnable := u.status()
return installed && active && runnable
}
func (u *ufwManager) setErr(msg string) {
u.mu.Lock()
u.lastErr = msg
u.mu.Unlock()
}
// LastError returns the last failed ufw command ("" when the last one worked).
func (u *ufwManager) LastError() string {
u.mu.Lock()
defer u.mu.Unlock()
return u.lastErr
}
// allow opens port/proto in ufw and records it, bound to the given forward
// rule id (0 = shared/manual). The caller has checked available().
func (u *ufwManager) allow(port int, proto string, ruleID int64) {
if u.db.HasUfwRule(port, proto) {
return
}
spec := fmt.Sprintf("%d/%s", port, proto)
out, err := ufwRun("allow", spec)
if err != nil {
log.Printf("ufw allow %s: %v: %s", spec, err, out)
u.setErr(fmt.Sprintf("ufw allow %s: %v: %s", spec, err, out))
return
}
u.setErr("")
if strings.Contains(out, "Skipping") {
// The rule was there before the panel asked for it, so it is not ours
// to track (and later delete).
return
}
_ = u.db.AddUfwRule(port, proto, ruleID)
log.Printf("ufw: allowed %s", spec)
}
// revokeRule deletes one tracked ufw rule from ufw and the tracking table.
func (u *ufwManager) revokeRule(r UfwRule) {
spec := fmt.Sprintf("%d/%s", r.Port, r.Proto)
if out, err := ufwRun("delete", "allow", spec); err != nil {
log.Printf("ufw delete allow %s: %v: %s", spec, err, out)
u.setErr(fmt.Sprintf("ufw delete allow %s: %v: %s", spec, err, out))
} else {
u.setErr("")
log.Printf("ufw: revoked %s", spec)
}
_ = u.db.DeleteUfwRule(r.ID)
}
// revoke removes a ufw rule by its tracking id (manual revoke from Settings).
func (u *ufwManager) revoke(id int64) {
r, err := u.db.GetUfwRule(id)
if err != nil || r == nil {
return
}
u.revokeRule(*r)
}
// revokeForRule removes the ufw rules bound to a specific forward rule, and
// the shared tunnel port once no forward needs it any more.
func (u *ufwManager) revokeForRule(ruleID int64) {
rules, err := u.db.ListUfwRulesByRule(ruleID)
if err != nil {
return
}
for _, r := range rules {
u.revokeRule(r)
}
u.pruneShared()
}
// pruneShared revokes shared (rule_id 0) ufw rules that nothing uses: anything
// but the current tunnel port, and the tunnel port itself for a protocol that
// has no direct forward left.
func (u *ufwManager) pruneShared() {
shared, err := u.db.ListUfwRulesByRule(0)
if err != nil || len(shared) == 0 {
return
}
forwards, err := u.db.ListForwardRules()
if err != nil {
return
}
used := map[string]bool{}
for _, f := range forwards {
if !f.NoUfw {
used[f.Proto] = true
}
}
port := u.tunnelPort()
for _, r := range shared {
if r.Port != port || !used[r.Proto] {
u.revokeRule(r)
}
}
}
// allowForward opens the host ports a forward rule needs: its host port (bound
// to the rule so it is revoked with it) plus the shared tunnel port for direct
// tcp/udp modes (bound to 0 — shared across rules). A rule made "without ufw"
// opens nothing, so nothing is revoked with it either.
func (u *ufwManager) allowForward(rule ForwardRule) {
if !u.available() {
return
}
u.allowForwardPorts(rule)
}
func (u *ufwManager) allowForwardPorts(rule ForwardRule) {
if rule.NoUfw {
return
}
// "http" (long-poll) and "tcp" forwards both listen on TCP on the host.
hostProto, direct := "tcp", rule.Proto == "tcp"
if rule.Proto == "udp" {
hostProto, direct = "udp", true
}
// A listener bound to loopback is not reachable from outside anyway.
if ip := net.ParseIP(rule.ListenAddr); ip == nil || !ip.IsLoopback() {
u.allow(rule.HostPort, hostProto, rule.ID)
}
if port := u.tunnelPort(); direct && port > 0 {
u.allow(port, hostProto, 0)
}
}
// sync brings ufw in line with the saved forward rules: it opens what is
// missing (rules made while ufw or the integration was off, or a tunnel port
// that has since changed) and drops shared rules nothing uses.
func (u *ufwManager) sync() {
if !u.available() {
return
}
forwards, err := u.db.ListForwardRules()
if err != nil {
return
}
for _, f := range forwards {
u.allowForwardPorts(f)
}
u.pruneShared()
}