218 lines
6.0 KiB
Go
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()
|
|
}
|