feat(firewall): add --wg-interface flag and thread iface through coold client

Add WGInterface field to FirewallFlags with --wg-interface flag (default
from DefaultWGInterface). Thread iface parameter through CooldApply,
CooldRevoke, CooldList, and CooldListAll so the WireGuard interface name
is configurable instead of hardcoded to wg0.

Also replace hardcoded "coolify-mesh" strings with PodmanNetworkName
where applicable.
This commit is contained in:
Andras Bacsai
2026-04-20 21:14:36 +02:00
parent 7c89c3a6c8
commit 95250d32a0
8 changed files with 66 additions and 44 deletions
+2 -2
View File
@@ -192,10 +192,10 @@ func emitAllowRevoke(
// Revoke by id — coold is idempotent (204 even on unknown id).
id := strings.TrimPrefix(r.Comment, "cid:")
rerr = ifw.CooldRevoke(ctx, runner, r.Host, parent.SSHUser,
parent.SSHPort, parent.CooldPort, token, id)
parent.SSHPort, parent.CooldPort, parent.WGInterface, token, id)
} else {
rerr = ifw.CooldApply(ctx, runner, r.Host, parent.SSHUser,
parent.SSHPort, parent.CooldPort, token, r)
parent.SSHPort, parent.CooldPort, parent.WGInterface, token, r)
}
if rerr != nil {
return fmt.Errorf("%s on %s: %w", action, r.Host, rerr)
+1
View File
@@ -78,6 +78,7 @@ func parentWithToken() *FirewallFlags {
PodmanNetworkName: "coolify-mesh",
CooldToken: "test-token",
CooldPort: 8443,
WGInterface: "wg0",
}
}
+2 -2
View File
@@ -16,7 +16,7 @@ import (
func newContainersCommand(flags *FirewallFlags) *cobra.Command {
return &cobra.Command{
Use: "containers",
Short: "List containers on coolify-mesh across all servers",
Short: "List containers on the Coolify mesh bridge across all servers",
RunE: func(cmd *cobra.Command, _ []string) error {
return runContainers(cmd.Context(), cmd, flags)
},
@@ -74,7 +74,7 @@ func emitContainers(
})
}
if len(rows) == 0 {
fmt.Fprintln(os.Stderr, "No containers found on coolify-mesh network.")
fmt.Fprintf(os.Stderr, "No containers found on %s network.\n", flags.PodmanNetworkName)
return nil
}
return formatter.Format(rows)
+2 -2
View File
@@ -15,8 +15,8 @@ func NewFirewallCommand() *cobra.Command {
Long: `[ALPHA] Manage the COOLIFY-ALLOW iptables chain installed by
"coolify init --podman --default-deny". This is a test harness for the v5
control-plane firewall flow: it SSHes into every server, discovers running
containers on the coolify-mesh bridge, and lets you add/remove cross-host
allow rules.
containers on the Coolify mesh bridge (override with --podman-network), and
lets you add/remove cross-host allow rules.
Subcommands:
containers List discovered containers across the mesh.
+8 -2
View File
@@ -11,6 +11,7 @@ import (
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/cmd/common"
ifw "github.com/coollabsio/coolify-cli/internal/firewall"
)
// FirewallFlags is the shared flag set for every `coolify firewall`
@@ -25,9 +26,12 @@ type FirewallFlags struct {
// each host and reads /etc/coolify/api-token instead — tokens are
// generated per-host at install time and are not centrally shared.
CooldToken string
// CooldPort is the TCP port coold listens on (bound to wg0 mgmt IP).
// CooldPort is the TCP port coold listens on (bound to the WG mgmt IP).
// Must match COOLD_API_BIND emitted by internal/services/coold.go.
CooldPort int
// WGInterface is the WireGuard interface name used to discover coold's
// bind IP on each host. Must match --wg-interface used at `coolify init`.
WGInterface string
}
// bindFirewallFlags registers the persistent flags on the parent command.
@@ -41,7 +45,9 @@ func bindFirewallFlags(cmd *cobra.Command, f *FirewallFlags) {
"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.")
pf.IntVar(&f.CooldPort, "coold-port", 8443,
"TCP port coold's REST API listens on (wg0 mgmt IP)")
"TCP port coold's REST API listens on (bound to the WG mgmt IP)")
pf.StringVar(&f.WGInterface, "wg-interface", ifw.DefaultWGInterface,
"WireGuard interface name on remote hosts (must match --wg-interface at init)")
}
// ResolveCooldToken returns the bearer-token override supplied via flag or
+1 -1
View File
@@ -43,7 +43,7 @@ func emitList(
) error {
tokenFor := tokenResolver(ctx, runner, flags)
all, results := ifw.CooldListAll(ctx, runner, flags.Servers, flags.SSHUser,
flags.SSHPort, flags.CooldPort, tokenFor, flags.Concurrency)
flags.SSHPort, flags.CooldPort, flags.WGInterface, tokenFor, flags.Concurrency)
rows := make([]models.AllowRuleRow, 0, len(all))
for _, r := range all {
+40 -25
View File
@@ -90,14 +90,14 @@ func CooldApply(
runner ssh.Runner,
host, user string,
sshPort, cooldPort int,
token string,
iface, token string,
r AllowRule,
) error {
body, err := json.Marshal(allowRulePayload(r))
if err != nil {
return fmt.Errorf("marshal allow rule: %w", err)
}
cmd := buildCurlAllow(token, cooldPort, string(body))
cmd := buildCurlAllow(iface, token, cooldPort, string(body))
if _, stderr, err := runner.Run(ctx, host, user, sshPort, cmd); err != nil {
return fmt.Errorf("coold apply on %s: %w (stderr: %s)",
host, err, strings.TrimSpace(stderr))
@@ -112,12 +112,12 @@ func CooldRevoke(
runner ssh.Runner,
host, user string,
sshPort, cooldPort int,
token, id string,
iface, token, id string,
) error {
if id == "" {
return fmt.Errorf("coold revoke: empty id")
}
cmd := buildCurlRevoke(token, cooldPort, id)
cmd := buildCurlRevoke(iface, token, cooldPort, id)
if _, stderr, err := runner.Run(ctx, host, user, sshPort, cmd); err != nil {
return fmt.Errorf("coold revoke on %s: %w (stderr: %s)",
host, err, strings.TrimSpace(stderr))
@@ -133,9 +133,9 @@ func CooldList(
runner ssh.Runner,
host, user string,
sshPort, cooldPort int,
token string,
iface, token string,
) ([]AllowRule, error) {
cmd := buildCurlList(token, cooldPort)
cmd := buildCurlList(iface, token, cooldPort)
stdout, stderr, err := runner.Run(ctx, host, user, sshPort, cmd)
if err != nil {
return nil, fmt.Errorf("coold list on %s: %w (stderr: %s)",
@@ -172,6 +172,7 @@ func CooldListAll(
hosts []string,
user string,
sshPort, cooldPort int,
iface string,
tokenFor func(host string) (string, error),
concurrency int,
) ([]AllowRule, []ssh.ServerResult[[]AllowRule]) {
@@ -181,7 +182,7 @@ func CooldListAll(
if err != nil {
return nil, err
}
return CooldList(ctx, runner, host, user, sshPort, cooldPort, token)
return CooldList(ctx, runner, host, user, sshPort, cooldPort, iface, token)
})
var all []AllowRule
for _, r := range results {
@@ -211,26 +212,40 @@ func shellSingleQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}
// mgmtIPScript discovers coold's bind IP on the remote host by reading the
// first IPv4 address on wg0. Emitted as part of every curl command so the
// CLI doesn't need to track per-host mgmt IPs (they are already encoded in
// the host's own wg0 interface).
const mgmtIPScript = `MGMT=$(ip -4 -o addr show wg0 2>/dev/null | awk '{print $4}' | cut -d/ -f1); ` +
`test -n "$MGMT" || { echo "coold mgmt IP (wg0) not found on $(hostname) — is coold installed?" >&2; exit 1; }; `
// DefaultWGInterface is the WireGuard interface name the firewall CLI
// assumes when no override is supplied. Matches the default of
// `coolify init --wg-interface`.
const DefaultWGInterface = "wg0"
// mgmtIPScriptSoft is the same as mgmtIPScript but treats a missing wg0 as
// "no rules" rather than a failure. Used by list so a host without coold is
// simply absent from the output instead of aborting the whole fanout.
const mgmtIPScriptSoft = `MGMT=$(ip -4 -o addr show wg0 2>/dev/null | awk '{print $4}' | cut -d/ -f1); ` +
`if [ -z "$MGMT" ]; then echo '[]'; exit 0; fi; `
// mgmtIPScript discovers coold's bind IP on the remote host by reading the
// first IPv4 address on the host's WireGuard interface. Emitted as part of
// every curl command so the CLI doesn't need to track per-host mgmt IPs
// (they are already encoded in the host's own WG interface).
func mgmtIPScript(iface string) string {
return fmt.Sprintf(
`MGMT=$(ip -4 -o addr show %[1]s 2>/dev/null | awk '{print $4}' | cut -d/ -f1); `+
`test -n "$MGMT" || { echo "coold mgmt IP (%[1]s) not found on $(hostname) — is coold installed?" >&2; exit 1; }; `,
iface)
}
// mgmtIPScriptSoft is the same as mgmtIPScript but treats a missing WG
// interface as "no rules" rather than a failure. Used by list so a host
// without coold is simply absent from the output instead of aborting the
// whole fanout.
func mgmtIPScriptSoft(iface string) string {
return fmt.Sprintf(
`MGMT=$(ip -4 -o addr show %s 2>/dev/null | awk '{print $4}' | cut -d/ -f1); `+
`if [ -z "$MGMT" ]; then echo '[]'; exit 0; fi; `,
iface)
}
// buildCurlAllow returns the shell one-liner that POSTs body to coold.
// Token is embedded inline in the -H header; on the remote it is briefly
// visible in /proc/<curl-pid>/cmdline to root only, for the ~ms lifetime of
// the curl invocation. Acceptable for alpha; TLS + stdin-fed tokens are a
// follow-up.
func buildCurlAllow(token string, port int, body string) string {
return mgmtIPScript +
func buildCurlAllow(iface, token string, port int, body string) string {
return mgmtIPScript(iface) +
`curl -fsS --max-time 10 ` +
`-H ` + shellSingleQuote("Authorization: Bearer "+token) + ` ` +
`-H 'Content-Type: application/json' ` +
@@ -239,8 +254,8 @@ func buildCurlAllow(token string, port int, body string) string {
}
// buildCurlRevoke returns the shell one-liner that DELETEs rule id.
func buildCurlRevoke(token string, port int, id string) string {
return mgmtIPScript +
func buildCurlRevoke(iface, token string, port int, id string) string {
return mgmtIPScript(iface) +
`curl -fsS --max-time 10 -o /dev/null ` +
`-H ` + shellSingleQuote("Authorization: Bearer "+token) + ` ` +
`-X DELETE ` +
@@ -248,10 +263,10 @@ func buildCurlRevoke(token string, port int, id string) string {
}
// buildCurlList returns the shell one-liner that GETs /allow. A missing
// wg0 interface returns an empty JSON array so the caller sees "no rules"
// WG interface returns an empty JSON array so the caller sees "no rules"
// instead of a transport error.
func buildCurlList(token string, port int) string {
return mgmtIPScriptSoft +
func buildCurlList(iface, token string, port int) string {
return mgmtIPScriptSoft(iface) +
`curl -fsS --max-time 10 ` +
`-H ` + shellSingleQuote("Authorization: Bearer "+token) + ` ` +
fmt.Sprintf(`"http://$MGMT:%d%s/allow"`, port, CooldAPIBasePath)
+10 -10
View File
@@ -36,7 +36,7 @@ func TestShellSingleQuote_Escapes(t *testing.T) {
}
func TestBuildCurlAllow_Shape(t *testing.T) {
cmd := buildCurlAllow("tok-xyz", 8443, `{"src":"10.0.0.1","dst":"10.0.0.2"}`)
cmd := buildCurlAllow("wg0", "tok-xyz", 8443, `{"src":"10.0.0.1","dst":"10.0.0.2"}`)
assert.Contains(t, cmd, "ip -4 -o addr show wg0")
assert.Contains(t, cmd, "curl -fsS")
assert.Contains(t, cmd, "Authorization: Bearer tok-xyz")
@@ -47,7 +47,7 @@ func TestBuildCurlAllow_Shape(t *testing.T) {
}
func TestBuildCurlRevoke_Shape(t *testing.T) {
cmd := buildCurlRevoke("tok-xyz", 8443, "abc123def456")
cmd := buildCurlRevoke("wg0", "tok-xyz", 8443, "abc123def456")
assert.Contains(t, cmd, "curl -fsS")
assert.Contains(t, cmd, "-X DELETE")
assert.Contains(t, cmd, "Authorization: Bearer tok-xyz")
@@ -55,7 +55,7 @@ func TestBuildCurlRevoke_Shape(t *testing.T) {
}
func TestBuildCurlList_SoftMgmtIP(t *testing.T) {
cmd := buildCurlList("tok-xyz", 8443)
cmd := buildCurlList("wg0", "tok-xyz", 8443)
// Missing wg0 yields an empty array and success exit.
assert.Contains(t, cmd, `echo '[]'; exit 0`)
assert.Contains(t, cmd, "Authorization: Bearer tok-xyz")
@@ -68,7 +68,7 @@ func TestCooldApply_SendsJSONPayload(t *testing.T) {
Src: net.ParseIP("10.0.0.1"), Dst: net.ParseIP("10.0.0.2"),
Proto: "tcp", Port: 80,
}
err := CooldApply(context.Background(), fr, "h1", "root", 22, 8443, "t", r)
err := CooldApply(context.Background(), fr, "h1", "root", 22, 8443, "wg0", "t", r)
assert.NoError(t, err)
assert.Len(t, fr.calls, 1)
assert.Contains(t, fr.calls[0], `"src":"10.0.0.1"`)
@@ -82,7 +82,7 @@ func TestCooldApply_OmitsProtoWhenEmpty(t *testing.T) {
r := AllowRule{
Src: net.ParseIP("10.0.0.1"), Dst: net.ParseIP("10.0.0.2"),
}
err := CooldApply(context.Background(), fr, "h1", "root", 22, 8443, "t", r)
err := CooldApply(context.Background(), fr, "h1", "root", 22, 8443, "wg0", "t", r)
assert.NoError(t, err)
// omitempty drops zero port and empty proto — avoids tripping coold's
// "port requires proto" validation.
@@ -92,7 +92,7 @@ func TestCooldApply_OmitsProtoWhenEmpty(t *testing.T) {
func TestCooldRevoke_RejectsEmptyID(t *testing.T) {
fr := &fakeCooldRunner{}
err := CooldRevoke(context.Background(), fr, "h1", "root", 22, 8443, "t", "")
err := CooldRevoke(context.Background(), fr, "h1", "root", 22, 8443, "wg0", "t", "")
assert.Error(t, err)
assert.Empty(t, fr.calls, "no SSH call for empty id")
}
@@ -104,7 +104,7 @@ func TestCooldList_ParsesJSON(t *testing.T) {
{"src":"10.0.0.3","dst":"10.0.0.4"}
]`,
}}
rules, err := CooldList(context.Background(), fr, "h1", "root", 22, 8443, "t")
rules, err := CooldList(context.Background(), fr, "h1", "root", 22, 8443, "wg0", "t")
assert.NoError(t, err)
assert.Len(t, rules, 2)
assert.Equal(t, "h1", rules[0].Host)
@@ -119,7 +119,7 @@ func TestCooldList_ParsesJSON(t *testing.T) {
func TestCooldList_EmptyBody(t *testing.T) {
fr := &fakeCooldRunner{}
rules, err := CooldList(context.Background(), fr, "h1", "root", 22, 8443, "t")
rules, err := CooldList(context.Background(), fr, "h1", "root", 22, 8443, "wg0", "t")
assert.NoError(t, err)
assert.Empty(t, rules)
}
@@ -132,7 +132,7 @@ func TestCooldListAll_SortsByHost(t *testing.T) {
}}
tokenFor := func(string) (string, error) { return "t", nil }
rules, results := CooldListAll(context.Background(), fr,
[]string{"hB", "hA"}, "root", 22, 8443, tokenFor, 2)
[]string{"hB", "hA"}, "root", 22, 8443, "wg0", tokenFor, 2)
assert.Len(t, rules, 2)
assert.Equal(t, "hA", rules[0].Host)
assert.Equal(t, "hB", rules[1].Host)
@@ -166,7 +166,7 @@ func TestCooldListAll_PropagatesTokenFetchError(t *testing.T) {
return "t", nil
}
_, results := CooldListAll(context.Background(), fr,
[]string{"hOk", "hBad"}, "root", 22, 8443, tokenFor, 2)
[]string{"hOk", "hBad"}, "root", 22, 8443, "wg0", tokenFor, 2)
var okCount, errCount int
for _, r := range results {
if r.Err != nil {