package api import ( "encoding/json" "math/rand" "net/http" "net/netip" "strconv" "strings" "clicd/internal/config" "clicd/internal/lxc" ) func generateFirewallRuleID() string { const chars = "abcdefghijklmnopqrstuvwxyz0123456789" b := make([]byte, 8) for i := range b { b[i] = chars[rand.Intn(len(chars))] } return string(b) } func getFirewall(w http.ResponseWriter, r *http.Request, id int) { c := config.FindContainer(id) if c == nil { jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"}) return } jsonResponse(w, http.StatusOK, APIResponse{ Success: true, Data: map[string]interface{}{ "enabled": c.FirewallEnabled, "default_action": normalizeFirewallDefaultAction(c.FirewallDefaultAction), "rules": c.FirewallRules, }, }) } func updateFirewall(w http.ResponseWriter, r *http.Request, id int) { c := config.FindContainer(id) if c == nil { jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"}) return } var req struct { Enabled *bool `json:"enabled"` DefaultAction *string `json:"default_action"` Rules *[]config.FirewallRule `json:"rules"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"}) return } oldEnabled := c.FirewallEnabled oldDefaultAction := c.FirewallDefaultAction oldRules := append([]config.FirewallRule(nil), c.FirewallRules...) if req.Enabled != nil { c.FirewallEnabled = *req.Enabled } if req.DefaultAction != nil { action := normalizeFirewallDefaultAction(*req.DefaultAction) if action == "" { jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid default action"}) return } c.FirewallDefaultAction = action } else if strings.TrimSpace(c.FirewallDefaultAction) == "" { c.FirewallDefaultAction = "DROP" } if req.Rules != nil { // Validate and assign IDs to new rules rules := *req.Rules for i := range rules { rules[i].Direction = strings.ToLower(strings.TrimSpace(rules[i].Direction)) rules[i].Protocol = strings.ToLower(strings.TrimSpace(rules[i].Protocol)) rules[i].Action = strings.ToUpper(strings.TrimSpace(rules[i].Action)) rules[i].Network = normalizeFirewallNetwork(rules[i].Network) rules[i].SourceIP = strings.TrimSpace(rules[i].SourceIP) rules[i].Port = strings.TrimSpace(rules[i].Port) if rules[i].Network == "" { jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid network"}) return } if rules[i].Direction != "in" && rules[i].Direction != "out" { jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid direction: " + rules[i].Direction}) return } if rules[i].Protocol != "tcp" && rules[i].Protocol != "udp" && rules[i].Protocol != "icmp" && rules[i].Protocol != "all" { jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid protocol: " + rules[i].Protocol}) return } if rules[i].Action != "ACCEPT" && rules[i].Action != "DROP" { jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid action: " + rules[i].Action}) return } if rules[i].SourceIP != "" { if err := validateFirewallIPSpec(rules[i].SourceIP, rules[i].Network); err != nil { jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid IP: " + err.Error()}) return } } if rules[i].ID == "" || strings.HasPrefix(rules[i].ID, "tmp-") { rules[i].ID = generateFirewallRuleID() } // Validate port spec if rules[i].Port != "" { if rules[i].Protocol != "tcp" && rules[i].Protocol != "udp" { jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Ports are only supported for TCP and UDP rules"}) return } if err := validatePortSpec(rules[i].Port); err != nil { jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid port: " + err.Error()}) return } } } c.FirewallRules = rules } // Apply firewall rules to iptables if container is running if c.Status == "running" { if err := lxc.ApplyFirewallRules(id); err != nil { c.FirewallEnabled = oldEnabled c.FirewallDefaultAction = oldDefaultAction c.FirewallRules = oldRules _ = lxc.ApplyFirewallRules(id) config.SaveConfig() jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to apply firewall rules: " + err.Error()}) return } } else if !c.FirewallEnabled { // If disabled and not running, clean any lingering rules lxc.CleanFirewallRules(id) } config.SaveConfig() jsonResponse(w, http.StatusOK, APIResponse{ Success: true, Message: "Firewall updated", Data: map[string]interface{}{ "enabled": c.FirewallEnabled, "default_action": normalizeFirewallDefaultAction(c.FirewallDefaultAction), "rules": c.FirewallRules, }, }) } func normalizeFirewallDefaultAction(action string) string { action = strings.ToUpper(strings.TrimSpace(action)) if action == "ACCEPT" || action == "DROP" { return action } return "" } func normalizeFirewallNetwork(network string) string { network = strings.ToLower(strings.TrimSpace(network)) switch network { case "", "ipv4", "nat4": return "ipv4" case "ipv6": return "ipv6" case "all", "both": return "all" default: return "" } } func validatePortSpec(port string) error { port = strings.TrimSpace(port) if port == "" { return nil } // Support: "22", "80,443", "8000-9000", "80,443,8000-9000" partCount := 0 for _, part := range strings.Split(port, ",") { part = strings.TrimSpace(part) if part == "" { return &portValidationError{port} } partCount++ if strings.Contains(part, "-") { // Range bounds := strings.SplitN(part, "-", 2) lo, err := strconv.Atoi(strings.TrimSpace(bounds[0])) if err != nil || lo < 1 || lo > 65535 { return &portValidationError{part} } hi, err := strconv.Atoi(strings.TrimSpace(bounds[1])) if err != nil || hi < 1 || hi > 65535 { return &portValidationError{part} } if hi < lo { return &portValidationError{part} } } else { p, err := strconv.Atoi(part) if err != nil || p < 1 || p > 65535 { return &portValidationError{part} } } } if partCount > 15 { return &portValidationError{"too many ports; maximum 15 items per rule"} } return nil } func validateFirewallIPSpec(value string, network string) error { var addr netip.Addr if strings.Contains(value, "/") { prefix, err := netip.ParsePrefix(value) if err != nil { return err } addr = prefix.Addr() } else { parsed, err := netip.ParseAddr(value) if err != nil { return err } addr = parsed } switch network { case "ipv4": if !addr.Is4() { return &ipValidationError{"IPv4 rule requires an IPv4 address or CIDR: " + value} } case "ipv6": if !addr.Is6() || addr.Is4In6() { return &ipValidationError{"IPv6 rule requires an IPv6 address or CIDR: " + value} } } return nil } type ipValidationError struct { value string } func (e *ipValidationError) Error() string { return e.value } type portValidationError struct { port string } func (e *portValidationError) Error() string { return "invalid port value: " + e.port }