244 lines
6.6 KiB
Go
Raw Permalink Normal View History

2026-03-12 20:51:38 -07:00
package system
import (
"fmt"
"os/exec"
"regexp"
"strings"
)
// ── Types ───────────────────────────────────────────────────────────
type UFWRule struct {
Direction string `json:"direction"` // "in" or "out"
Protocol string `json:"protocol"` // "tcp", "udp", or "" for both
Port string `json:"port"` // e.g. "22", "80:90"
Source string `json:"source"` // IP/CIDR or "any"/"Anywhere"
Action string `json:"action"` // "allow", "deny", "reject", "limit"
Comment string `json:"comment"`
}
// ── Firewall (UFW) ──────────────────────────────────────────────────
// Status parses `ufw status verbose` and returns the enable state, parsed rules,
// and the raw command output.
func FirewallStatus() (enabled bool, rules []UFWRule, raw string, err error) {
out, cmdErr := exec.Command("ufw", "status", "verbose").CombinedOutput()
raw = string(out)
if cmdErr != nil {
// ufw may return non-zero when inactive; check output
if strings.Contains(raw, "Status: inactive") {
return false, nil, raw, nil
}
err = fmt.Errorf("ufw status failed: %w (%s)", cmdErr, raw)
return
}
enabled = strings.Contains(raw, "Status: active")
// Parse rule lines. After the header block, rules look like:
// 22/tcp ALLOW IN Anywhere # SSH
// 80/tcp ALLOW IN 192.168.1.0/24 # Web
// We find lines after the "---" separator.
lines := strings.Split(raw, "\n")
pastSeparator := false
// Match: port/proto (or port) ACTION DIRECTION source # optional comment
ruleRegex := regexp.MustCompile(
`^(\S+)\s+(ALLOW|DENY|REJECT|LIMIT)\s+(IN|OUT|FWD)?\s*(.+?)(?:\s+#\s*(.*))?$`,
)
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "---") {
pastSeparator = true
continue
}
if !pastSeparator || trimmed == "" {
continue
}
matches := ruleRegex.FindStringSubmatch(trimmed)
if matches == nil {
continue
}
portProto := matches[1]
action := strings.ToLower(matches[2])
direction := strings.ToLower(matches[3])
source := strings.TrimSpace(matches[4])
comment := strings.TrimSpace(matches[5])
if direction == "" {
direction = "in"
}
// Split port/protocol
var port, proto string
if strings.Contains(portProto, "/") {
parts := strings.SplitN(portProto, "/", 2)
port = parts[0]
proto = parts[1]
} else {
port = portProto
}
// Normalize source
if source == "Anywhere" || source == "Anywhere (v6)" {
source = "any"
}
rules = append(rules, UFWRule{
Direction: direction,
Protocol: proto,
Port: port,
Source: source,
Action: action,
Comment: comment,
})
}
return enabled, rules, raw, nil
}
// FirewallEnable enables UFW with --force to skip the interactive prompt.
func FirewallEnable() error {
out, err := exec.Command("ufw", "--force", "enable").CombinedOutput()
if err != nil {
return fmt.Errorf("ufw enable failed: %w (%s)", err, string(out))
}
return nil
}
// FirewallDisable disables UFW.
func FirewallDisable() error {
out, err := exec.Command("ufw", "disable").CombinedOutput()
if err != nil {
return fmt.Errorf("ufw disable failed: %w (%s)", err, string(out))
}
return nil
}
// FirewallAddRule constructs and executes a ufw command from the given rule struct.
func FirewallAddRule(rule UFWRule) error {
if rule.Port == "" {
return fmt.Errorf("port is required")
}
if rule.Action == "" {
rule.Action = "allow"
}
if rule.Protocol == "" {
rule.Protocol = "tcp"
}
if rule.Source == "" || rule.Source == "any" {
rule.Source = ""
}
// Validate action
switch rule.Action {
case "allow", "deny", "reject", "limit":
// valid
default:
return fmt.Errorf("invalid action %q: must be allow, deny, reject, or limit", rule.Action)
}
// Validate protocol
switch rule.Protocol {
case "tcp", "udp":
// valid
default:
return fmt.Errorf("invalid protocol %q: must be tcp or udp", rule.Protocol)
}
// Validate direction
if rule.Direction != "" && rule.Direction != "in" && rule.Direction != "out" {
return fmt.Errorf("invalid direction %q: must be in or out", rule.Direction)
}
// Build argument list
args := []string{rule.Action}
// Direction
if rule.Direction == "out" {
args = append(args, "out")
}
// Source filter
if rule.Source != "" {
args = append(args, "from", rule.Source)
}
args = append(args, "to", "any", "port", rule.Port, "proto", rule.Protocol)
// Comment
if rule.Comment != "" {
args = append(args, "comment", rule.Comment)
}
out, err := exec.Command("ufw", args...).CombinedOutput()
if err != nil {
return fmt.Errorf("ufw add rule failed: %w (%s)", err, string(out))
}
return nil
}
// FirewallDeleteRule constructs and executes a ufw delete command for the given rule.
func FirewallDeleteRule(rule UFWRule) error {
if rule.Port == "" {
return fmt.Errorf("port is required")
}
if rule.Action == "" {
rule.Action = "allow"
}
// Build the rule specification that matches what was added
args := []string{"delete", rule.Action}
if rule.Direction == "out" {
args = append(args, "out")
}
if rule.Source != "" && rule.Source != "any" {
args = append(args, "from", rule.Source)
}
portSpec := rule.Port
if rule.Protocol != "" {
portSpec = rule.Port + "/" + rule.Protocol
}
args = append(args, portSpec)
out, err := exec.Command("ufw", args...).CombinedOutput()
if err != nil {
return fmt.Errorf("ufw delete rule failed: %w (%s)", err, string(out))
}
return nil
}
// FirewallSetDefaults sets the default incoming and outgoing policies.
// Valid values are "allow", "deny", "reject".
func FirewallSetDefaults(incoming, outgoing string) error {
validPolicy := map[string]bool{"allow": true, "deny": true, "reject": true}
if incoming != "" {
if !validPolicy[incoming] {
return fmt.Errorf("invalid incoming policy %q: must be allow, deny, or reject", incoming)
}
out, err := exec.Command("ufw", "default", incoming, "incoming").CombinedOutput()
if err != nil {
return fmt.Errorf("setting default incoming policy failed: %w (%s)", err, string(out))
}
}
if outgoing != "" {
if !validPolicy[outgoing] {
return fmt.Errorf("invalid outgoing policy %q: must be allow, deny, or reject", outgoing)
}
out, err := exec.Command("ufw", "default", outgoing, "outgoing").CombinedOutput()
if err != nil {
return fmt.Errorf("setting default outgoing policy failed: %w (%s)", err, string(out))
}
}
return nil
}