diff --git a/cmd/init/flags.go b/cmd/init/flags.go index 47529bb..9c84a5e 100644 --- a/cmd/init/flags.go +++ b/cmd/init/flags.go @@ -26,7 +26,7 @@ type InitFlags struct { // CentralHost is the SSH address of the central VM (from --central flag). // When non-empty, phases 4+5 install Redis + broker on that host and push // per-host JWTs to all other hosts. Default empty = no broker setup. - CentralHost string + CentralHost string BrokerVersion string } diff --git a/cmd/init/plan.go b/cmd/init/plan.go index 00b7ba9..ef2d86d 100644 --- a/cmd/init/plan.go +++ b/cmd/init/plan.go @@ -52,11 +52,11 @@ func runPlan(ctx context.Context, cmd *cobra.Command, flags *InitFlags) error { InstallPodman: true, Namespaces: flags.Namespaces, DefaultDenyContainers: !flags.SkipDefaultDeny, - InstallCoold: true, - CooldVersion: flags.CooldVersion, - CorrosionVersion: flags.CorrosionVersion, - CorrosionGossipPort: flags.CorrosionGossipPort, - CorrosionAPIPort: flags.CorrosionAPIPort, + InstallCoold: true, + CooldVersion: flags.CooldVersion, + CorrosionVersion: flags.CorrosionVersion, + CorrosionGossipPort: flags.CorrosionGossipPort, + CorrosionAPIPort: flags.CorrosionAPIPort, } // Build SSH runner (handles passphrase resolution). diff --git a/go.mod b/go.mod index 8f1215b..55866ef 100644 --- a/go.mod +++ b/go.mod @@ -5,12 +5,16 @@ go 1.25.0 require ( github.com/adrg/xdg v0.5.3 github.com/creativeprojects/go-selfupdate v1.5.1 + github.com/golang-jwt/jwt/v5 v5.3.1 github.com/hashicorp/go-version v1.7.0 + github.com/mattn/go-isatty v0.0.20 github.com/olekukonko/tablewriter v1.1.2 github.com/spf13/cobra v1.10.1 github.com/spf13/pflag v1.0.10 github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 + golang.org/x/crypto v0.50.0 + golang.org/x/term v0.42.0 ) require ( @@ -27,14 +31,12 @@ require ( github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-fed/httpsig v1.1.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect - github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/google/go-github/v30 v30.1.0 // indirect github.com/google/go-querystring v1.1.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-retryablehttp v0.7.8 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.19 // indirect github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect github.com/olekukonko/errors v1.1.0 // indirect @@ -49,10 +51,8 @@ require ( github.com/ulikunitz/xz v0.5.15 // indirect github.com/xanzy/go-gitlab v0.115.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.50.0 // indirect golang.org/x/oauth2 v0.32.0 // indirect golang.org/x/sys v0.43.0 // indirect - golang.org/x/term v0.42.0 // indirect golang.org/x/text v0.36.0 // indirect golang.org/x/time v0.14.0 // indirect gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect diff --git a/go.sum b/go.sum index b47b604..96f6913 100644 --- a/go.sum +++ b/go.sum @@ -105,8 +105,6 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= -golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= -golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= @@ -120,19 +118,13 @@ golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= -golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= -golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= diff --git a/internal/firewall/coold_client_test.go b/internal/firewall/coold_client_test.go index f280a99..7fa8d06 100644 --- a/internal/firewall/coold_client_test.go +++ b/internal/firewall/coold_client_test.go @@ -4,6 +4,7 @@ import ( "context" "net" "strings" + "sync" "testing" "github.com/stretchr/testify/assert" @@ -13,13 +14,18 @@ import ( // fakeCooldRunner is a minimal Runner for client-level tests. It captures // every command and replies based on substring-matched canned responses. +// mu guards calls against concurrent appends from ForEachServer's parallel +// goroutines. type fakeCooldRunner struct { + mu sync.Mutex responses map[string]string calls []string } func (f *fakeCooldRunner) Run(_ context.Context, _, _ string, _ int, cmd string) (string, string, error) { + f.mu.Lock() f.calls = append(f.calls, cmd) + f.mu.Unlock() for sub, resp := range f.responses { if strings.Contains(cmd, sub) { return resp, "", nil diff --git a/internal/firewall/discover.go b/internal/firewall/discover.go index a62634b..0baf26d 100644 --- a/internal/firewall/discover.go +++ b/internal/firewall/discover.go @@ -139,9 +139,9 @@ func DiscoverAllNamespaces( concurrency int, ) ([]Container, []ssh.ServerResult[[]Container]) { var ( - all []Container - allResults []ssh.ServerResult[[]Container] - seenHosts = map[string]struct{}{} + all []Container + allResults []ssh.ServerResult[[]Container] + seenHosts = map[string]struct{}{} ) for _, ns := range namespaces { nsContainers, results := DiscoverAll(ctx, runner, hosts, user, port, diff --git a/internal/firewall/discover_test.go b/internal/firewall/discover_test.go index 1a68d40..a80ff9e 100644 --- a/internal/firewall/discover_test.go +++ b/internal/firewall/discover_test.go @@ -3,6 +3,7 @@ package firewall import ( "context" "strings" + "sync" "testing" "github.com/stretchr/testify/assert" @@ -39,14 +40,18 @@ func TestParseDiscoverLine(t *testing.T) { } // fakeRunner is a deterministic ssh.Runner for firewall tests. Responses -// map a command substring to its canned stdout. +// map a command substring to its canned stdout. mu guards calls against +// concurrent appends from ForEachServer's parallel goroutines. type fakeRunner struct { + mu sync.Mutex responses map[string]string calls []string } func (f *fakeRunner) Run(_ context.Context, _, _ string, _ int, cmd string) (string, string, error) { + f.mu.Lock() f.calls = append(f.calls, cmd) + f.mu.Unlock() for sub, resp := range f.responses { if strings.Contains(cmd, sub) { return resp, "", nil diff --git a/internal/wireguard/plan.go b/internal/wireguard/plan.go index f8584b8..69f6a51 100644 --- a/internal/wireguard/plan.go +++ b/internal/wireguard/plan.go @@ -408,8 +408,8 @@ func buildNamespaceConfigs(host string, nsSorted []string, assignments map[strin continue } out = append(out, services.CooldNamespace{ - Name: ns, - Network: PodmanNetworkFor(ns), + Name: ns, + Network: PodmanNetworkFor(ns), BridgeGateway: MachineIP(subnet), }) } diff --git a/internal/wireguard/plan_test.go b/internal/wireguard/plan_test.go index 3231977..b40dd8e 100644 --- a/internal/wireguard/plan_test.go +++ b/internal/wireguard/plan_test.go @@ -37,13 +37,13 @@ func convergedServer(host, pubkey, peerKey, mgmtIP, contSubnet string) *ServerSt sn := mustParseCIDR(contSubnet) firewallHash := sha256Hex([]byte(FirewallServiceUnit("wg0", []string{"default"}, []*net.IPNet{sn}, false))) return &ServerState{ - Host: host, - Installed: true, - KeysExist: true, - PublicKey: pubkey, - WireGuardMgmtIP: net.ParseIP(mgmtIP).To4(), - ListenPort: 51820, - Active: true, + Host: host, + Installed: true, + KeysExist: true, + PublicKey: pubkey, + WireGuardMgmtIP: net.ParseIP(mgmtIP).To4(), + ListenPort: 51820, + Active: true, Peers: []Peer{{ PublicKey: peerKey, AllowedIPs: []string{peerMgmtForPub(peerKey), peerSubnetForPub(peerKey)}, @@ -564,11 +564,11 @@ func TestBuildPlan_PodmanRequiresNamespace(t *testing.T) { func TestBinaryVersionDrift(t *testing.T) { tests := []struct { - name string - desiredVersion string - installed bool - haveVersion string - wantDrift bool + name string + desiredVersion string + installed bool + haveVersion string + wantDrift bool }{ {"not installed", "nightly", false, "", true}, {"installed no marker", "nightly", true, "", true}, diff --git a/internal/wireguard/reconstruct_test.go b/internal/wireguard/reconstruct_test.go index 403b955..ce0cb6c 100644 --- a/internal/wireguard/reconstruct_test.go +++ b/internal/wireguard/reconstruct_test.go @@ -167,28 +167,28 @@ func TestTruncateKey(t *testing.T) { func TestProbe_NftAvailableAndBridgeTableExists_True(t *testing.T) { runner := &fakeReconRunner{ responses: map[string]string{ - "dpkg-query": "1\n", - "wg show": "", - "cat /etc/wireguard/": "", - "wg pubkey": "", - "ip -4 -o addr show": "", - "systemctl is-active wg-quick": "active\n", - "podman --version": "podman version 4.9.0\n", - "systemctl is-active podman.socket": "active\n", - "sysctl net.ipv4.ip_forward": "net.ipv4.ip_forward = 1\n", - "podman network inspect": `[{"name":"coolify-default-mesh","subnets":[{"subnet":"10.210.0.0/24","gateway":"10.210.0.1"}],"dns_enabled":false,"labels":{"io.coolify.managed":"true","io.coolify.namespace":"default"}}]` + "\n", - "systemctl is-active coolify-mesh-fw": "active\n", - "sha256sum /etc/systemd/system/coolify-mesh-fw.service": "", - "iptables -nL COOLIFY-INTRA": "yes\n", - "command -v nft": "yes\n", - "nft list table bridge coolify_bridge": "yes\n", - "test -x /usr/local/bin/corrosion": "yes\n", - "systemctl is-active corrosion": "active\n", - "sha256sum /etc/corrosion/config.toml": "", - "test -x /usr/local/bin/coold": "yes\n", - "systemctl is-active coold": "active\n", - "cat /etc/coolify/coold-version": "", - "cat /etc/coolify/corrosion-version": "", + "dpkg-query": "1\n", + "wg show": "", + "cat /etc/wireguard/": "", + "wg pubkey": "", + "ip -4 -o addr show": "", + "systemctl is-active wg-quick": "active\n", + "podman --version": "podman version 4.9.0\n", + "systemctl is-active podman.socket": "active\n", + "sysctl net.ipv4.ip_forward": "net.ipv4.ip_forward = 1\n", + "podman network inspect": `[{"name":"coolify-default-mesh","subnets":[{"subnet":"10.210.0.0/24","gateway":"10.210.0.1"}],"dns_enabled":false,"labels":{"io.coolify.managed":"true","io.coolify.namespace":"default"}}]` + "\n", + "systemctl is-active coolify-mesh-fw": "active\n", + "sha256sum /etc/systemd/system/coolify-mesh-fw.service": "", + "iptables -nL COOLIFY-INTRA": "yes\n", + "command -v nft": "yes\n", + "nft list table bridge coolify_bridge": "yes\n", + "test -x /usr/local/bin/corrosion": "yes\n", + "systemctl is-active corrosion": "active\n", + "sha256sum /etc/corrosion/config.toml": "", + "test -x /usr/local/bin/coold": "yes\n", + "systemctl is-active coold": "active\n", + "cat /etc/coolify/coold-version": "", + "cat /etc/coolify/corrosion-version": "", }, } @@ -203,10 +203,10 @@ func TestProbe_NftAvailableAndBridgeTableExists_True(t *testing.T) { func TestProbe_NftNotAvailable_BridgeTableAbsent(t *testing.T) { runner := &fakeReconRunner{ responses: map[string]string{ - "dpkg-query": "1\n", - "iptables -nL COOLIFY-INTRA": "yes\n", - "command -v nft": "no\n", - "nft list table bridge coolify_bridge": "no\n", + "dpkg-query": "1\n", + "iptables -nL COOLIFY-INTRA": "yes\n", + "command -v nft": "no\n", + "nft list table bridge coolify_bridge": "no\n", }, } diff --git a/llms-full.txt b/llms-full.txt index e1fdf32..c9ea499 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -1724,6 +1724,129 @@ Parameters: required: false default: 0 +Command: coolify firewall +Description: [ALPHA] Manage cross-host container allow rules (Coolify v5) +Parameters: + - name: --all-namespaces + type: boolean + description: Operate across every mesh namespace on each host (list/containers fan out; allow/revoke still require a specific --namespace) + required: false + default: false + - name: --concurrency + type: integer + description: Maximum number of parallel SSH connections + required: false + default: 10 + - name: --coold-port + type: integer + description: TCP port coold's REST API listens on (bound to the WG mgmt IP) + required: false + default: 8443 + - name: --coold-token + type: string + description: Bearer token override for coold REST API (also reads COOLIFY_COOLD_TOKEN env). When unset, CLI reads /etc/coolify/api-token over SSH per host. + required: false + - name: --namespace + type: string + description: Namespace the command operates against (must match a namespace created by `coolify init`) + required: false + default: default + - name: --servers + type: stringSlice + description: Comma-separated server IPs (required) + required: true + - name: --ssh-key + type: string + description: Path to SSH private key used to connect to servers (required) + required: true + - name: --ssh-passphrase-prompt + type: boolean + description: Prompt for SSH key passphrase (also reads COOLIFY_SSH_PASSPHRASE env var) + required: false + default: false + - name: --ssh-port + type: integer + description: SSH port + required: false + default: 22 + - name: --ssh-timeout + type: string + description: SSH connection timeout (e.g. 30s, 1m) + required: false + default: 30s + - name: --ssh-user + type: string + description: SSH username + required: false + default: root + - name: --wg-interface + type: string + description: WireGuard interface name on remote hosts (must match --wg-interface at init) + required: false + default: wg0 + +Command: coolify firewall allow +Description: Add an allow rule (from container → to container:port) +Parameters: + - name: --bidirectional + type: boolean + description: Also install the reverse rule on the source host (default: one-way; conntrack handles replies) + required: false + default: false + - name: --from + type: string + description: Source container (name, short-id, raw IP, or host:name) — required + required: false + - name: --port + type: integer + description: Destination port (required unless --proto is empty) + required: false + default: 0 + - name: --proto + type: string + description: Protocol (tcp, udp, or empty for any) + required: false + default: tcp + - name: --to + type: string + description: Destination container (name, short-id, raw IP, or host:name) — required + required: false + +Command: coolify firewall containers +Description: List containers on the Coolify mesh bridge across all servers +Parameters: (None) + +Command: coolify firewall list +Description: List installed allow rules across all servers +Parameters: (None) + +Command: coolify firewall revoke +Description: Remove an allow rule +Parameters: + - name: --bidirectional + type: boolean + description: Also install the reverse rule on the source host (default: one-way; conntrack handles replies) + required: false + default: false + - name: --from + type: string + description: Source container (name, short-id, raw IP, or host:name) — required + required: false + - name: --port + type: integer + description: Destination port (required unless --proto is empty) + required: false + default: 0 + - name: --proto + type: string + description: Protocol (tcp, udp, or empty for any) + required: false + default: tcp + - name: --to + type: string + description: Destination container (name, short-id, raw IP, or host:name) — required + required: false + Command: coolify github branches Description: List branches for a repository Parameters: (None) @@ -1869,6 +1992,122 @@ Parameters: description: GitHub Webhook Secret required: false +Command: coolify init +Description: [ALPHA] Initialize WireGuard mesh for Coolify v5 +Parameters: + - name: --broker-version + type: string + description: Release tag to download for coolify-broker (e.g. "nightly", "v1.2.3"). + required: false + default: nightly + - name: --central + type: string + description: SSH address of the central VM that will run coolify-broker + Redis (and later Laravel). +Must be one of the --servers entries. When set, phases 4+5 install Redis + broker on that host +and push a per-host JWT to every other server. Leave empty to skip broker setup. + required: false + - name: --concurrency + type: integer + description: Maximum number of parallel SSH connections + required: false + default: 10 + - name: --container-pool + type: string + description: Shared container address pool — each (namespace, host) pair gets a / from here, owned by that namespace's Podman bridge + required: false + default: 10.210.0.0/16 + - name: --container-prefix + type: integer + description: Prefix length of each per-host, per-namespace container subnet + required: false + default: 24 + - name: --coold-version + type: string + description: Release tag to download for coold (e.g. "nightly", "v1.2.3"). nightly always re-installs on every apply. + required: false + default: nightly + - name: --corrosion-api-port + type: integer + description: Corrosion HTTP API port (bound to 127.0.0.1) + required: false + default: 8080 + - name: --corrosion-gossip-port + type: integer + description: Corrosion SWIM gossip port (bound to the wg0 mgmt IP) + required: false + default: 8787 + - name: --corrosion-version + type: string + description: Release tag to download for corrosion (e.g. "nightly", "v1.2.3"). nightly always re-installs on every apply. + required: false + default: nightly + - name: --namespaces + type: stringSlice + description: Comma-separated list of namespaces to create on each host. Each namespace is a separate Podman bridge network (coolify--mesh) with its own / per host + required: false + default: [default] + - name: --servers + type: stringSlice + description: Comma-separated server IPs (required) + required: true + - name: --skip-default-deny + type: boolean + description: Skip installing the default-deny firewall scaffold. By default, both cross-host and intra-host (same bridge) container traffic is blocked; coold manages the allow list at runtime + required: false + default: false + - name: --ssh-key + type: string + description: Path to SSH private key used to connect to servers (required) + required: true + - name: --ssh-passphrase-prompt + type: boolean + description: Prompt for SSH key passphrase (also reads COOLIFY_SSH_PASSPHRASE env var) + required: false + default: false + - name: --ssh-port + type: integer + description: SSH port + required: false + default: 22 + - name: --ssh-timeout + type: string + description: SSH connection timeout (e.g. 30s, 1m) + required: false + default: 30s + - name: --ssh-user + type: string + description: SSH username + required: false + default: root + - name: --wg-interface + type: string + description: WireGuard interface name on the remote hosts + required: false + default: wg0 + - name: --wg-listen-port + type: integer + description: WireGuard UDP listen port + required: false + default: 51820 + - name: --wg-mgmt-pool + type: string + description: WireGuard management address pool — each host gets a /32 from here, assigned to wg0 + required: false + default: 100.64.0.0/16 + - name: --yes (-y) + type: boolean + description: Skip the interactive alpha confirmation prompt + required: false + default: false + +Command: coolify init apply +Description: Bootstrap the WireGuard mesh (executes the plan) +Parameters: (None) + +Command: coolify init plan +Description: Show WireGuard mesh changes without applying them +Parameters: (None) + Command: coolify private-key add Description: Add a private key Parameters: (None)