added network-go project
continuous-integration/drone/push Build encountered an error

This commit is contained in:
gyurix
2026-06-08 15:34:01 +02:00
parent 9271f63dd9
commit c3de398f35
11 changed files with 1448 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
network-go
*.test
+136
View File
@@ -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
}
+187
View File
@@ -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
}
+216
View File
@@ -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 ""
}
+35
View File
@@ -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
)
+99
View File
@@ -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=
+156
View File
@@ -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
+362
View File
@@ -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...)
}
+107
View File
@@ -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")
}
+65
View File
@@ -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
}
+83
View File
@@ -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)
}