diff --git a/network-go/.gitignore b/network-go/.gitignore new file mode 100644 index 0000000..df69222 --- /dev/null +++ b/network-go/.gitignore @@ -0,0 +1,2 @@ +network-go +*.test \ No newline at end of file diff --git a/network-go/config/config.go b/network-go/config/config.go new file mode 100644 index 0000000..eb2cb2f --- /dev/null +++ b/network-go/config/config.go @@ -0,0 +1,136 @@ +package config + +import ( + "encoding/json" + "fmt" + "net" + "os" +) + +// NetworksConfig represents the /etc/user/config/networks.json structure +type NetworksConfig struct { + Networks map[string]NetworkConfig `json:"networks"` + IPs map[string]IPConfig `json:"ips"` + Policies []PolicyConfig `json:"policies"` +} + +// NetworkConfig represents a single network definition +type NetworkConfig struct { + NetworkName string `json:"network_name"` + Subnet string `json:"subnet"` + Gateway string `json:"gateway"` +} + +// IPConfig represents a single IP assignment +type IPConfig struct { + IP string `json:"ip"` + ContainerName string `json:"container_name"` + Selector string `json:"selector"` + ServiceName string `json:"service_name"` +} + +// PolicyConfig represents a single policy rule from networks.json +type PolicyConfig struct { + ServiceName string `json:"service_name,omitempty"` + ContainerName string `json:"container_name,omitempty"` + Selector string `json:"selector,omitempty"` + From string `json:"from,omitempty"` + To string `json:"to,omitempty"` + Port int `json:"port,omitempty"` + Proto string `json:"proto,omitempty"` + Name string `json:"name,omitempty"` + Iface string `json:"iface,omitempty"` + Nat string `json:"nat,omitempty"` +} + +// FirewallRule is a resolved, executable firewall rule derived from a PolicyConfig +type FirewallRule struct { + // Source info + SourceIP string + SourcePort int + SourceIface string + + // Target info + TargetIP string + TargetPort int + + // Protocol (tcp/udp) + Proto string + + // Chain and table + Chain string // PREROUTING, POSTROUTING, FORWARD, DOCKER-USER, etc. + Table string // nat, filter + + // Action + Action string // DNAT, MASQUERADE, ACCEPT, DROP + + // Comment for iptables + Comment string + + // Namespace info (empty = host, non-empty = container PID namespace) + ContainerPID string +} + +// ToCIDR converts an IP without mask to a /24 CIDR notation (matching shell script behavior) +func ToCIDR(ip string) string { + // If it's already a CIDR, return as-is + if _, _, err := net.ParseCIDR(ip); err == nil { + return ip + } + // If last octet is 0, it's already a network address + parsed := net.ParseIP(ip) + if parsed == nil { + return ip + } + ipv4 := parsed.To4() + if ipv4 == nil { + return ip + } + if ipv4[3] == 0 { + return fmt.Sprintf("%d.%d.%d.0/24", ipv4[0], ipv4[1], ipv4[2]) + } + return ip +} + +// NetworkPrefix returns the first three octets as a /24 CIDR +func NetworkPrefix(ip string) string { + parsed := net.ParseIP(ip) + if parsed == nil { + return ip + } + ipv4 := parsed.To4() + if ipv4 == nil { + return ip + } + return fmt.Sprintf("%d.%d.%d.0/24", ipv4[0], ipv4[1], ipv4[2]) +} + +// IsIP checks if a string is an IPv4 address +func IsIP(s string) bool { + parsed := net.ParseIP(s) + return parsed != nil && parsed.To4() != nil +} + +// ParseCIDR parses the subnet string into an IPNet +func (n NetworkConfig) ParseCIDR() (*net.IPNet, error) { + _, ipNet, err := net.ParseCIDR(n.Subnet) + if err != nil { + return nil, fmt.Errorf("failed to parse subnet %s: %w", n.Subnet, err) + } + return ipNet, nil +} + +// Load reads and parses the networks.json configuration file +func Load(path string) (*NetworksConfig, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read config file %s: %w", path, err) + } + + var config NetworksConfig + if err := json.Unmarshal(data, &config); err != nil { + return nil, fmt.Errorf("failed to parse config file %s: %w", path, err) + } + + return &config, nil +} \ No newline at end of file diff --git a/network-go/docker/docker.go b/network-go/docker/docker.go new file mode 100644 index 0000000..b376729 --- /dev/null +++ b/network-go/docker/docker.go @@ -0,0 +1,187 @@ +package docker + +import ( + "context" + "fmt" + "net" + "os/exec" + "time" + + "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/filters" + "github.com/docker/docker/api/types/network" + "github.com/docker/docker/client" + + "firewall_containers/network-go/config" +) + +// Client wraps the Docker SDK client +type Client struct { + cli *client.Client +} + +// NewClient creates a new Docker client +func NewClient() (*Client, error) { + cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) + if err != nil { + return nil, fmt.Errorf("failed to create Docker client: %w", err) + } + return &Client{cli: cli}, nil +} + +// Close closes the Docker client +func (c *Client) Close() error { + return c.cli.Close() +} + +// EnsureNetwork creates a Docker network if it does not already exist +func (c *Client) EnsureNetwork(ctx context.Context, netCfg config.NetworkConfig) error { + // Check if network already exists + existingNetworks, err := c.cli.NetworkList(ctx, network.ListOptions{ + Filters: filters.NewArgs(filters.Arg("name", netCfg.NetworkName)), + }) + if err != nil { + return fmt.Errorf("failed to list networks: %w", err) + } + + for _, n := range existingNetworks { + if n.Name == netCfg.NetworkName { + // Network already exists, skip creation + return nil + } + } + + // Parse subnet and gateway + _, ipNet, err := net.ParseCIDR(netCfg.Subnet) + if err != nil { + return fmt.Errorf("failed to parse subnet %s: %w", netCfg.Subnet, err) + } + + gatewayIP := net.ParseIP(netCfg.Gateway) + if gatewayIP == nil { + return fmt.Errorf("failed to parse gateway IP %s", netCfg.Gateway) + } + + // Create the network + createOpts := network.CreateOptions{ + Driver: "bridge", + IPAM: &network.IPAM{ + Config: []network.IPAMConfig{ + { + Subnet: ipNet.String(), + Gateway: gatewayIP.String(), + }, + }, + }, + Labels: map[string]string{ + "managed-by": "firewall-network-go", + }, + Attachable: true, + } + + resp, err := c.cli.NetworkCreate(ctx, netCfg.NetworkName, createOpts) + if err != nil { + return fmt.Errorf("failed to create network %s: %w", netCfg.NetworkName, err) + } + + _ = resp // response contains ID and warnings + return nil +} + +// RemoveNetwork removes a Docker network +func (c *Client) RemoveNetwork(ctx context.Context, networkName string) error { + err := c.cli.NetworkRemove(ctx, networkName) + if err != nil { + return fmt.Errorf("failed to remove network %s: %w", networkName, err) + } + return nil +} + +// ConnectContainer connects a container to a network with a specific IP +func (c *Client) ConnectContainer(ctx context.Context, containerName, networkName, ip string) error { + endpointSettings := &network.EndpointSettings{ + IPAMConfig: &network.EndpointIPAMConfig{ + IPv4Address: ip, + }, + } + + err := c.cli.NetworkConnect(ctx, networkName, containerName, endpointSettings) + if err != nil { + return fmt.Errorf("failed to connect container %s to network %s: %w", containerName, networkName, err) + } + return nil +} + +// DisconnectContainer disconnects a container from a network +func (c *Client) DisconnectContainer(ctx context.Context, containerName, networkName string) error { + err := c.cli.NetworkDisconnect(ctx, networkName, containerName, true) + if err != nil { + return fmt.Errorf("failed to disconnect container %s from network %s: %w", containerName, networkName, err) + } + return nil +} + +// InspectContainer returns the container's details +func (c *Client) InspectContainer(ctx context.Context, containerName string) (*types.ContainerJSON, error) { + container, err := c.cli.ContainerInspect(ctx, containerName) + if err != nil { + return nil, fmt.Errorf("failed to inspect container %s: %w", containerName, err) + } + return &container, nil +} + +// WaitForContainerRunning waits until a container is in running state, with a timeout +func (c *Client) WaitForContainerRunning(ctx context.Context, containerName string, timeout time.Duration) error { + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + ticker := time.NewTicker(500 * time.Millisecond) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return fmt.Errorf("timeout waiting for container %s to be running", containerName) + case <-ticker.C: + container, err := c.cli.ContainerInspect(ctx, containerName) + if err != nil { + continue + } + if container.State != nil && container.State.Running { + return nil + } + } + } +} + +func (c *Client) GetContainerPID(ctx context.Context, containerName string) (int, error) { + cont, err := c.cli.ContainerInspect(ctx, containerName) + if err != nil { + return 0, fmt.Errorf("failed to inspect container %s: %w", containerName, err) + } + if cont.State == nil || !cont.State.Running { + return 0, fmt.Errorf("container %s is not running", containerName) + } + return cont.State.Pid, nil +} + +// AddRouteInContainer adds a route inside a container's network namespace using nsenter +func (c *Client) AddRouteInContainer(ctx context.Context, containerName, network, gateway string) error { + pid, err := c.GetContainerPID(ctx, containerName) + if err != nil { + return fmt.Errorf("failed to get PID for container %s: %w", containerName, err) + } + + args := []string{ + "-t", fmt.Sprintf("%d", pid), + "-n", "--", + "ip", "route", "add", network, "via", gateway, + } + + cmd := exec.Command("nsenter", args...) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to add route in container %s: %w\noutput: %s", containerName, err, string(output)) + } + return nil +} diff --git a/network-go/firewall/firewall.go b/network-go/firewall/firewall.go new file mode 100644 index 0000000..fc3d2e3 --- /dev/null +++ b/network-go/firewall/firewall.go @@ -0,0 +1,216 @@ +package firewall + +import ( + "context" + "log" + "net" + "strconv" + "time" + + "firewall_containers/network-go/config" + "firewall_containers/network-go/docker" + "firewall_containers/network-go/iptables" + "firewall_containers/network-go/resolver" +) + +// Orchestrator reconciles the networks.json configuration into Docker networks +// and iptables firewall rules +type Orchestrator struct { + dockerClient *docker.Client + iptablesMgr *iptables.Manager + resolver *resolver.Resolver + debug bool +} + +// NewOrchestrator creates a new firewall orchestrator +func NewOrchestrator(dockerClient *docker.Client, iptablesMgr *iptables.Manager, cfg *config.NetworksConfig) *Orchestrator { + return &Orchestrator{ + dockerClient: dockerClient, + iptablesMgr: iptablesMgr, + resolver: resolver.NewResolver(cfg), + } +} + +// ReconcileAll runs the full reconciliation: networks, container connections, and firewall rules +func (o *Orchestrator) ReconcileAll(ctx context.Context, cfg *config.NetworksConfig) { + log.Println("FIREWALL: starting full reconciliation") + + // Update resolver with latest config + o.resolver.SetConfig(cfg) + + // Step 0: Enable IP forwarding + log.Println("FIREWALL: enabling IP forwarding") + if err := o.iptablesMgr.EnsureIPForward(); err != nil { + log.Printf("FIREWALL: ERROR enabling ip_forward: %v", err) + } + + // Step 1: Ensure all defined networks exist + o.reconcileNetworks(ctx, cfg) + + // Step 2: Connect containers to networks with assigned IPs + o.reconcileIPs(ctx, cfg) + + // Step 3: Reconcile firewall policies + o.reconcilePolicies(ctx, cfg) + + log.Println("FIREWALL: full reconciliation completed") +} + +// reconcileNetworks creates Docker networks if they don't exist +func (o *Orchestrator) reconcileNetworks(ctx context.Context, cfg *config.NetworksConfig) { + for name, netCfg := range cfg.Networks { + log.Printf("FIREWALL: ensuring network %s (%s, subnet=%s, gateway=%s)", name, netCfg.NetworkName, netCfg.Subnet, netCfg.Gateway) + if err := o.dockerClient.EnsureNetwork(ctx, netCfg); err != nil { + log.Printf("FIREWALL: ERROR ensuring network %s: %v", name, err) + } + } +} + +// reconcileIPs connects containers to networks with their assigned IPs +func (o *Orchestrator) reconcileIPs(ctx context.Context, cfg *config.NetworksConfig) { + for ipStr, ipCfg := range cfg.IPs { + networkName := findNetworkForIP(cfg, ipStr) + if networkName == "" { + log.Printf("FIREWALL: WARNING no network found for IP %s (container=%s)", ipStr, ipCfg.ContainerName) + continue + } + + log.Printf("FIREWALL: connecting container %s to network %s with IP %s", ipCfg.ContainerName, networkName, ipStr) + + waitCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + if err := o.dockerClient.WaitForContainerRunning(waitCtx, ipCfg.ContainerName, 10*time.Second); err != nil { + log.Printf("FIREWALL: WARNING container %s not running yet: %v, connecting anyway", ipCfg.ContainerName, err) + } + cancel() + + if err := o.dockerClient.ConnectContainer(ctx, ipCfg.ContainerName, networkName, ipStr); err != nil { + log.Printf("FIREWALL: ERROR connecting container %s to %s: %v", ipCfg.ContainerName, networkName, err) + } + } +} + +// reconcilePolicies translates PolicyConfig entries into iptables rules +func (o *Orchestrator) reconcilePolicies(ctx context.Context, cfg *config.NetworksConfig) { + for i, policy := range cfg.Policies { + log.Printf("FIREWALL: processing policy[%d]", i) + + proto := policy.Proto + if proto == "" { + proto = "tcp" + } + port := strconv.Itoa(policy.Port) + + // Build comment for iptables (matches shell script's NAME-COMMENT pattern) + comment := policy.ServiceName + if policy.Name != "" { + comment = policy.Name + "-" + policy.ServiceName + } + + // CASE 1: Rule with "from" field — this is a FORWARD ACCEPT rule + if policy.From != "" { + o.applyForwardRule(ctx, cfg, policy, proto, port, comment) + continue + } + + // CASE 2: Rule with "nat" field — this is a DNAT/MASQUERADE rule + if policy.Nat != "" { + o.applyNATRule(ctx, cfg, policy, proto, port, comment) + continue + } + + // Unhandled pattern + log.Printf("FIREWALL: policy[%d] unhandled pattern — service=%s container=%s selector=%s from=%s to=%s port=%d proto=%s nat=%s", + i, policy.ServiceName, policy.ContainerName, policy.Selector, policy.From, policy.To, policy.Port, policy.Proto, policy.Nat) + } +} + +func (o *Orchestrator) applyForwardRule(ctx context.Context, cfg *config.NetworksConfig, policy config.PolicyConfig, proto, port, comment string) { + sourceIP := o.resolveIP(policy.From) + targetIP := "" + if policy.To != "" { + targetIP = o.resolveIP(policy.To) + } + + // Determine the chain: use DOCKER-USER (iptables-legacy) or FORWARD + chain := "FORWARD" + if o.iptablesMgr.Binary() == "/usr/sbin/iptables-legacy" { + chain = "DOCKER-USER" + } + + // Ensure established/related rule exists at the top + if err := o.iptablesMgr.EnsureEstablishedRelated(chain); err != nil { + log.Printf("FIREWALL: ERROR ensuring established/related rule in %s: %v", chain, err) + } + + // Insert the FORWARD ACCEPT rule + if err := o.iptablesMgr.InsertForwardAccept(chain, sourceIP, targetIP, proto, "", port, comment); err != nil { + log.Printf("FIREWALL: ERROR inserting FORWARD ACCEPT rule: %v", err) + } +} + +func (o *Orchestrator) applyNATRule(ctx context.Context, cfg *config.NetworksConfig, policy config.PolicyConfig, proto, port, comment string) { + selector := policy.Selector + to := policy.To + + // Resolve "to" as target IP + targetIP := o.resolveIP(to) + + if targetIP == "" { + log.Printf("FIREWALL: WARNING cannot resolve target %s for nat policy", to) + return + } + + if policy.Nat == "dnat" { + // Get the container PID for nsenter + pid, err := o.dockerClient.GetContainerPID(ctx, selector) + if err != nil { + log.Printf("FIREWALL: WARNING cannot get PID for container %s: %v, trying host rules", selector, err) + // Fall back to host-level PREROUTING + if policy.Iface != "" { + if err := o.iptablesMgr.InsertPreroutingRuleOnInterface(policy.Iface, proto, port, targetIP, port, comment); err != nil { + log.Printf("FIREWALL: ERROR inserting interface PREROUTING rule: %v", err) + } + } + return + } + + // Insert DNAT PREROUTING inside container namespace + if err := o.iptablesMgr.InsertPreroutingRuleInContainer(pid, "0.0.0.0/0", proto, port, targetIP, port, comment); err != nil { + log.Printf("FIREWALL: ERROR inserting container PREROUTING rule: %v", err) + } + } +} + +// resolveIP resolves a name or IP string to an IP address using networks.json config +func (o *Orchestrator) resolveIP(name string) string { + // If it's already an IP, return it as CIDR + if config.IsIP(name) { + return config.ToCIDR(name) + } + + // Use the resolver which looks up from networks.json + ips := o.resolver.Resolve(name) + if len(ips) > 0 { + return ips[0] + } + + return "" +} + +// findNetworkForIP finds the network name that contains the given IP in its subnet +func findNetworkForIP(cfg *config.NetworksConfig, ip string) string { + for _, netCfg := range cfg.Networks { + subnet, err := netCfg.ParseCIDR() + if err != nil { + continue + } + parsedIP := net.ParseIP(ip) + if parsedIP == nil { + continue + } + if subnet.Contains(parsedIP) { + return netCfg.NetworkName + } + } + return "" +} \ No newline at end of file diff --git a/network-go/go.mod b/network-go/go.mod new file mode 100644 index 0000000..cd0502b --- /dev/null +++ b/network-go/go.mod @@ -0,0 +1,35 @@ +module firewall_containers/network-go + +go 1.26.4 + +require github.com/docker/docker v28.5.2+incompatible + +require ( + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/go-connections v0.7.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/sys/atomicwriter v0.1.0 // indirect + github.com/moby/term v0.5.2 // indirect + github.com/morikuni/aec v1.1.0 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/pkg/errors v0.9.1 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect + go.opentelemetry.io/otel v1.44.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/time v0.15.0 // indirect + gotest.tools/v3 v3.5.2 // indirect +) diff --git a/network-go/go.sum b/network-go/go.sum new file mode 100644 index 0000000..eae94a1 --- /dev/null +++ b/network-go/go.sum @@ -0,0 +1,99 @@ +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= +github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c= +github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= +github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/morikuni/aec v1.1.0 h1:vBBl0pUnvi/Je71dsRrhMBtreIqNMYErSAbEeb8jrXQ= +github.com/morikuni/aec v1.1.0/go.mod h1:xDRgiq/iw5l+zkao76YTKzKttOp2cwPEne25HDkJnBw= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 h1:lgh3PiVrRUWMLOVSkQicxzZll5NjF1r+AtsX1XRIHw0= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0/go.mod h1:5Cnhth3m/AgOeTgE3ex12pPmiu/gGtZit03kSzx9X7s= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= +go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= +google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= diff --git a/network-go/implementation.md b/network-go/implementation.md new file mode 100644 index 0000000..463c516 --- /dev/null +++ b/network-go/implementation.md @@ -0,0 +1,156 @@ +# network-go — Docker Network & Firewall Manager + +A Go-based replacement for the `firewall/firewall-add` shell script. It watches +`/etc/user/config/networks.json` for changes and reconciles Docker networks, +container connections, and iptables firewall rules using the Docker SDK. + +## Project Structure + +``` +network-go/ +├── main.go Entry point: watches config, orchestrates reconciliation +├── config/ +│ └── config.go Parses /etc/user/config/networks.json into typed structs +├── docker/ +│ └── docker.go Docker SDK wrapper (network create, container connect, +│ container PID for nsenter, route management) +├── firewall/ +│ └── firewall.go Orchestrator: translates policies → iptables rules +├── iptables/ +│ └── iptables.go Manages iptables CLI: PREROUTING DNAT, POSTROUTING MASQUERADE, +│ FORWARD ACCEPT, nsenter for container network namespaces +├── resolver/ +│ └── resolver.go Resolves names → IPs using networks.json config +├── watcher/ +│ └── watcher.go Periodic file change detection via MD5 hash polling +├── go.mod / go.sum Module definition with Docker SDK dependency +└── .gitignore +``` + +## Packages + +### config +- `NetworksConfig`, `NetworkConfig`, `IPConfig`, `PolicyConfig` structs matching `networks.json` +- `FirewallRule` struct for resolved executable rules +- Helpers: `IsIP`, `ToCIDR`, `NetworkPrefix`, `ParseCIDR`, `Load` + +### docker +- `Client` wrapping `github.com/docker/docker/client` +- `EnsureNetwork` — creates a Docker network if it doesn't exist +- `ConnectContainer` / `DisconnectContainer` — manages container ↔ network membership +- `GetContainerPID` — returns the container PID for nsenter +- `AddRouteInContainer` — adds routes inside a container namespace via nsenter +- `WaitForContainerRunning` — polls until a container is running + +### resolver +- Resolves names → IPs using the `networks.json` config only +- Looks up by `container_name` and `selector` in the `ips` section +- Falls back to prefix matching (e.g. `smarthost-backend-1` → prefix `smarthost`) + +### iptables +- Auto-detects `iptables-legacy` vs `iptables` (matching shell script logic) +- `EnsureIPForward` — enables `/proc/sys/net/ipv4/ip_forward` +- `EnsureEstablishedRelated` — inserts ESTABLISHED,RELATED ACCEPT at top of chain +- `InsertPreroutingRule` / `InsertPreroutingRuleInContainer` — DNAT rules +- `InsertPostroutingMasquerade` — MASQUERADE rules +- `InsertForwardAccept` — FORWARD/DOCKER-USER ACCEPT rules +- Rule deletion by line-number + grep pattern matching (matching shell script) +- nsenter-based execution inside container network namespaces + +### firewall +- `Orchestrator` ties all packages together +- `ReconcileAll` runs the full reconciliation cycle: + 1. Enable IP forwarding + 2. Ensure all Docker networks from config + 3. Connect containers to networks with assigned IPs + 4. Apply firewall policies as iptables rules +- Policy → rule mapping: + - `from` field → FORWARD ACCEPT rule on DOCKER-USER or FORWARD chain + - `nat: dnat` field → PREROUTING DNAT inside container namespace via nsenter + - Interface-based rules (e.g. `wg0`) → host-level PREROUTING DNAT + +### watcher +- `FileWatcher` polls a file at a configurable interval +- Computes MD5 hash on each tick +- Fires `onChange` callback when hash changes +- `Start` / `Stop` for lifecycle management + +## Configuration + +The file `/etc/user/config/networks.json` defines: + +```json +{ + "networks": { + "smarthost-loadbalancer": { + "network_name": "smarthost-loadbalancer", + "subnet": "172.18.103.0/24", + "gateway": "172.18.103.1" + } + }, + "ips": { + "172.18.103.2": { + "ip": "172.18.103.2", + "container_name": "smarthostloadbalancer", + "selector": "smarthostloadbalancer", + "service_name": "smarthost-proxy" + } + }, + "policies": [ + { + "service_name": "smarthost-proxy", + "container_name": "smarthost_loadbalancer", + "selector": "smarthostloadbalancer", + "from": "publicbackend", + "port": 80, + "proto": "tcp" + }, + { + "service_name": "smarthost-proxy", + "container_name": "smarthost_loadbalancer", + "selector": "smarthostloadbalancer", + "name": "wireguardproxy", + "iface": "wg0", + "nat": "dnat", + "to": "smarthostloadbalancer", + "port": 80, + "proto": "tcp" + } + ] +} +``` + +## Environment Variables + +| Variable | Default | Description | +|---|---|---| +| `NETWORKS_CONFIG_PATH` | `/etc/user/config/networks.json` | Path to the configuration file | +| `WATCH_PERIOD_SECONDS` | `30` | Polling interval in seconds for config file changes | +| `DEBUG` | `false` | Enable debug output (`1` or `true`) | + +## Key Differences from Shell Script + +| Shell Script (`firewall-add`) | Go Implementation | +|---|---| +| `docker ps --format`, `docker inspect` | Docker SDK (`github.com/docker/docker`) | +| `/etc/dns/hosts.local` lookup | networks.json config lookup | +| `$SOURCE` / `$TARGET` env vars | `from` / `to` fields in `networks.json` | +| iptables via bash + grep + awk | Go `os/exec` with structured line matching | +| `nsenter -t PID -n` for container iptables | `nsenter` via `os/exec` in `iptables.Manager` | +| `$ROUTE=true` + `ip route` | `docker.Client.AddRouteInContainer()` | +| Manual per-rule invocation | `Orchestrator.ReconcileAll()` batch reconciliation | + +## Build & Run + +```bash +go build -o network-go . +./network-go +``` + +In Docker: +```bash +go build -o network-go . +# Mount Docker socket and config +docker run -v /var/run/docker.sock:/var/run/docker.sock \ + -v /etc/user/config:/etc/user/config \ + network-go \ No newline at end of file diff --git a/network-go/iptables/iptables.go b/network-go/iptables/iptables.go new file mode 100644 index 0000000..c7d2a3c --- /dev/null +++ b/network-go/iptables/iptables.go @@ -0,0 +1,362 @@ +package iptables + +import ( + "fmt" + "os/exec" + "strings" +) + +// Manager manages iptables rules via the iptables/iptables-legacy CLI +type Manager struct { + binary string // /usr/sbin/iptables or /usr/sbin/iptables-legacy + debug bool +} + +// NewManager creates a new iptables manager, auto-detecting the binary +func NewManager(debug bool) *Manager { + m := &Manager{debug: debug} + m.detectBinary() + return m +} + +// detectBinary checks if iptables-legacy is available (matching shell script logic) +func (m *Manager) detectBinary() { + cmd := exec.Command("/usr/sbin/iptables-legacy", "-L") + output, err := cmd.CombinedOutput() + if err == nil && strings.Contains(string(output), "DOCKER-USER") { + m.binary = "/usr/sbin/iptables-legacy" + return + } + m.binary = "/usr/sbin/iptables" +} + +// Binary returns the detected iptables binary path +func (m *Manager) Binary() string { + return m.binary +} + +// run executes an iptables command on the host +func (m *Manager) run(args ...string) error { + if m.debug { + fmt.Printf("[IPTABLES DEBUG] %s %s\n", m.binary, strings.Join(args, " ")) + } + cmd := exec.Command(m.binary, args...) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("iptables %s failed: %w\noutput: %s", strings.Join(args, " "), err, string(output)) + } + return nil +} + +// runInContainer executes an iptables command inside a container's network namespace via nsenter +func (m *Manager) runInContainer(pid int, table string, args ...string) error { + iptPath := "/sbin/iptables-legacy" + if !strings.Contains(m.binary, "legacy") { + iptPath = "/sbin/iptables" + } + + fullArgs := []string{"-t", fmt.Sprintf("%d", pid), "-n", "--", iptPath} + if table != "" { + fullArgs = append(fullArgs, "-t", table) + } + fullArgs = append(fullArgs, args...) + + if m.debug { + fmt.Printf("[IPTABLES DEBUG] nsenter %s\n", strings.Join(fullArgs, " ")) + } + cmd := exec.Command("nsenter", fullArgs...) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("nsenter iptables failed: %w\noutput: %s", err, string(output)) + } + return nil +} + +// EnsureIPForward enables IP forwarding on the host +func (m *Manager) EnsureIPForward() error { + cmd := exec.Command("sh", "-c", "echo 1 > /proc/sys/net/ipv4/ip_forward") + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to enable ip_forward: %w\noutput: %s", err, string(output)) + } + return nil +} + +// EnsureEstablishedRelated inserts an ESTABLISHED,RELATED accept rule at the top of a chain +// if it doesn't already exist +func (m *Manager) EnsureEstablishedRelated(chain string) error { + checkArgs := []string{"-w", "-n", "-L", chain} + cmd := exec.Command(m.binary, checkArgs...) + output, err := cmd.Output() + if err != nil { + // Chain may not exist, create it + return nil + } + + // Only insert if ESTABLISHED,RELATED rule is not present + if !strings.Contains(string(output), "ESTABLISHED") || !strings.Contains(string(output), "RELATED") { + args := []string{"-w", "-I", chain, "-m", "state", "--state", "established,related", "-j", "ACCEPT"} + return m.run(args...) + } + return nil +} + +// DeleteLine deletes a specific line number from a chain +func (m *Manager) DeleteLine(chain string, lineNum string) error { + args := []string{"-w", "-D", chain, lineNum} + return m.run(args...) +} + +// DeleteLineInContainer deletes a specific line number from a chain inside a container namespace +func (m *Manager) DeleteLineInContainer(pid int, table, chain, lineNum string) error { + args := []string{"-D", chain, lineNum} + return m.runInContainer(pid, table, args...) +} + +// getLineNumbers returns line numbers matching certain criteria in a chain/table +// This implements the grep logic from the shell script: iptables -w --line-number -n -L $CHAIN | grep ... +func (m *Manager) getLineNumbers(chain, table string, grepPatterns ...string) []string { + args := []string{"-w", "--line-number", "-n", "-L", chain} + if table != "" { + args = []string{"-w", "-t", table, "--line-number", "-n", "-L", chain} + } + + cmd := exec.Command(m.binary, args...) + output, err := cmd.Output() + if err != nil { + return nil + } + + lines := strings.Split(string(output), "\n") + var matchingLines []string + for _, line := range lines { + matchesAll := true + for _, pattern := range grepPatterns { + if !strings.Contains(line, pattern) { + matchesAll = false + break + } + } + if matchesAll { + fields := strings.Fields(line) + if len(fields) > 0 { + matchingLines = append(matchingLines, fields[0]) + } + } + } + return matchingLines +} + +// deleteMatchingLines deletes all lines in a chain matching the given patterns +func (m *Manager) deleteMatchingLines(chain, table string, grepPatterns ...string) error { + lines := m.getLineNumbers(chain, table, grepPatterns...) + // Reverse order (highest line first) so deletions don't shift line numbers + for i := len(lines) - 1; i >= 0; i-- { + if err := m.DeleteLine(chain, lines[i]); err != nil { + return err + } + } + return nil +} + +// deleteMatchingLinesInContainer deletes matching lines inside a container namespace +func (m *Manager) deleteMatchingLinesInContainer(pid int, table, chain string, grepPatterns ...string) error { + // For container namespaces, we use a different approach: list via nsenter + grep + iptPath := "/sbin/iptables-legacy" + if !strings.Contains(m.binary, "legacy") { + iptPath = "/sbin/iptables" + } + + nsenterArgs := []string{"-t", fmt.Sprintf("%d", pid), "-n", "--", iptPath, "-w", "--line-number", "-n", "-t", table, "-L", chain} + cmd := exec.Command("nsenter", nsenterArgs...) + output, err := cmd.Output() + if err != nil { + return nil + } + + lines := strings.Split(string(output), "\n") + var matchingLines []string + for _, line := range lines { + matchesAll := true + for _, pattern := range grepPatterns { + if !strings.Contains(line, pattern) { + matchesAll = false + break + } + } + if matchesAll { + fields := strings.Fields(line) + if len(fields) > 0 { + matchingLines = append(matchingLines, fields[0]) + } + } + } + + // Delete in reverse order + for i := len(matchingLines) - 1; i >= 0; i-- { + if err := m.DeleteLineInContainer(pid, table, chain, matchingLines[i]); err != nil { + return err + } + } + return nil +} + +// InsertPreroutingRule inserts a DNAT PREROUTING rule on the host +func (m *Manager) InsertPreroutingRule(sourceIP, proto, sourcePort, targetIP, targetPort, comment string) error { + // First, delete existing matching rules + patterns := []string{"DNAT", sourcePort, targetIP, targetPort, comment} + if err := m.deleteMatchingLines("PREROUTING", "nat", patterns...); err != nil { + return fmt.Errorf("failed to delete old PREROUTING rules: %w", err) + } + + // Insert the new rule + args := []string{ + "-w", "-t", "nat", "-I", "PREROUTING", + "-d", sourceIP, + "-p", proto, + "--dport", sourcePort, + "-m", "comment", "--comment", comment, + "-j", "DNAT", "--to", targetIP + ":" + targetPort, + } + return m.run(args...) +} + +// InsertPreroutingRuleOnInterface inserts a DNAT PREROUTING rule on a specific interface +func (m *Manager) InsertPreroutingRuleOnInterface(iface, proto, sourcePort, targetIP, targetPort, comment string) error { + args := []string{ + "-w", "-I", "PREROUTING", + "-i", iface, + "-p", proto, + "--dport", sourcePort, + "-m", "comment", "--comment", comment, + "-j", "DNAT", "--to", targetIP + ":" + targetPort, + } + return m.run(args...) +} + +// InsertPostroutingMasquerade inserts a MASQUERADE POSTROUTING rule on the host +func (m *Manager) InsertPostroutingMasquerade(sourceCIDR, proto, sourcePort, comment string) error { + // Delete existing matching rules first + patterns := []string{"MASQUERADE", comment, sourceCIDR, sourcePort} + if err := m.deleteMatchingLines("POSTROUTING", "nat", patterns...); err != nil { + return fmt.Errorf("failed to delete old POSTROUTING rules: %w", err) + } + + args := []string{ + "-w", "-t", "nat", "-I", "POSTROUTING", + "-s", sourceCIDR, + "-p", proto, + "--sport", sourcePort, + "-m", "comment", "--comment", comment, + "-j", "MASQUERADE", + } + return m.run(args...) +} + +// InsertPostroutingMasqueradeForTarget inserts a MASQUERADE POSTROUTING rule for a target +func (m *Manager) InsertPostroutingMasqueradeForTarget(targetCIDR, proto, targetPort, comment string) error { + patterns := []string{"MASQUERADE", comment, targetCIDR, targetPort} + if err := m.deleteMatchingLines("POSTROUTING", "nat", patterns...); err != nil { + return fmt.Errorf("failed to delete old POSTROUTING rules: %w", err) + } + + args := []string{ + "-w", "-t", "nat", "-I", "POSTROUTING", + "-d", targetCIDR, + "-p", proto, + "--dport", targetPort, + "-m", "comment", "--comment", comment, + "-j", "MASQUERADE", + } + return m.run(args...) +} + +// InsertForwardAccept inserts a FORWARD ACCEPT rule on the host +func (m *Manager) InsertForwardAccept(chain, sourceIP, targetIP, proto, sourcePort, targetPort, comment string) error { + // Build grep patterns to match existing rules + var grepPatterns []string + grepPatterns = append(grepPatterns, proto) + if sourceIP != "" { + grepPatterns = append(grepPatterns, sourceIP) + } + if targetIP != "" { + grepPatterns = append(grepPatterns, targetIP) + } + if sourcePort != "" { + grepPatterns = append(grepPatterns, sourcePort) + } + if targetPort != "" { + grepPatterns = append(grepPatterns, targetPort) + } + + // Delete old matching rules + if err := m.deleteMatchingLines(chain, "", grepPatterns...); err != nil { + return fmt.Errorf("failed to delete old FORWARD rules: %w", err) + } + + // Build iptables args + args := []string{"-w", "-I", chain, "-p", proto} + if sourceIP != "" { + args = append(args, "-s", sourceIP) + } + if targetIP != "" { + args = append(args, "-d", targetIP) + } + if sourcePort != "" { + args = append(args, "--sport", sourcePort) + } + if targetPort != "" { + args = append(args, "--dport", targetPort) + } + args = append(args, "-m", "comment", "--comment", comment, "-j", "ACCEPT") + + return m.run(args...) +} + +// DeleteForwardAccept deletes a FORWARD ACCEPT rule by comment +func (m *Manager) DeleteForwardAccept(chain, comment string) error { + lines := m.getLineNumbers(chain, "", comment) + for i := len(lines) - 1; i >= 0; i-- { + if err := m.DeleteLine(chain, lines[i]); err != nil { + return err + } + } + return nil +} + +// InsertPreroutingRuleInContainer inserts a DNAT PREROUTING rule inside a container namespace +func (m *Manager) InsertPreroutingRuleInContainer(pid int, sourceIP, proto, sourcePort, targetIP, targetPort, comment string) error { + // Delete existing first + patterns := []string{"DNAT", sourcePort, targetIP, targetPort, comment} + if err := m.deleteMatchingLinesInContainer(pid, "nat", "PREROUTING", patterns...); err != nil { + return fmt.Errorf("failed to delete old container PREROUTING rules: %w", err) + } + + args := []string{ + "-I", "PREROUTING", + "-d", sourceIP, + "-p", proto, + "--dport", sourcePort, + "-m", "comment", "--comment", comment, + "-j", "DNAT", "--to", targetIP + ":" + targetPort, + } + return m.runInContainer(pid, "nat", args...) +} + +// InsertPostroutingMasqueradeInContainer inserts a MASQUERADE POSTROUTING rule inside a container namespace +func (m *Manager) InsertPostroutingMasqueradeInContainer(pid int, sourceCIDR, proto, sourcePort, comment string) error { + patterns := []string{"MASQUERADE", comment, sourceCIDR, sourcePort} + if err := m.deleteMatchingLinesInContainer(pid, "nat", "POSTROUTING", patterns...); err != nil { + return fmt.Errorf("failed to delete old container POSTROUTING rules: %w", err) + } + + args := []string{ + "-I", "POSTROUTING", + "-s", sourceCIDR, + "-p", proto, + "--sport", sourcePort, + "-m", "comment", "--comment", comment, + "-j", "MASQUERADE", + } + return m.runInContainer(pid, "nat", args...) +} \ No newline at end of file diff --git a/network-go/main.go b/network-go/main.go new file mode 100644 index 0000000..502a0a7 --- /dev/null +++ b/network-go/main.go @@ -0,0 +1,107 @@ +package main + +import ( + "context" + "log" + "os" + "os/signal" + "syscall" + "time" + + "firewall_containers/network-go/config" + "firewall_containers/network-go/docker" + "firewall_containers/network-go/firewall" + "firewall_containers/network-go/iptables" + "firewall_containers/network-go/watcher" +) + +// Config path - can be overridden via environment variable +const defaultConfigPath = "/etc/user/config/networks.json" + +// Watch period - can be overridden via environment variable +const defaultWatchPeriod = 30 * time.Second + +func getConfigPath() string { + path := os.Getenv("NETWORKS_CONFIG_PATH") + if path == "" { + return defaultConfigPath + } + return path +} + +func getWatchPeriod() time.Duration { + periodStr := os.Getenv("WATCH_PERIOD_SECONDS") + if periodStr == "" { + return defaultWatchPeriod + } + seconds, err := time.ParseDuration(periodStr + "s") + if err != nil { + log.Printf("MAIN: invalid WATCH_PERIOD_SECONDS=%s, using default %s", periodStr, defaultWatchPeriod) + return defaultWatchPeriod + } + return seconds +} + +func getDebug() bool { + return os.Getenv("DEBUG") == "1" || os.Getenv("DEBUG") == "true" +} + +func main() { + log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile) + log.Println("MAIN: starting network-go firewall container manager") + + configPath := getConfigPath() + watchPeriod := getWatchPeriod() + debug := getDebug() + + log.Printf("MAIN: config path = %s", configPath) + log.Printf("MAIN: watch period = %s", watchPeriod) + log.Printf("MAIN: debug = %v", debug) + + // Create Docker client (uses DOCKER_HOST env var automatically) + dockerClient, err := docker.NewClient() + if err != nil { + log.Fatalf("MAIN: failed to create Docker client: %v", err) + } + defer dockerClient.Close() + + // Create iptables manager + iptablesMgr := iptables.NewManager(debug) + + ctx := context.Background() + + // Load initial config + cfg, err := config.Load(configPath) + if err != nil { + log.Fatalf("MAIN: failed to load initial config: %v", err) + } + + // Create the firewall orchestrator (needs config for resolver) + orchestrator := firewall.NewOrchestrator(dockerClient, iptablesMgr, cfg) + + // Run full reconciliation + orchestrator.ReconcileAll(ctx, cfg) + + // Set up file watcher to detect changes and re-reconcile + onChange := func() { + log.Println("MAIN: config file change detected, reloading and reconciling") + newCfg, err := config.Load(configPath) + if err != nil { + log.Printf("MAIN: failed to reload config: %v", err) + return + } + orchestrator.ReconcileAll(ctx, newCfg) + } + + fileWatcher := watcher.NewFileWatcher(configPath, watchPeriod, onChange) + fileWatcher.Start() + + // Wait for shutdown signal + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + + sig := <-sigCh + log.Printf("MAIN: received signal %v, shutting down", sig) + fileWatcher.Stop() + log.Println("MAIN: shutdown complete") +} \ No newline at end of file diff --git a/network-go/resolver/resolver.go b/network-go/resolver/resolver.go new file mode 100644 index 0000000..c5a867a --- /dev/null +++ b/network-go/resolver/resolver.go @@ -0,0 +1,65 @@ +package resolver + +import ( + "log" + "strings" + + "firewall_containers/network-go/config" +) + +// Resolver resolves names to IPs using the networks.json configuration +type Resolver struct { + cfg *config.NetworksConfig + retries int +} + +// NewResolver creates a new name resolver backed by the networks.json config +func NewResolver(cfg *config.NetworksConfig) *Resolver { + return &Resolver{ + cfg: cfg, + retries: 2, + } +} + +// SetConfig updates the config used for name resolution +func (r *Resolver) SetConfig(cfg *config.NetworksConfig) { + r.cfg = cfg +} + +// SetRetries sets the number of retries for resolution +func (r *Resolver) SetRetries(n int) { + r.retries = n +} + +// Resolve resolves a name to one or more IP addresses +// It looks up the name in the networks.json config by container_name and selector fields +func (r *Resolver) Resolve(name string) []string { + if r.cfg == nil { + return nil + } + + var ips []string + + // Look up by container_name and selector in the IPs section + for _, ipCfg := range r.cfg.IPs { + if ipCfg.ContainerName == name || ipCfg.Selector == name { + ips = append(ips, ipCfg.IP) + } + } + + // If no exact match, try prefix matching (extract prefix before first dash) + if len(ips) == 0 && strings.Contains(name, "-") { + prefix := name[:strings.Index(name, "-")] + for _, ipCfg := range r.cfg.IPs { + if strings.HasPrefix(ipCfg.ContainerName, prefix) || strings.HasPrefix(ipCfg.Selector, prefix) { + ips = append(ips, ipCfg.IP) + } + } + } + + if len(ips) == 0 { + log.Printf("RESOLVER: no IP found for %s in networks.json config", name) + } + + return ips +} \ No newline at end of file diff --git a/network-go/watcher/watcher.go b/network-go/watcher/watcher.go new file mode 100644 index 0000000..b89541e --- /dev/null +++ b/network-go/watcher/watcher.go @@ -0,0 +1,83 @@ +package watcher + +import ( + "crypto/md5" + "fmt" + "log" + "os" + "time" +) + +// FileWatcher periodically checks a file for changes using MD5 hash +type FileWatcher struct { + path string + period time.Duration + lastHash string + onChange func() + stopCh chan struct{} +} + +// NewFileWatcher creates a new file watcher that polls the file at the given period +func NewFileWatcher(path string, period time.Duration, onChange func()) *FileWatcher { + return &FileWatcher{ + path: path, + period: period, + lastHash: "", + onChange: onChange, + stopCh: make(chan struct{}), + } +} + +// hashFile computes an MD5 hash of the file contents +func (fw *FileWatcher) hashFile() (string, error) { + data, err := os.ReadFile(fw.path) + if err != nil { + return "", fmt.Errorf("failed to read file %s: %w", fw.path, err) + } + return fmt.Sprintf("%x", md5.Sum(data)), nil +} + +// Start begins polling the file for changes in a goroutine +func (fw *FileWatcher) Start() { + // Compute initial hash + hash, err := fw.hashFile() + if err != nil { + log.Printf("WATCHER: initial hash computation failed for %s: %v", fw.path, err) + } else { + fw.lastHash = hash + } + + go func() { + ticker := time.NewTicker(fw.period) + defer ticker.Stop() + + log.Printf("WATCHER: started watching %s every %s", fw.path, fw.period) + + for { + select { + case <-fw.stopCh: + log.Printf("WATCHER: stopped watching %s", fw.path) + return + case <-ticker.C: + hash, err := fw.hashFile() + if err != nil { + log.Printf("WATCHER: failed to hash %s: %v", fw.path, err) + continue + } + + if hash != fw.lastHash { + log.Printf("WATCHER: detected change in %s", fw.path) + fw.lastHash = hash + if fw.onChange != nil { + fw.onChange() + } + } + } + } + }() +} + +// Stop signals the watcher goroutine to stop +func (fw *FileWatcher) Stop() { + close(fw.stopCh) +} \ No newline at end of file