The direct tunnel port (8446 by default) and the host agents dial for it can be set in Settings. The server moves its listeners at once and agents pick the address up from the panel: on connect and with every poll, unless TUNNEL_ADDR / TUNNEL_PORT pin it locally. ufw: run with a C locale (the output is translated), do not adopt rules that existed before the panel, skip loopback-only listeners, re-open what saved forwards need on start and when the integration is turned on, move the shared tunnel rule with the port and drop it when no direct forward is left. The last ufw error is shown in Settings. The forwards page shows the real tunnel and panel ports.
202 lines
5.6 KiB
Go
202 lines
5.6 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
|
|
}
|
|
|
|
// 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()
|
|
}
|