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 } // 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 { 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). func (u *ufwManager) allowForward(rule ForwardRule) { if !u.available() { return } u.allowForwardPorts(rule) } func (u *ufwManager) allowForwardPorts(rule ForwardRule) { // "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() }