forked from mirror/coolify-cli
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:
@@ -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)
|
||||
|
||||
@@ -78,6 +78,7 @@ func parentWithToken() *FirewallFlags {
|
||||
PodmanNetworkName: "coolify-mesh",
|
||||
CooldToken: "test-token",
|
||||
CooldPort: 8443,
|
||||
WGInterface: "wg0",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user