feat(firewall): add cross-host container allow-rule command

Add `coolify firewall` command tree (alpha) for managing iptables
COOLIFY-ALLOW rules across SSH-reachable servers in the coolify-mesh
Podman network.

New subcommands:
- containers: discover running containers across all servers
- list: show installed allow rules
- allow: add src→dst:port allow rule
- revoke: remove an allow rule

Extract shared SSH-mesh flags (--servers, --ssh-key, --ssh-user,
--ssh-port, --concurrency, --ssh-timeout) into cmd/common.SSHMeshFlags
so both `init` and `firewall` reuse the same flag set. Trim duplicated
flag definitions from cmd/init/flags.go accordingly.

Internal packages added:
- internal/firewall/rule.go: AllowRule model + iptables rule rendering
- internal/firewall/discover.go: fan-out container discovery via podman ps
- internal/firewall/list.go: fan-out rule listing via iptables-save
- internal/firewall/apply.go: apply/revoke rules over SSH
- internal/firewall/persist.go: rule persistence helpers
- internal/models/firewall.go: ContainerRow / AllowRuleRow display models

Full unit-test coverage added for all new packages.
This commit is contained in:
Andras Bacsai
2026-04-20 13:53:36 +02:00
parent 0df8f401e1
commit 84fec60a60
29 changed files with 2086 additions and 93 deletions
+95
View File
@@ -0,0 +1,95 @@
// Package common hosts flag sets and helpers shared between multiple
// top-level commands that SSH into a list of servers (init, firewall, ...).
package common
import (
"fmt"
"os"
"time"
"github.com/spf13/cobra"
"golang.org/x/term"
internalssh "github.com/coollabsio/coolify-cli/internal/ssh"
)
// SSHMeshFlags holds the flags shared by every command that fans out over
// a list of SSH-reachable servers (coolify init, coolify firewall, ...).
type SSHMeshFlags struct {
Servers []string
SSHKey string
SSHUser string
SSHPort int
SSHPassphrasePrompt bool
Concurrency int
SSHTimeout string
}
// BindSSHMeshFlags registers the shared flags as PersistentFlags on cmd.
func BindSSHMeshFlags(cmd *cobra.Command, f *SSHMeshFlags) {
pf := cmd.PersistentFlags()
pf.StringSliceVar(&f.Servers, "servers", nil,
"Comma-separated server IPs (required)")
pf.StringVar(&f.SSHKey, "ssh-key", "",
"Path to SSH private key used to connect to servers (required)")
pf.StringVar(&f.SSHUser, "ssh-user", "root",
"SSH username")
pf.IntVar(&f.SSHPort, "ssh-port", 22,
"SSH port")
pf.BoolVar(&f.SSHPassphrasePrompt, "ssh-passphrase-prompt", false,
"Prompt for SSH key passphrase (also reads COOLIFY_SSH_PASSPHRASE env var)")
pf.IntVar(&f.Concurrency, "concurrency", 10,
"Maximum number of parallel SSH connections")
pf.StringVar(&f.SSHTimeout, "ssh-timeout", "30s",
"SSH connection timeout (e.g. 30s, 1m)")
}
// ParseSSHTimeout parses SSHTimeout, falling back to 30s on error/zero.
func (f *SSHMeshFlags) ParseSSHTimeout() time.Duration {
d, err := time.ParseDuration(f.SSHTimeout)
if err != nil || d <= 0 {
return 30 * time.Second
}
return d
}
// ResolvePassphrase returns the SSH key passphrase in this priority order:
// 1. COOLIFY_SSH_PASSPHRASE env var
// 2. Interactive prompt when --ssh-passphrase-prompt is set
// 3. nil (no passphrase)
func (f *SSHMeshFlags) ResolvePassphrase() ([]byte, error) {
if env := os.Getenv("COOLIFY_SSH_PASSPHRASE"); env != "" {
return []byte(env), nil
}
if f.SSHPassphrasePrompt {
fmt.Fprint(os.Stderr, "SSH key passphrase: ")
pass, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Fprintln(os.Stderr)
if err != nil {
return nil, fmt.Errorf("read passphrase: %w", err)
}
return pass, nil
}
return nil, nil
}
// BuildSSHClient creates an SSH client, resolving any key passphrase first.
func (f *SSHMeshFlags) BuildSSHClient() (*internalssh.Client, error) {
passphrase, err := f.ResolvePassphrase()
if err != nil {
return nil, err
}
return internalssh.NewClient(f.SSHKey, passphrase, f.ParseSSHTimeout())
}
// Validate checks that the required flags are set.
func (f *SSHMeshFlags) Validate() error {
if len(f.Servers) == 0 {
return fmt.Errorf("--servers is required")
}
if f.SSHKey == "" {
return fmt.Errorf("--ssh-key is required")
}
return nil
}
+56
View File
@@ -0,0 +1,56 @@
package common
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestSSHMeshFlags_ParseSSHTimeout(t *testing.T) {
tests := []struct {
input string
want time.Duration
}{
{"30s", 30 * time.Second},
{"1m", time.Minute},
{"invalid", 30 * time.Second},
{"0s", 30 * time.Second},
{"", 30 * time.Second},
}
for _, tt := range tests {
f := &SSHMeshFlags{SSHTimeout: tt.input}
assert.Equal(t, tt.want, f.ParseSSHTimeout(), "input: %q", tt.input)
}
}
func TestSSHMeshFlags_Validate(t *testing.T) {
t.Run("missing servers", func(t *testing.T) {
err := (&SSHMeshFlags{SSHKey: "/k"}).Validate()
assert.Error(t, err)
assert.Contains(t, err.Error(), "--servers")
})
t.Run("missing ssh key", func(t *testing.T) {
err := (&SSHMeshFlags{Servers: []string{"1.1.1.1"}}).Validate()
assert.Error(t, err)
assert.Contains(t, err.Error(), "--ssh-key")
})
t.Run("valid", func(t *testing.T) {
err := (&SSHMeshFlags{Servers: []string{"1.1.1.1"}, SSHKey: "/k"}).Validate()
assert.NoError(t, err)
})
}
func TestSSHMeshFlags_ResolvePassphrase_Env(t *testing.T) {
t.Setenv("COOLIFY_SSH_PASSPHRASE", "hunter2")
pass, err := (&SSHMeshFlags{}).ResolvePassphrase()
assert.NoError(t, err)
assert.Equal(t, []byte("hunter2"), pass)
}
func TestSSHMeshFlags_ResolvePassphrase_NoPrompt(t *testing.T) {
t.Setenv("COOLIFY_SSH_PASSPHRASE", "")
pass, err := (&SSHMeshFlags{SSHPassphrasePrompt: false}).ResolvePassphrase()
assert.NoError(t, err)
assert.Nil(t, pass)
}
+237
View File
@@ -0,0 +1,237 @@
package firewall
import (
"context"
"fmt"
"net"
"os"
"github.com/spf13/cobra"
ifw "github.com/coollabsio/coolify-cli/internal/firewall"
"github.com/coollabsio/coolify-cli/internal/models"
"github.com/coollabsio/coolify-cli/internal/output"
"github.com/coollabsio/coolify-cli/internal/ssh"
)
// allowRevokeFlags are the per-subcommand flags for `allow` / `revoke`.
type allowRevokeFlags struct {
From string
To string
Port int
Proto string
Bidirectional bool
}
// newAllowCommand builds `coolify firewall allow`.
func newAllowCommand(parent *FirewallFlags) *cobra.Command {
local := &allowRevokeFlags{}
cmd := &cobra.Command{
Use: "allow",
Short: "Add an allow rule (from container → to container:port)",
RunE: func(cmd *cobra.Command, _ []string) error {
return runAllowRevoke(cmd.Context(), cmd, parent, local, false)
},
}
bindAllowRevokeFlags(cmd, local)
return cmd
}
// newRevokeCommand builds `coolify firewall revoke`.
func newRevokeCommand(parent *FirewallFlags) *cobra.Command {
local := &allowRevokeFlags{}
cmd := &cobra.Command{
Use: "revoke",
Short: "Remove an allow rule",
RunE: func(cmd *cobra.Command, _ []string) error {
return runAllowRevoke(cmd.Context(), cmd, parent, local, true)
},
}
bindAllowRevokeFlags(cmd, local)
return cmd
}
func bindAllowRevokeFlags(cmd *cobra.Command, f *allowRevokeFlags) {
pf := cmd.Flags()
pf.StringVar(&f.From, "from", "",
"Source container (name, short-id, raw IP, or host:name) — required")
pf.StringVar(&f.To, "to", "",
"Destination container (name, short-id, raw IP, or host:name) — required")
pf.IntVar(&f.Port, "port", 0,
"Destination port (required unless --proto is empty)")
pf.StringVar(&f.Proto, "proto", "tcp",
"Protocol (tcp, udp, or empty for any)")
pf.BoolVar(&f.Bidirectional, "bidirectional", false,
"Also install the reverse rule on the source host (default: one-way; conntrack handles replies)")
}
func validateAllowRevokeFlags(f *allowRevokeFlags) error {
if f.From == "" {
return fmt.Errorf("--from is required")
}
if f.To == "" {
return fmt.Errorf("--to is required")
}
if f.Proto != "" && f.Proto != "tcp" && f.Proto != "udp" {
return fmt.Errorf("--proto must be tcp, udp, or empty (got %q)", f.Proto)
}
if f.Proto != "" && f.Port <= 0 {
return fmt.Errorf("--port is required when --proto is set")
}
return nil
}
func runAllowRevoke(
ctx context.Context,
cmd *cobra.Command,
parent *FirewallFlags,
local *allowRevokeFlags,
revoke bool,
) error {
if err := parent.SSHMeshFlags.Validate(); err != nil {
return err
}
if err := validateAllowRevokeFlags(local); err != nil {
return err
}
runner, err := parent.BuildSSHClient()
if err != nil {
return fmt.Errorf("SSH client: %w", err)
}
return emitAllowRevoke(ctx, cmd, parent, local, runner, revoke)
}
// emitAllowRevoke is the core path: discover → resolve → build rule → apply.
// Split from the cobra wrapper so tests inject a fake ssh.Runner.
func emitAllowRevoke(
ctx context.Context,
cmd *cobra.Command,
parent *FirewallFlags,
local *allowRevokeFlags,
runner ssh.Runner,
revoke bool,
) error {
all, results := discoverAllViaPkg(ctx, runner, parent)
for _, r := range results {
if r.Err != nil {
fmt.Fprintf(os.Stderr, "Warning: discover %s: %v\n", r.Host, r.Err)
}
}
from, err := resolveEndpoint(local.From, all)
if err != nil {
return fmt.Errorf("--from: %w", err)
}
to, err := resolveEndpoint(local.To, all)
if err != nil {
return fmt.Errorf("--to: %w", err)
}
if from.IP == nil || to.IP == nil {
return fmt.Errorf("failed to resolve endpoint IPs (from=%s to=%s)", local.From, local.To)
}
// Determine destination host (rule owner). If `to` was resolved from a
// raw IP with no container match, try to map it via discovery first.
dstHost := to.Host
if dstHost == "" {
if h, ok := findHostForIP(to.IP, all); ok {
dstHost = h
}
}
if dstHost == "" {
return fmt.Errorf("cannot determine destination host for IP %s — no container on the mesh owns it", to.IP)
}
srcHost := from.Host
if srcHost == "" {
if h, ok := findHostForIP(from.IP, all); ok {
srcHost = h
}
}
primary := ifw.AllowRule{
Host: dstHost,
Src: from.IP,
Dst: to.IP,
Proto: local.Proto,
Port: local.Port,
Comment: "cid:" + ifw.ComputeID(from.IP, to.IP, local.Proto, local.Port),
}
rules := []ifw.AllowRule{primary}
if local.Bidirectional {
if srcHost == "" {
return fmt.Errorf("--bidirectional requires the source endpoint to belong to a mesh host")
}
reverse := ifw.AllowRule{
Host: srcHost,
Src: to.IP,
Dst: from.IP,
Proto: local.Proto,
Port: local.Port,
Comment: "cid:" + ifw.ComputeID(to.IP, from.IP, local.Proto, local.Port),
}
rules = append(rules, reverse)
}
action := "allow"
past := "allowed"
if revoke {
action = "revoke"
past = "revoked"
}
for _, r := range rules {
var rerr error
if revoke {
rerr = ifw.RevokeAllow(ctx, runner, r, parent.SSHUser, parent.SSHPort)
} else {
rerr = ifw.ApplyAllow(ctx, runner, r, parent.SSHUser, parent.SSHPort)
}
if rerr != nil {
return fmt.Errorf("%s on %s: %w", action, r.Host, rerr)
}
fmt.Fprintf(os.Stderr, "%s on %s: %s → %s %s/%d\n",
past, r.Host, ipOrAny(r.Src), ipOrAny(r.Dst),
protoOrAny(r.Proto), r.Port)
}
rows := make([]models.AllowRuleRow, 0, len(rules))
for _, r := range rules {
rows = append(rows, models.AllowRuleRow{
Host: r.Host,
ID: r.Comment,
Src: r.Src.String(),
Dst: r.Dst.String(),
Proto: r.Proto,
Port: r.Port,
Comment: r.Comment,
})
}
format, _ := cmd.Root().PersistentFlags().GetString("format")
if format == "" {
format = output.FormatTable
}
formatter, err := output.NewFormatter(format, output.Options{Writer: os.Stdout})
if err != nil {
return err
}
if format == output.FormatJSON || format == output.FormatPretty {
return formatter.Format(models.FirewallAllowOutput{Rules: rows})
}
return formatter.Format(rows)
}
func ipOrAny(ip net.IP) string {
if ip == nil {
return "any"
}
return ip.String()
}
func protoOrAny(p string) string {
if p == "" {
return "any"
}
return p
}
+159
View File
@@ -0,0 +1,159 @@
package firewall
import (
"context"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/coollabsio/coolify-cli/cmd/common"
"github.com/coollabsio/coolify-cli/internal/ssh"
)
func TestValidateAllowRevokeFlags(t *testing.T) {
t.Run("missing from", func(t *testing.T) {
err := validateAllowRevokeFlags(&allowRevokeFlags{To: "x", Port: 80, Proto: "tcp"})
assert.Error(t, err)
assert.Contains(t, err.Error(), "--from")
})
t.Run("missing to", func(t *testing.T) {
err := validateAllowRevokeFlags(&allowRevokeFlags{From: "x", Port: 80, Proto: "tcp"})
assert.Error(t, err)
assert.Contains(t, err.Error(), "--to")
})
t.Run("missing port with proto", func(t *testing.T) {
err := validateAllowRevokeFlags(&allowRevokeFlags{From: "a", To: "b", Proto: "tcp"})
assert.Error(t, err)
assert.Contains(t, err.Error(), "--port")
})
t.Run("bad proto", func(t *testing.T) {
err := validateAllowRevokeFlags(&allowRevokeFlags{From: "a", To: "b", Proto: "icmp", Port: 1})
assert.Error(t, err)
})
t.Run("ok tcp", func(t *testing.T) {
err := validateAllowRevokeFlags(&allowRevokeFlags{From: "a", To: "b", Proto: "tcp", Port: 80})
assert.NoError(t, err)
})
t.Run("ok no-proto no-port", func(t *testing.T) {
err := validateAllowRevokeFlags(&allowRevokeFlags{From: "a", To: "b", Proto: "", Port: 0})
assert.NoError(t, err)
})
}
// fakeRunner mirrors internal/firewall's fake but lives in this package.
type cmdFakeRunner struct {
responses map[string]string
calls []string
}
func (f *cmdFakeRunner) Run(_ context.Context, _, _ string, _ int, cmd string) (string, string, error) {
f.calls = append(f.calls, cmd)
for sub, resp := range f.responses {
if strings.Contains(cmd, sub) {
return resp, "", nil
}
}
return "", "", nil
}
var _ ssh.Runner = (*cmdFakeRunner)(nil)
func rootCmdFor(cmd *cobra.Command) *cobra.Command {
root := &cobra.Command{Use: "coolify"}
root.PersistentFlags().String("format", "table", "")
root.AddCommand(cmd)
return root
}
func TestEmitAllowRevoke_AppliesOneDirection(t *testing.T) {
fr := &cmdFakeRunner{responses: map[string]string{
"podman ps": "aaa111111111|web|10.210.0.10",
}}
// Single host — fake can't route output by host, so collapse.
parent := &FirewallFlags{
SSHMeshFlags: common.SSHMeshFlags{
Servers: []string{"h1"}, SSHUser: "root", SSHPort: 22, Concurrency: 1,
},
PodmanNetworkName: "coolify-mesh",
}
local := &allowRevokeFlags{
From: "10.210.1.5", To: "web", Proto: "tcp", Port: 80,
}
inner := &cobra.Command{Use: "allow"}
rootCmdFor(inner)
err := emitAllowRevoke(context.Background(), inner, parent, local, fr, false)
assert.NoError(t, err)
// One apply call issued (excluding discovery).
var applies []string
for _, c := range fr.calls {
if strings.Contains(c, "iptables -A COOLIFY-ALLOW") {
applies = append(applies, c)
}
}
assert.Len(t, applies, 1)
assert.Contains(t, applies[0], "-s 10.210.1.5")
assert.Contains(t, applies[0], "-d 10.210.0.10")
assert.Contains(t, applies[0], "--dport 80")
}
func TestEmitAllowRevoke_Bidirectional(t *testing.T) {
// Single host: bidir still issues 2 rules (one per direction), both with
// Host = the only host. This is a degenerate but valid setup for the test.
fr := &cmdFakeRunner{responses: map[string]string{
"podman ps": "aaa111111111|web|10.210.0.10\nbbb222222222|client|10.210.1.5",
}}
parent := &FirewallFlags{
SSHMeshFlags: common.SSHMeshFlags{
Servers: []string{"h1"}, SSHUser: "root", SSHPort: 22, Concurrency: 1,
},
PodmanNetworkName: "coolify-mesh",
}
local := &allowRevokeFlags{
From: "10.210.1.5", To: "10.210.0.10", Proto: "tcp", Port: 80, Bidirectional: true,
}
inner := &cobra.Command{Use: "allow"}
rootCmdFor(inner)
err := emitAllowRevoke(context.Background(), inner, parent, local, fr, false)
assert.NoError(t, err)
var applies int
for _, c := range fr.calls {
if strings.Contains(c, "iptables -A COOLIFY-ALLOW") {
applies++
}
}
assert.Equal(t, 2, applies)
}
func TestEmitAllowRevoke_RevokeIssuesDelete(t *testing.T) {
fr := &cmdFakeRunner{responses: map[string]string{
"podman ps": "aaa111111111|web|10.210.0.10",
}}
parent := &FirewallFlags{
SSHMeshFlags: common.SSHMeshFlags{
Servers: []string{"h1"}, SSHUser: "root", SSHPort: 22, Concurrency: 1,
},
PodmanNetworkName: "coolify-mesh",
}
local := &allowRevokeFlags{
From: "10.210.1.5", To: "web", Proto: "tcp", Port: 80,
}
inner := &cobra.Command{Use: "revoke"}
rootCmdFor(inner)
err := emitAllowRevoke(context.Background(), inner, parent, local, fr, true)
assert.NoError(t, err)
var del int
for _, c := range fr.calls {
if strings.Contains(c, "iptables -D COOLIFY-ALLOW") {
del++
}
}
assert.Equal(t, 1, del)
}
+81
View File
@@ -0,0 +1,81 @@
package firewall
import (
"context"
"fmt"
"os"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/models"
"github.com/coollabsio/coolify-cli/internal/output"
"github.com/coollabsio/coolify-cli/internal/ssh"
)
// newContainersCommand builds `coolify firewall containers`.
func newContainersCommand(flags *FirewallFlags) *cobra.Command {
return &cobra.Command{
Use: "containers",
Short: "List containers on coolify-mesh across all servers",
RunE: func(cmd *cobra.Command, _ []string) error {
return runContainers(cmd.Context(), cmd, flags)
},
}
}
func runContainers(ctx context.Context, cmd *cobra.Command, flags *FirewallFlags) error {
if err := flags.SSHMeshFlags.Validate(); err != nil {
return err
}
runner, err := flags.BuildSSHClient()
if err != nil {
return fmt.Errorf("SSH client: %w", err)
}
return emitContainers(ctx, cmd, flags, runner)
}
// emitContainers is factored out so tests can pass a fake ssh.Runner.
func emitContainers(
ctx context.Context,
cmd *cobra.Command,
flags *FirewallFlags,
runner ssh.Runner,
) error {
all, results := discoverAllViaPkg(ctx, runner, flags)
rows := make([]models.ContainerRow, 0, len(all))
for _, c := range all {
rows = append(rows, models.ContainerRow{
Host: c.Host, ID: c.ID, Name: c.Name, IP: c.IP.String(),
})
}
var errs []string
for _, r := range results {
if r.Err != nil {
errs = append(errs, fmt.Sprintf("%s: %v", r.Host, r.Err))
}
}
for _, e := range errs {
fmt.Fprintln(os.Stderr, "Warning:", e)
}
format, _ := cmd.Root().PersistentFlags().GetString("format")
if format == "" {
format = output.FormatTable
}
formatter, err := output.NewFormatter(format, output.Options{Writer: os.Stdout})
if err != nil {
return err
}
if format == output.FormatJSON || format == output.FormatPretty {
return formatter.Format(models.FirewallContainersOutput{
Containers: rows, Errors: errs,
})
}
if len(rows) == 0 {
fmt.Fprintln(os.Stderr, "No containers found on coolify-mesh network.")
return nil
}
return formatter.Format(rows)
}
+46
View File
@@ -0,0 +1,46 @@
package firewall
import (
"context"
"testing"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/coollabsio/coolify-cli/cmd/common"
)
func TestEmitContainers_RunsAndFormatsTable(t *testing.T) {
fr := &cmdFakeRunner{responses: map[string]string{
"podman ps": "aaa111111111|web|10.210.0.10",
}}
parent := &FirewallFlags{
SSHMeshFlags: common.SSHMeshFlags{
Servers: []string{"h1"}, SSHUser: "root", SSHPort: 22, Concurrency: 1,
},
PodmanNetworkName: "coolify-mesh",
}
inner := &cobra.Command{Use: "containers"}
rootCmdFor(inner)
err := emitContainers(context.Background(), inner, parent, fr)
assert.NoError(t, err)
// Discovery command was issued.
assert.Len(t, fr.calls, 1)
assert.Contains(t, fr.calls[0], "podman ps")
}
func TestEmitContainers_EmptyOutput(t *testing.T) {
fr := &cmdFakeRunner{responses: map[string]string{}}
parent := &FirewallFlags{
SSHMeshFlags: common.SSHMeshFlags{
Servers: []string{"h1"}, SSHUser: "root", SSHPort: 22, Concurrency: 1,
},
PodmanNetworkName: "coolify-mesh",
}
inner := &cobra.Command{Use: "containers"}
rootCmdFor(inner)
err := emitContainers(context.Background(), inner, parent, fr)
assert.NoError(t, err)
}
+39
View File
@@ -0,0 +1,39 @@
package firewall
import (
"github.com/spf13/cobra"
)
// NewFirewallCommand creates the parent `coolify firewall` command.
// On bare invocation (no subcommand) it prints help.
func NewFirewallCommand() *cobra.Command {
flags := &FirewallFlags{}
cmd := &cobra.Command{
Use: "firewall",
Short: "[ALPHA] Manage cross-host container allow rules (Coolify v5)",
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.
Subcommands:
containers List discovered containers across the mesh.
list Show installed allow rules.
allow Add an allow rule (src container → dst container:port).
revoke Remove an allow rule.`,
RunE: func(cmd *cobra.Command, _ []string) error {
return cmd.Help()
},
}
bindFirewallFlags(cmd, flags)
cmd.AddCommand(newContainersCommand(flags))
cmd.AddCommand(newListCommand(flags))
cmd.AddCommand(newAllowCommand(flags))
cmd.AddCommand(newRevokeCommand(flags))
return cmd
}
+47
View File
@@ -0,0 +1,47 @@
package firewall
import (
"testing"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
)
func TestNewFirewallCommand_Subcommands(t *testing.T) {
cmd := NewFirewallCommand()
assert.Equal(t, "firewall", cmd.Use)
subs := map[string]*cobra.Command{}
for _, s := range cmd.Commands() {
subs[s.Use] = s
}
assert.Contains(t, subs, "containers")
assert.Contains(t, subs, "list")
assert.Contains(t, subs, "allow")
assert.Contains(t, subs, "revoke")
}
func TestNewFirewallCommand_PersistentFlags(t *testing.T) {
cmd := NewFirewallCommand()
pf := cmd.PersistentFlags()
for _, name := range []string{"servers", "ssh-key", "ssh-user", "ssh-port",
"concurrency", "ssh-timeout", "podman-network"} {
assert.NotNil(t, pf.Lookup(name), "missing --%s", name)
}
}
func TestAllowCommand_LocalFlags(t *testing.T) {
cmd := NewFirewallCommand()
var allow *cobra.Command
for _, s := range cmd.Commands() {
if s.Use == "allow" {
allow = s
break
}
}
if allow == nil {
t.Fatal("allow subcommand not found")
}
for _, name := range []string{"from", "to", "port", "proto", "bidirectional"} {
assert.NotNil(t, allow.Flags().Lookup(name), "missing --%s on allow", name)
}
}
+25
View File
@@ -0,0 +1,25 @@
// Package firewall implements the `coolify firewall` command tree: a test
// harness for the v5 cross-host allow-rule flow that will eventually be
// owned by the coold agent. See CONTROL_PLANE.md §3.
package firewall
import (
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/cmd/common"
)
// FirewallFlags is the shared flag set for every `coolify firewall`
// subcommand: SSH plumbing (via embed) + podman network name.
type FirewallFlags struct {
common.SSHMeshFlags
PodmanNetworkName string
}
// bindFirewallFlags registers the persistent flags on the parent command.
func bindFirewallFlags(cmd *cobra.Command, f *FirewallFlags) {
common.BindSSHMeshFlags(cmd, &f.SSHMeshFlags)
cmd.PersistentFlags().StringVar(&f.PodmanNetworkName, "podman-network",
"coolify-mesh",
"Podman bridge network name (must match --podman-network used at init)")
}
+29
View File
@@ -0,0 +1,29 @@
package firewall
import (
"context"
ifw "github.com/coollabsio/coolify-cli/internal/firewall"
"github.com/coollabsio/coolify-cli/internal/ssh"
)
// discoverAllViaPkg is a thin wrapper around ifw.DiscoverAll that threads
// the FirewallFlags in. Exists so command files stay short.
func discoverAllViaPkg(
ctx context.Context,
runner ssh.Runner,
flags *FirewallFlags,
) ([]ifw.Container, []ssh.ServerResult[[]ifw.Container]) {
return ifw.DiscoverAll(ctx, runner, flags.Servers, flags.SSHUser, flags.SSHPort,
flags.PodmanNetworkName, flags.Concurrency)
}
// listAllViaPkg wraps ifw.ListAll for the same reason.
func listAllViaPkg(
ctx context.Context,
runner ssh.Runner,
flags *FirewallFlags,
) ([]ifw.AllowRule, []ssh.ServerResult[[]ifw.AllowRule]) {
return ifw.ListAll(ctx, runner, flags.Servers, flags.SSHUser, flags.SSHPort,
flags.Concurrency)
}
+86
View File
@@ -0,0 +1,86 @@
package firewall
import (
"context"
"fmt"
"os"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/models"
"github.com/coollabsio/coolify-cli/internal/output"
"github.com/coollabsio/coolify-cli/internal/ssh"
)
// newListCommand builds `coolify firewall list`.
func newListCommand(flags *FirewallFlags) *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List installed allow rules across all servers",
RunE: func(cmd *cobra.Command, _ []string) error {
return runList(cmd.Context(), cmd, flags)
},
}
}
func runList(ctx context.Context, cmd *cobra.Command, flags *FirewallFlags) error {
if err := flags.SSHMeshFlags.Validate(); err != nil {
return err
}
runner, err := flags.BuildSSHClient()
if err != nil {
return fmt.Errorf("SSH client: %w", err)
}
return emitList(ctx, cmd, flags, runner)
}
func emitList(
ctx context.Context,
cmd *cobra.Command,
flags *FirewallFlags,
runner ssh.Runner,
) error {
all, results := listAllViaPkg(ctx, runner, flags)
rows := make([]models.AllowRuleRow, 0, len(all))
for _, r := range all {
rows = append(rows, models.AllowRuleRow{
Host: r.Host,
ID: r.Comment,
Src: r.Src.String(),
Dst: r.Dst.String(),
Proto: r.Proto,
Port: r.Port,
Comment: r.Comment,
})
}
var errs []string
for _, r := range results {
if r.Err != nil {
errs = append(errs, fmt.Sprintf("%s: %v", r.Host, r.Err))
}
}
for _, e := range errs {
fmt.Fprintln(os.Stderr, "Warning:", e)
}
format, _ := cmd.Root().PersistentFlags().GetString("format")
if format == "" {
format = output.FormatTable
}
formatter, err := output.NewFormatter(format, output.Options{Writer: os.Stdout})
if err != nil {
return err
}
if format == output.FormatJSON || format == output.FormatPretty {
return formatter.Format(models.FirewallListOutput{
Rules: rows, Errors: errs,
})
}
if len(rows) == 0 {
fmt.Fprintln(os.Stderr, "No allow rules found. Run `coolify firewall allow ...` to add one.")
return nil
}
return formatter.Format(rows)
}
+44
View File
@@ -0,0 +1,44 @@
package firewall
import (
"context"
"testing"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/coollabsio/coolify-cli/cmd/common"
)
func TestEmitList_RunsAndFormatsTable(t *testing.T) {
fr := &cmdFakeRunner{responses: map[string]string{
"iptables -S COOLIFY-ALLOW": `-A COOLIFY-ALLOW -s 10.0.0.1 -d 10.0.0.2 -p tcp -m tcp --dport 80 -j ACCEPT
`,
}}
parent := &FirewallFlags{
SSHMeshFlags: common.SSHMeshFlags{
Servers: []string{"h1"}, SSHUser: "root", SSHPort: 22, Concurrency: 1,
},
}
inner := &cobra.Command{Use: "list"}
rootCmdFor(inner)
err := emitList(context.Background(), inner, parent, fr)
assert.NoError(t, err)
assert.Len(t, fr.calls, 1)
assert.Contains(t, fr.calls[0], "iptables -S COOLIFY-ALLOW")
}
func TestEmitList_EmptyChain(t *testing.T) {
fr := &cmdFakeRunner{responses: map[string]string{}}
parent := &FirewallFlags{
SSHMeshFlags: common.SSHMeshFlags{
Servers: []string{"h1"}, SSHUser: "root", SSHPort: 22, Concurrency: 1,
},
}
inner := &cobra.Command{Use: "list"}
rootCmdFor(inner)
err := emitList(context.Background(), inner, parent, fr)
assert.NoError(t, err)
}
+113
View File
@@ -0,0 +1,113 @@
package firewall
import (
"fmt"
"net"
"strings"
ifw "github.com/coollabsio/coolify-cli/internal/firewall"
)
// resolveEndpoint turns a user-supplied reference (name, short-id, raw IP,
// or "host:name") into the container it points at. When ref is a raw IP
// that doesn't match any discovered container, it returns a synthetic
// entry with Host="" — the caller must derive Host some other way.
//
// Ambiguous names across hosts are rejected; the user must disambiguate
// with "host:name" or a short-ID.
func resolveEndpoint(ref string, all []ifw.Container) (ifw.Container, error) {
ref = strings.TrimSpace(ref)
if ref == "" {
return ifw.Container{}, fmt.Errorf("empty container reference")
}
// "host:name" form — exact host disambiguator.
if host, name, ok := splitHostName(ref); ok {
for _, c := range all {
if c.Host == host && c.Name == name {
return c, nil
}
}
return ifw.Container{}, fmt.Errorf("no container named %q on host %q", name, host)
}
// Raw IP form.
if ip := net.ParseIP(ref); ip != nil {
for _, c := range all {
if c.IP.Equal(ip) {
return c, nil
}
}
// Synthetic: caller must decide on Host.
return ifw.Container{IP: ip}, nil
}
// Name / short-id form. Collect matches, error on ambiguity.
var matches []ifw.Container
for _, c := range all {
if c.Name == ref || strings.HasPrefix(c.ID, ref) {
matches = append(matches, c)
}
}
switch len(matches) {
case 0:
return ifw.Container{}, fmt.Errorf("no container matches %q", ref)
case 1:
return matches[0], nil
default:
return ifw.Container{}, fmt.Errorf(
"reference %q is ambiguous across hosts (%s) — use host:name form",
ref, hostList(matches))
}
}
func splitHostName(ref string) (host, name string, ok bool) {
i := strings.IndexByte(ref, ':')
if i <= 0 || i == len(ref)-1 {
return "", "", false
}
// Reject if the part after `:` looks like a port (all digits) — likely
// an IP:port form the user didn't mean.
name = ref[i+1:]
host = ref[:i]
if allDigits(name) {
return "", "", false
}
return host, name, true
}
func allDigits(s string) bool {
if s == "" {
return false
}
for _, r := range s {
if r < '0' || r > '9' {
return false
}
}
return true
}
func hostList(cs []ifw.Container) string {
seen := map[string]bool{}
var hosts []string
for _, c := range cs {
if !seen[c.Host] {
hosts = append(hosts, c.Host)
seen[c.Host] = true
}
}
return strings.Join(hosts, ", ")
}
// findHostForIP returns the SSH host that owns ip (i.e. the host whose
// coolify-mesh bridge has ip assigned). Used when --to/--from is given as
// a raw IP not tied to a running container.
func findHostForIP(ip net.IP, all []ifw.Container) (string, bool) {
for _, c := range all {
if c.IP.Equal(ip) {
return c.Host, true
}
}
return "", false
}
+75
View File
@@ -0,0 +1,75 @@
package firewall
import (
"net"
"testing"
"github.com/stretchr/testify/assert"
ifw "github.com/coollabsio/coolify-cli/internal/firewall"
)
func cs() []ifw.Container {
return []ifw.Container{
{Host: "h1", ID: "aaa111111111", Name: "web", IP: net.ParseIP("10.210.0.10")},
{Host: "h2", ID: "bbb222222222", Name: "api", IP: net.ParseIP("10.210.1.10")},
{Host: "h3", ID: "ccc333333333", Name: "web", IP: net.ParseIP("10.210.2.10")},
}
}
func TestResolveEndpoint_ByName_Unique(t *testing.T) {
c, err := resolveEndpoint("api", cs())
assert.NoError(t, err)
assert.Equal(t, "h2", c.Host)
assert.Equal(t, "10.210.1.10", c.IP.String())
}
func TestResolveEndpoint_ByName_Ambiguous(t *testing.T) {
_, err := resolveEndpoint("web", cs())
assert.Error(t, err)
assert.Contains(t, err.Error(), "ambiguous")
}
func TestResolveEndpoint_ByShortID(t *testing.T) {
c, err := resolveEndpoint("bbb", cs())
assert.NoError(t, err)
assert.Equal(t, "h2", c.Host)
}
func TestResolveEndpoint_ByHostName(t *testing.T) {
c, err := resolveEndpoint("h3:web", cs())
assert.NoError(t, err)
assert.Equal(t, "h3", c.Host)
assert.Equal(t, "10.210.2.10", c.IP.String())
}
func TestResolveEndpoint_ByRawIP(t *testing.T) {
c, err := resolveEndpoint("10.210.1.10", cs())
assert.NoError(t, err)
assert.Equal(t, "h2", c.Host)
}
func TestResolveEndpoint_UnknownRawIP_Synthetic(t *testing.T) {
c, err := resolveEndpoint("10.99.99.99", cs())
assert.NoError(t, err)
assert.Equal(t, "", c.Host)
assert.Equal(t, "10.99.99.99", c.IP.String())
}
func TestResolveEndpoint_NotFound(t *testing.T) {
_, err := resolveEndpoint("nobody", cs())
assert.Error(t, err)
}
func TestResolveEndpoint_Empty(t *testing.T) {
_, err := resolveEndpoint("", cs())
assert.Error(t, err)
}
func TestFindHostForIP(t *testing.T) {
h, ok := findHostForIP(net.ParseIP("10.210.0.10"), cs())
assert.True(t, ok)
assert.Equal(t, "h1", h)
_, ok = findHostForIP(net.ParseIP("1.2.3.4"), cs())
assert.False(t, ok)
}
+5 -63
View File
@@ -3,23 +3,17 @@
package initcmd
import (
"fmt"
"os"
"time"
"github.com/spf13/cobra"
"golang.org/x/term"
internalssh "github.com/coollabsio/coolify-cli/internal/ssh"
"github.com/coollabsio/coolify-cli/cmd/common"
)
// InitFlags holds all flags shared between `plan` and `apply`.
type InitFlags struct {
Servers []string
SSHKey string
SSHUser string
SSHPort int
SSHPassphrasePrompt bool
common.SSHMeshFlags
WGMgmtPool string
ContainerPool string
ContainerPrefix int
@@ -33,63 +27,15 @@ type InitFlags struct {
CorrosionBinaryPath string
CorrosionGossipPort int
CorrosionAPIPort int
Concurrency int
SSHTimeout string
Yes bool
}
// ParseSSHTimeout parses the SSHTimeout string into a time.Duration.
func (f *InitFlags) ParseSSHTimeout() time.Duration {
d, err := time.ParseDuration(f.SSHTimeout)
if err != nil || d <= 0 {
return 30 * time.Second
}
return d
}
// ResolvePassphrase returns the SSH key passphrase in this priority order:
// 1. COOLIFY_SSH_PASSPHRASE env var
// 2. Interactive prompt when --ssh-passphrase-prompt is set
// 3. nil (no passphrase)
func (f *InitFlags) ResolvePassphrase() ([]byte, error) {
if env := os.Getenv("COOLIFY_SSH_PASSPHRASE"); env != "" {
return []byte(env), nil
}
if f.SSHPassphrasePrompt {
fmt.Fprint(os.Stderr, "SSH key passphrase: ")
pass, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Fprintln(os.Stderr) // newline after hidden input
if err != nil {
return nil, fmt.Errorf("read passphrase: %w", err)
}
return pass, nil
}
return nil, nil
}
// BuildSSHClient creates an SSH client, resolving any key passphrase first.
func (f *InitFlags) BuildSSHClient() (*internalssh.Client, error) {
passphrase, err := f.ResolvePassphrase()
if err != nil {
return nil, err
}
return internalssh.NewClient(f.SSHKey, passphrase, f.ParseSSHTimeout())
}
// bindInitFlags registers all shared flags as PersistentFlags on cmd.
func bindInitFlags(cmd *cobra.Command, f *InitFlags) {
common.BindSSHMeshFlags(cmd, &f.SSHMeshFlags)
pf := cmd.PersistentFlags()
pf.StringSliceVar(&f.Servers, "servers", nil,
"Comma-separated server IPs to include in the mesh (required for plan/apply)")
pf.StringVar(&f.SSHKey, "ssh-key", "",
"Path to SSH private key used to connect to servers (required for plan/apply)")
pf.StringVar(&f.SSHUser, "ssh-user", "root",
"SSH username")
pf.IntVar(&f.SSHPort, "ssh-port", 22,
"SSH port")
pf.BoolVar(&f.SSHPassphrasePrompt, "ssh-passphrase-prompt", false,
"Prompt for SSH key passphrase (also reads COOLIFY_SSH_PASSPHRASE env var)")
pf.StringVar(&f.WGMgmtPool, "wg-mgmt-pool", "100.64.0.0/16",
"WireGuard management address pool — each host gets a /32 from here, assigned to wg0")
pf.StringVar(&f.ContainerPool, "container-pool", "10.210.0.0/16",
@@ -118,10 +64,6 @@ func bindInitFlags(cmd *cobra.Command, f *InitFlags) {
"Corrosion SWIM gossip port (bound to the wg0 mgmt IP)")
pf.IntVar(&f.CorrosionAPIPort, "corrosion-api-port", 8080,
"Corrosion HTTP API port (bound to 127.0.0.1)")
pf.IntVar(&f.Concurrency, "concurrency", 10,
"Maximum number of parallel SSH connections")
pf.StringVar(&f.SSHTimeout, "ssh-timeout", "30s",
"SSH connection timeout (e.g. 30s, 1m)")
pf.BoolVarP(&f.Yes, "yes", "y", false,
"Skip the interactive alpha confirmation prompt")
}
+1 -7
View File
@@ -149,13 +149,7 @@ func runPlan(ctx context.Context, cmd *cobra.Command, flags *InitFlags) error {
}
func validatePlanFlags(f *InitFlags) error {
if len(f.Servers) == 0 {
return fmt.Errorf("--servers is required")
}
if f.SSHKey == "" {
return fmt.Errorf("--ssh-key is required")
}
return nil
return f.SSHMeshFlags.Validate()
}
// warningsToStrings formats allocator warnings as human-readable strings.
+12 -23
View File
@@ -4,47 +4,36 @@ import (
"context"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
// TestInitFlags_ParseSSHTimeout verifies the duration parsing helper.
func TestInitFlags_ParseSSHTimeout(t *testing.T) {
tests := []struct {
input string
want time.Duration
}{
{"30s", 30 * time.Second},
{"1m", time.Minute},
{"invalid", 30 * time.Second}, // default on parse failure
{"0s", 30 * time.Second}, // default when <= 0
{"", 30 * time.Second}, // default on empty
}
for _, tt := range tests {
f := &InitFlags{SSHTimeout: tt.input}
assert.Equal(t, tt.want, f.ParseSSHTimeout(), "input: %q", tt.input)
}
}
"github.com/coollabsio/coolify-cli/cmd/common"
)
// TestValidatePlanFlags checks required flag validation.
func TestValidatePlanFlags(t *testing.T) {
t.Run("missing servers", func(t *testing.T) {
err := validatePlanFlags(&InitFlags{SSHKey: "/path/to/key"})
err := validatePlanFlags(&InitFlags{
SSHMeshFlags: common.SSHMeshFlags{SSHKey: "/path/to/key"},
})
assert.Error(t, err)
assert.Contains(t, err.Error(), "--servers")
})
t.Run("missing ssh key", func(t *testing.T) {
err := validatePlanFlags(&InitFlags{Servers: []string{"1.1.1.1"}})
err := validatePlanFlags(&InitFlags{
SSHMeshFlags: common.SSHMeshFlags{Servers: []string{"1.1.1.1"}},
})
assert.Error(t, err)
assert.Contains(t, err.Error(), "--ssh-key")
})
t.Run("valid", func(t *testing.T) {
err := validatePlanFlags(&InitFlags{
Servers: []string{"1.1.1.1"},
SSHKey: "/path/to/key",
SSHMeshFlags: common.SSHMeshFlags{
Servers: []string{"1.1.1.1"},
SSHKey: "/path/to/key",
},
})
assert.NoError(t, err)
})
+2
View File
@@ -15,6 +15,7 @@ import (
"github.com/coollabsio/coolify-cli/cmd/context"
"github.com/coollabsio/coolify-cli/cmd/database"
"github.com/coollabsio/coolify-cli/cmd/deployment"
"github.com/coollabsio/coolify-cli/cmd/firewall"
"github.com/coollabsio/coolify-cli/cmd/github"
initcmd "github.com/coollabsio/coolify-cli/cmd/init"
"github.com/coollabsio/coolify-cli/cmd/privatekeys"
@@ -92,6 +93,7 @@ func init() {
rootCmd.AddCommand(context.NewContextCommand())
rootCmd.AddCommand(database.NewDatabaseCommand())
rootCmd.AddCommand(deployment.NewDeploymentCommand())
rootCmd.AddCommand(firewall.NewFirewallCommand())
rootCmd.AddCommand(github.NewGitHubCommand())
rootCmd.AddCommand(initcmd.NewInitCommand())
rootCmd.AddCommand(privatekeys.NewPrivateKeysCommand())
+105
View File
@@ -0,0 +1,105 @@
package firewall
import (
"context"
"fmt"
"strings"
"github.com/coollabsio/coolify-cli/internal/ssh"
)
// ApplyAllow idempotently appends r to COOLIFY-ALLOW on r.Host, installs the
// reboot-persistence unit on first call, and snapshots the chain to
// /etc/coolify/allow.rules so the rule survives a reboot.
//
// The operation is a single SSH session chained with `&&`:
//
// ensure chain exists → install persistence unit → -C || -A → save
//
// The chain-exists guard (`iptables -N COOLIFY-ALLOW || true`) covers the
// case where the user ran `coolify init` WITHOUT --default-deny: the chain
// simply won't exist. Without the guard every allow would fail.
func ApplyAllow(
ctx context.Context,
runner ssh.Runner,
r AllowRule,
user string,
port int,
) error {
if r.Host == "" {
return fmt.Errorf("AllowRule.Host must be set")
}
cmd := buildApplyCmd(r)
stdout, stderr, err := runner.Run(ctx, r.Host, user, port, cmd)
if err != nil {
return fmt.Errorf("apply allow on %s: %w (stderr: %s, stdout: %s)",
r.Host, err, strings.TrimSpace(stderr), strings.TrimSpace(stdout))
}
return nil
}
// RevokeAllow idempotently deletes r from COOLIFY-ALLOW on r.Host, then
// re-snapshots the chain. A missing rule is a no-op — the `-C` guard
// short-circuits before `-D` is attempted.
func RevokeAllow(
ctx context.Context,
runner ssh.Runner,
r AllowRule,
user string,
port int,
) error {
if r.Host == "" {
return fmt.Errorf("AllowRule.Host must be set")
}
cmd := buildRevokeCmd(r)
stdout, stderr, err := runner.Run(ctx, r.Host, user, port, cmd)
if err != nil {
return fmt.Errorf("revoke allow on %s: %w (stderr: %s, stdout: %s)",
r.Host, err, strings.TrimSpace(stderr), strings.TrimSpace(stdout))
}
return nil
}
// buildApplyCmd composes the idempotent apply script. Kept separate from
// ApplyAllow so tests can assert on the exact command without SSH mocking.
func buildApplyCmd(r AllowRule) string {
var b strings.Builder
// Guarantee chain exists even if --default-deny wasn't set at init.
b.WriteString(`iptables -N `)
b.WriteString(ChainName)
b.WriteString(` 2>/dev/null || true`)
b.WriteString(" && ")
// Install persistence unit. Idempotent; no-op on repeat.
b.WriteString(InstallPersistenceCommand())
b.WriteString(" && ")
// Idempotent append.
b.WriteString("( ")
b.WriteString(r.RenderCheck())
b.WriteString(" 2>/dev/null || ")
b.WriteString(r.RenderAppend())
b.WriteString(" )")
b.WriteString(" && ")
// Snapshot for reboot-persistence.
b.WriteString(SaveRulesCommand())
return b.String()
}
// buildRevokeCmd composes the idempotent revoke script.
func buildRevokeCmd(r AllowRule) string {
var b strings.Builder
// If the chain doesn't exist there's nothing to revoke.
b.WriteString("iptables -S ")
b.WriteString(ChainName)
b.WriteString(" >/dev/null 2>&1 || exit 0")
b.WriteString(" ; ")
// Delete if present (no-op on missing).
b.WriteString("( ")
b.WriteString(r.RenderCheck())
b.WriteString(" 2>/dev/null && ")
b.WriteString(r.RenderDelete())
b.WriteString(" || true")
b.WriteString(" )")
b.WriteString(" && ")
b.WriteString(SaveRulesCommand())
return b.String()
}
+65
View File
@@ -0,0 +1,65 @@
package firewall
import (
"context"
"net"
"testing"
"github.com/stretchr/testify/assert"
)
func mkRule() AllowRule {
return AllowRule{
Host: "h1",
Src: net.ParseIP("10.210.0.10"),
Dst: net.ParseIP("10.210.1.10"),
Proto: "tcp", Port: 80,
Comment: "cid:abc123def456",
}
}
func TestBuildApplyCmd_Shape(t *testing.T) {
cmd := buildApplyCmd(mkRule())
// Ordering: ensure chain → persistence unit → -C||-A → save.
ensureIdx := indexOf(cmd, "iptables -N COOLIFY-ALLOW")
unitIdx := indexOf(cmd, "systemctl enable "+PersistUnitName)
checkIdx := indexOf(cmd, "iptables -C COOLIFY-ALLOW")
appendIdx := indexOf(cmd, "iptables -A COOLIFY-ALLOW")
saveIdx := indexOf(cmd, "iptables -S "+ChainName)
assert.True(t, ensureIdx >= 0)
assert.True(t, unitIdx > ensureIdx)
assert.True(t, checkIdx > unitIdx)
assert.True(t, appendIdx > checkIdx)
assert.True(t, saveIdx > appendIdx)
}
func TestBuildRevokeCmd_Shape(t *testing.T) {
cmd := buildRevokeCmd(mkRule())
assert.Contains(t, cmd, "iptables -C COOLIFY-ALLOW")
assert.Contains(t, cmd, "iptables -D COOLIFY-ALLOW")
assert.Contains(t, cmd, "exit 0") // no-op when chain missing
assert.Contains(t, cmd, SaveRulesCommand())
}
func TestApplyAllow_MissingHost(t *testing.T) {
r := mkRule()
r.Host = ""
err := ApplyAllow(context.Background(), &fakeRunner{}, r, "root", 22)
assert.Error(t, err)
}
func TestApplyAllow_RunsAndCaptures(t *testing.T) {
fr := &fakeRunner{responses: map[string]string{}}
err := ApplyAllow(context.Background(), fr, mkRule(), "root", 22)
assert.NoError(t, err)
assert.Len(t, fr.calls, 1)
assert.Contains(t, fr.calls[0], "iptables -A COOLIFY-ALLOW")
}
func TestRevokeAllow_RunsAndCaptures(t *testing.T) {
fr := &fakeRunner{responses: map[string]string{}}
err := RevokeAllow(context.Background(), fr, mkRule(), "root", 22)
assert.NoError(t, err)
assert.Len(t, fr.calls, 1)
assert.Contains(t, fr.calls[0], "iptables -D COOLIFY-ALLOW")
}
+114
View File
@@ -0,0 +1,114 @@
package firewall
import (
"context"
"fmt"
"net"
"sort"
"strings"
"github.com/coollabsio/coolify-cli/internal/ssh"
)
// Container is a single running podman container on one mesh host.
type Container struct {
Host string // SSH host the container runs on
ID string // short (12-char) podman ID
Name string // podman container name
IP net.IP // IP on the coolify-mesh bridge network
}
// discoverScript prints one `id|name|ip` line per running container on the
// target network. Piped through `podman inspect` to resolve the per-network
// IP because `podman ps` doesn't surface that directly. `|| true` keeps the
// script from erroring when podman is absent or the network has no members.
func discoverScript(networkName string) string {
return fmt.Sprintf(
`podman ps --filter network=%[1]s --format '{{.ID}}|{{.Names}}' 2>/dev/null | `+
`while IFS='|' read id name; do `+
` [ -z "$id" ] && continue; `+
` ip=$(podman inspect --format '{{(index .NetworkSettings.Networks %[2]q).IPAddress}}' "$id" 2>/dev/null); `+
` printf '%%s|%%s|%%s\n' "$id" "$name" "$ip"; `+
`done || true`,
networkName, networkName)
}
// ParseDiscoverLine parses one `id|name|ip` line from discoverScript.
// Returns (_, false) when the line is blank or malformed.
func ParseDiscoverLine(line string) (id, name string, ip net.IP, ok bool) {
parts := strings.SplitN(strings.TrimSpace(line), "|", 3)
if len(parts) != 3 {
return "", "", nil, false
}
if parts[0] == "" || parts[1] == "" || parts[2] == "" {
return "", "", nil, false
}
ip = net.ParseIP(parts[2])
if ip == nil {
return "", "", nil, false
}
id = parts[0]
if len(id) > 12 {
id = id[:12]
}
return id, parts[1], ip, true
}
// DiscoverContainers SSHes into host and returns every container on
// networkName with its bridge IP.
func DiscoverContainers(
ctx context.Context,
runner ssh.Runner,
host, user string,
port int,
networkName string,
) ([]Container, error) {
stdout, _, err := runner.Run(ctx, host, user, port, discoverScript(networkName))
if err != nil {
return nil, fmt.Errorf("discover containers on %s: %w", host, err)
}
var out []Container
for _, line := range strings.Split(stdout, "\n") {
id, name, ip, ok := ParseDiscoverLine(line)
if !ok {
continue
}
out = append(out, Container{Host: host, ID: id, Name: name, IP: ip})
}
sort.Slice(out, func(i, j int) bool {
if out[i].Host != out[j].Host {
return out[i].Host < out[j].Host
}
return out[i].Name < out[j].Name
})
return out, nil
}
// DiscoverAll runs DiscoverContainers across every host in parallel.
// Returns a flattened, sort-stable slice plus the per-host results so
// callers can surface partial failures.
func DiscoverAll(
ctx context.Context,
runner ssh.Runner,
hosts []string,
user string,
port int,
networkName string,
concurrency int,
) ([]Container, []ssh.ServerResult[[]Container]) {
results := ssh.ForEachServer(ctx, hosts, concurrency,
func(ctx context.Context, host string) ([]Container, error) {
return DiscoverContainers(ctx, runner, host, user, port, networkName)
})
var all []Container
for _, r := range results {
all = append(all, r.Result...)
}
sort.Slice(all, func(i, j int) bool {
if all[i].Host != all[j].Host {
return all[i].Host < all[j].Host
}
return all[i].Name < all[j].Name
})
return all, results
}
+98
View File
@@ -0,0 +1,98 @@
package firewall
import (
"context"
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func TestParseDiscoverLine(t *testing.T) {
tests := []struct {
line string
wantOk bool
wantID string
wantNm string
wantIP string
}{
{"abcdef123456|web|10.210.0.10", true, "abcdef123456", "web", "10.210.0.10"},
{"abcdef1234567890|web|10.210.0.10", true, "abcdef123456", "web", "10.210.0.10"},
{"|name|10.0.0.1", false, "", "", ""},
{"id|name|", false, "", "", ""},
{"id|name|not-an-ip", false, "", "", ""},
{"", false, "", "", ""},
{"a|b", false, "", "", ""},
}
for _, tt := range tests {
t.Run(tt.line, func(t *testing.T) {
id, name, ip, ok := ParseDiscoverLine(tt.line)
assert.Equal(t, tt.wantOk, ok)
if !ok {
return
}
assert.Equal(t, tt.wantID, id)
assert.Equal(t, tt.wantNm, name)
assert.Equal(t, tt.wantIP, ip.String())
})
}
}
// fakeRunner is a deterministic ssh.Runner for firewall tests. Responses
// map a command substring to its canned stdout.
type fakeRunner struct {
responses map[string]string
calls []string
}
func (f *fakeRunner) Run(_ context.Context, _, _ string, _ int, cmd string) (string, string, error) {
f.calls = append(f.calls, cmd)
for sub, resp := range f.responses {
if strings.Contains(cmd, sub) {
return resp, "", nil
}
}
return "", "", nil
}
func TestDiscoverContainers(t *testing.T) {
r := &fakeRunner{responses: map[string]string{
"podman ps": "abc111111111|web|10.210.0.10\ndef222222222|api|10.210.0.11\n\n",
}}
got, err := DiscoverContainers(context.Background(), r, "h1", "root", 22, "coolify-mesh")
assert.NoError(t, err)
assert.Len(t, got, 2)
assert.Equal(t, "api", got[0].Name) // sorted by name
assert.Equal(t, "web", got[1].Name)
assert.Equal(t, "h1", got[0].Host)
assert.Equal(t, "10.210.0.11", got[0].IP.String())
}
func TestDiscoverContainers_EmptyOutput(t *testing.T) {
r := &fakeRunner{responses: map[string]string{}}
got, err := DiscoverContainers(context.Background(), r, "h1", "root", 22, "coolify-mesh")
assert.NoError(t, err)
assert.Empty(t, got)
}
func TestDiscoverContainers_BadLinesSkipped(t *testing.T) {
r := &fakeRunner{responses: map[string]string{
"podman ps": "abc111111111|web|10.210.0.10\ngarbage\n|noid|1.1.1.1\n",
}}
got, err := DiscoverContainers(context.Background(), r, "h1", "root", 22, "coolify-mesh")
assert.NoError(t, err)
assert.Len(t, got, 1)
assert.Equal(t, "web", got[0].Name)
}
func TestDiscoverAll_Sorted(t *testing.T) {
r := &fakeRunner{responses: map[string]string{
"podman ps": "aaa111111111|x|10.210.0.10",
}}
all, perHost := DiscoverAll(context.Background(), r,
[]string{"h2", "h1"}, "root", 22, "coolify-mesh", 2)
assert.Len(t, all, 2)
assert.Equal(t, "h1", all[0].Host)
assert.Equal(t, "h2", all[1].Host)
assert.Len(t, perHost, 2)
}
+71
View File
@@ -0,0 +1,71 @@
package firewall
import (
"context"
"fmt"
"sort"
"strings"
"github.com/coollabsio/coolify-cli/internal/ssh"
)
// ListAllow returns every rule currently installed in COOLIFY-ALLOW on host.
// Missing chain (e.g. default-deny not installed yet) yields an empty slice.
func ListAllow(
ctx context.Context,
runner ssh.Runner,
host, user string,
port int,
) ([]AllowRule, error) {
cmd := "iptables -S " + ChainName + " 2>/dev/null || true"
stdout, _, err := runner.Run(ctx, host, user, port, cmd)
if err != nil {
return nil, fmt.Errorf("list %s on %s: %w", ChainName, host, err)
}
var out []AllowRule
for _, line := range strings.Split(stdout, "\n") {
r, ok := ParseChainLine(line)
if !ok {
continue
}
r.Host = host
out = append(out, r)
}
return out, nil
}
// ListAll runs ListAllow on every host in parallel. Returns the flattened
// slice sorted by (host, src, dst, port) plus per-host results so callers
// can surface partial failures.
func ListAll(
ctx context.Context,
runner ssh.Runner,
hosts []string,
user string,
port int,
concurrency int,
) ([]AllowRule, []ssh.ServerResult[[]AllowRule]) {
results := ssh.ForEachServer(ctx, hosts, concurrency,
func(ctx context.Context, host string) ([]AllowRule, error) {
return ListAllow(ctx, runner, host, user, port)
})
var all []AllowRule
for _, r := range results {
all = append(all, r.Result...)
}
sort.Slice(all, func(i, j int) bool {
if all[i].Host != all[j].Host {
return all[i].Host < all[j].Host
}
si, sj := all[i].Src.String(), all[j].Src.String()
if si != sj {
return si < sj
}
di, dj := all[i].Dst.String(), all[j].Dst.String()
if di != dj {
return di < dj
}
return all[i].Port < all[j].Port
})
return all, results
}
+45
View File
@@ -0,0 +1,45 @@
package firewall
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
)
func TestListAllow_Parses(t *testing.T) {
r := &fakeRunner{responses: map[string]string{
"iptables -S COOLIFY-ALLOW": `-N COOLIFY-ALLOW
-A COOLIFY-ALLOW -s 10.210.0.10/32 -d 10.210.1.10/32 -p tcp -m tcp --dport 80 -m comment --comment "cid:abc123def456" -j ACCEPT
-A COOLIFY-ALLOW -s 10.210.0.11 -d 10.210.1.11 -p udp -m udp --dport 53 -j ACCEPT
`,
}}
got, err := ListAllow(context.Background(), r, "h1", "root", 22)
assert.NoError(t, err)
assert.Len(t, got, 2)
assert.Equal(t, "h1", got[0].Host)
assert.Equal(t, "tcp", got[0].Proto)
assert.Equal(t, 80, got[0].Port)
assert.Equal(t, "udp", got[1].Proto)
assert.Equal(t, 53, got[1].Port)
}
func TestListAllow_EmptyChainMissing(t *testing.T) {
r := &fakeRunner{responses: map[string]string{}}
got, err := ListAllow(context.Background(), r, "h1", "root", 22)
assert.NoError(t, err)
assert.Empty(t, got)
}
func TestListAll_Sorted(t *testing.T) {
r := &fakeRunner{responses: map[string]string{
"iptables -S COOLIFY-ALLOW": `-A COOLIFY-ALLOW -s 10.0.0.1 -d 10.0.0.2 -p tcp -m tcp --dport 80 -j ACCEPT
`,
}}
all, perHost := ListAll(context.Background(), r,
[]string{"h2", "h1"}, "root", 22, 2)
assert.Len(t, all, 2)
assert.Equal(t, "h1", all[0].Host)
assert.Equal(t, "h2", all[1].Host)
assert.Len(t, perHost, 2)
}
+55
View File
@@ -0,0 +1,55 @@
package firewall
// Paths and unit names kept in one place so the install and restore flows
// stay in sync.
const (
RulesPath = "/etc/coolify/allow.rules"
RulesDir = "/etc/coolify"
PersistUnitPath = "/etc/systemd/system/coolify-mesh-allow.service"
PersistUnitName = "coolify-mesh-allow.service"
)
// AllowPersistUnit returns the systemd unit text that restores the saved
// COOLIFY-ALLOW rules on boot. Ordered after coolify-mesh-fw.service so
// the chain exists before the restore runs. --noflush keeps tables intact.
func AllowPersistUnit() string {
return `[Unit]
Description=Coolify mesh COOLIFY-ALLOW restore
After=coolify-mesh-fw.service network-online.target
Wants=coolify-mesh-fw.service
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/bin/sh -c 'test -f ` + RulesPath + ` && /usr/sbin/iptables-restore --noflush ` + RulesPath + ` || true'
[Install]
WantedBy=multi-user.target
`
}
// SaveRulesCommand returns a shell one-liner that captures the current
// COOLIFY-ALLOW chain (only) and atomically writes it to RulesPath in
// iptables-restore format. Safe to chain after an `iptables -A/-D` call.
//
// We emit a minimal `*filter` + `:COOLIFY-ALLOW -` + `-A COOLIFY-ALLOW ...`
// + `COMMIT` block so `iptables-restore --noflush` only touches our chain.
func SaveRulesCommand() string {
return `mkdir -p ` + RulesDir + ` && ` +
`( printf '*filter\n:` + ChainName + ` -\n'; ` +
`iptables -S ` + ChainName + ` 2>/dev/null | grep '^-A ' || true; ` +
`printf 'COMMIT\n' ) > ` + RulesPath + `.tmp && ` +
`mv ` + RulesPath + `.tmp ` + RulesPath
}
// InstallPersistenceCommand returns a shell command that writes the
// coolify-mesh-allow.service unit, reloads systemd, enables/starts it.
// Idempotent: atomic rewrite, enable is a no-op when already enabled.
func InstallPersistenceCommand() string {
return `cat > ` + PersistUnitPath + `.tmp <<'COOLIFY_ALLOW_UNIT_EOF'
` + AllowPersistUnit() + `COOLIFY_ALLOW_UNIT_EOF
mv ` + PersistUnitPath + `.tmp ` + PersistUnitPath + ` && ` +
`systemctl daemon-reload && ` +
`systemctl enable ` + PersistUnitName + ` && ` +
`systemctl start ` + PersistUnitName
}
+35
View File
@@ -0,0 +1,35 @@
package firewall
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func TestAllowPersistUnit_ContainsExpected(t *testing.T) {
u := AllowPersistUnit()
assert.Contains(t, u, "After=coolify-mesh-fw.service")
assert.Contains(t, u, "iptables-restore --noflush "+RulesPath)
assert.Contains(t, u, "WantedBy=multi-user.target")
assert.Contains(t, u, "Type=oneshot")
assert.Contains(t, u, "RemainAfterExit=yes")
}
func TestSaveRulesCommand(t *testing.T) {
c := SaveRulesCommand()
assert.Contains(t, c, "mkdir -p "+RulesDir)
assert.Contains(t, c, "iptables -S "+ChainName)
assert.Contains(t, c, RulesPath+".tmp")
assert.Contains(t, c, "mv "+RulesPath+".tmp "+RulesPath)
// Rough shape check: single shell line.
assert.False(t, strings.Contains(c, "\n"))
}
func TestInstallPersistenceCommand(t *testing.T) {
c := InstallPersistenceCommand()
assert.Contains(t, c, PersistUnitPath+".tmp")
assert.Contains(t, c, "systemctl daemon-reload")
assert.Contains(t, c, "systemctl enable "+PersistUnitName)
assert.Contains(t, c, "systemctl start "+PersistUnitName)
}
+133
View File
@@ -0,0 +1,133 @@
// Package firewall implements the `coolify firewall` command logic:
// per-host container discovery, COOLIFY-ALLOW rule rendering and parsing,
// iptables apply/revoke over SSH, and reboot-persistence scaffolding.
//
// The package is the companion to `internal/wireguard`: it owns the dynamic
// allow-rule layer on top of the default-deny scaffold installed by
// `coolify init --podman --default-deny`. Rules go on the host that owns the
// destination IP (COOLIFY-INTRA fires on `-d <container-subnet>`), matching
// the design in CONTROL_PLANE.md §3.
package firewall
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"net"
"regexp"
"strconv"
"strings"
)
// ChainName is the iptables chain the v5 control plane owns. `coolify init
// --default-deny` creates it empty; this package fills it.
const ChainName = "COOLIFY-ALLOW"
// AllowRule is a single cross-host container allow entry.
//
// The rule lives on the host that owns Dst's container subnet (the default-
// deny jump fires on `-d <subnet> -j COOLIFY-INTRA`). Src may belong to any
// host in the mesh. Proto/Port are optional; zero values mean "any".
type AllowRule struct {
Host string // host that owns Dst's container subnet
Src net.IP
Dst net.IP
Proto string // "tcp" | "udp" | ""
Port int // 0 = any
Comment string // "cid:<12-hex>" stable identity for list/revoke
}
// ComputeID returns a 12-hex stable identity hash over (src, dst, proto, port).
// Used as the rule comment so `list` can display it and `revoke --from ...
// --to ... --port ...` finds the right rule without needing to parse.
func ComputeID(src, dst net.IP, proto string, port int) string {
h := sha256.New()
fmt.Fprintf(h, "%s|%s|%s|%d", src.String(), dst.String(), strings.ToLower(proto), port)
return hex.EncodeToString(h.Sum(nil))[:12]
}
// matchArgs renders the common iptables match portion for this rule.
// Used by RenderAppend / RenderDelete / RenderCheck so they stay in sync.
func (r AllowRule) matchArgs() string {
var b strings.Builder
fmt.Fprintf(&b, "-s %s -d %s", r.Src.String(), r.Dst.String())
if r.Proto != "" {
fmt.Fprintf(&b, " -p %s", r.Proto)
if r.Port > 0 {
fmt.Fprintf(&b, " --dport %d", r.Port)
}
}
if r.Comment != "" {
fmt.Fprintf(&b, " -m comment --comment %q", r.Comment)
}
b.WriteString(" -j ACCEPT")
return b.String()
}
// RenderAppend produces the `iptables -A COOLIFY-ALLOW ...` command.
func (r AllowRule) RenderAppend() string {
return fmt.Sprintf("iptables -A %s %s", ChainName, r.matchArgs())
}
// RenderDelete produces the `iptables -D COOLIFY-ALLOW ...` command.
func (r AllowRule) RenderDelete() string {
return fmt.Sprintf("iptables -D %s %s", ChainName, r.matchArgs())
}
// RenderCheck produces the `iptables -C COOLIFY-ALLOW ...` command,
// suitable as an idempotency guard (`-C ... || -A ...`).
func (r AllowRule) RenderCheck() string {
return fmt.Sprintf("iptables -C %s %s", ChainName, r.matchArgs())
}
// chainLineRegex parses one `-A COOLIFY-ALLOW ...` line from `iptables -S`.
// Captures in order: src, dst, optional proto, optional dport, optional comment.
var (
reSrc = regexp.MustCompile(`-s (\S+?)(?:/32)?(?:\s|$)`)
reDst = regexp.MustCompile(`-d (\S+?)(?:/32)?(?:\s|$)`)
reProto = regexp.MustCompile(`-p (\S+)`)
reDport = regexp.MustCompile(`--dport (\d+)`)
reComment = regexp.MustCompile(`--comment "([^"]*)"|--comment (\S+)`)
)
// ParseChainLine parses a single `-A COOLIFY-ALLOW ...` output line from
// `iptables -S COOLIFY-ALLOW` into an AllowRule. Returns (_, false) when
// the line is not an append or cannot be parsed (missing src/dst).
func ParseChainLine(line string) (AllowRule, bool) {
line = strings.TrimSpace(line)
prefix := "-A " + ChainName + " "
if !strings.HasPrefix(line, prefix) {
return AllowRule{}, false
}
rest := line[len(prefix):]
srcMatch := reSrc.FindStringSubmatch(rest)
dstMatch := reDst.FindStringSubmatch(rest)
if len(srcMatch) < 2 || len(dstMatch) < 2 {
return AllowRule{}, false
}
src := net.ParseIP(srcMatch[1])
dst := net.ParseIP(dstMatch[1])
if src == nil || dst == nil {
return AllowRule{}, false
}
r := AllowRule{Src: src, Dst: dst}
if m := reProto.FindStringSubmatch(rest); len(m) >= 2 {
r.Proto = m[1]
}
if m := reDport.FindStringSubmatch(rest); len(m) >= 2 {
if n, err := strconv.Atoi(m[1]); err == nil {
r.Port = n
}
}
if m := reComment.FindStringSubmatch(rest); len(m) >= 2 {
if m[1] != "" {
r.Comment = m[1]
} else if len(m) >= 3 {
r.Comment = m[2]
}
}
return r, true
}
+176
View File
@@ -0,0 +1,176 @@
package firewall
import (
"net"
"testing"
"github.com/stretchr/testify/assert"
)
func TestComputeID_Stable(t *testing.T) {
a := ComputeID(net.ParseIP("10.210.0.10"), net.ParseIP("10.210.1.10"), "tcp", 80)
b := ComputeID(net.ParseIP("10.210.0.10"), net.ParseIP("10.210.1.10"), "tcp", 80)
assert.Equal(t, a, b)
assert.Len(t, a, 12)
}
func TestComputeID_CaseInsensitiveProto(t *testing.T) {
a := ComputeID(net.ParseIP("1.1.1.1"), net.ParseIP("2.2.2.2"), "TCP", 80)
b := ComputeID(net.ParseIP("1.1.1.1"), net.ParseIP("2.2.2.2"), "tcp", 80)
assert.Equal(t, a, b)
}
func TestComputeID_DifferentInputsDifferent(t *testing.T) {
a := ComputeID(net.ParseIP("1.1.1.1"), net.ParseIP("2.2.2.2"), "tcp", 80)
b := ComputeID(net.ParseIP("1.1.1.1"), net.ParseIP("2.2.2.2"), "tcp", 443)
assert.NotEqual(t, a, b)
}
func TestAllowRule_RenderAppend(t *testing.T) {
tests := []struct {
name string
rule AllowRule
want string
}{
{
name: "tcp with port and comment",
rule: AllowRule{
Src: net.ParseIP("10.210.0.10"), Dst: net.ParseIP("10.210.1.10"),
Proto: "tcp", Port: 80, Comment: "cid:abc123def456",
},
want: `iptables -A COOLIFY-ALLOW -s 10.210.0.10 -d 10.210.1.10 -p tcp --dport 80 -m comment --comment "cid:abc123def456" -j ACCEPT`,
},
{
name: "udp with port",
rule: AllowRule{
Src: net.ParseIP("10.210.0.10"), Dst: net.ParseIP("10.210.1.10"),
Proto: "udp", Port: 53,
},
want: `iptables -A COOLIFY-ALLOW -s 10.210.0.10 -d 10.210.1.10 -p udp --dport 53 -j ACCEPT`,
},
{
name: "no proto no port",
rule: AllowRule{
Src: net.ParseIP("10.210.0.10"), Dst: net.ParseIP("10.210.1.10"),
},
want: `iptables -A COOLIFY-ALLOW -s 10.210.0.10 -d 10.210.1.10 -j ACCEPT`,
},
{
name: "proto without port",
rule: AllowRule{
Src: net.ParseIP("10.210.0.10"), Dst: net.ParseIP("10.210.1.10"),
Proto: "tcp",
},
want: `iptables -A COOLIFY-ALLOW -s 10.210.0.10 -d 10.210.1.10 -p tcp -j ACCEPT`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, tt.rule.RenderAppend())
})
}
}
func TestAllowRule_RenderDelete_And_Check(t *testing.T) {
r := AllowRule{
Src: net.ParseIP("10.0.0.1"), Dst: net.ParseIP("10.0.0.2"),
Proto: "tcp", Port: 8080, Comment: "cid:xyz000000001",
}
assert.Contains(t, r.RenderDelete(), "iptables -D COOLIFY-ALLOW")
assert.Contains(t, r.RenderCheck(), "iptables -C COOLIFY-ALLOW")
// Match args identical between -D and -A.
_, after, _ := splitAtFirst(r.RenderAppend(), "COOLIFY-ALLOW ")
_, afterDel, _ := splitAtFirst(r.RenderDelete(), "COOLIFY-ALLOW ")
assert.Equal(t, after, afterDel)
}
func splitAtFirst(s, sep string) (before, after string, ok bool) {
i := indexOf(s, sep)
if i < 0 {
return s, "", false
}
return s[:i], s[i+len(sep):], true
}
func indexOf(s, sub string) int {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return i
}
}
return -1
}
func TestParseChainLine(t *testing.T) {
tests := []struct {
name string
line string
wantOk bool
wantSrc string
wantDst string
wantPro string
wantPrt int
wantCmt string
}{
{
name: "full rule with comment",
line: `-A COOLIFY-ALLOW -s 10.210.0.10/32 -d 10.210.1.10/32 -p tcp -m tcp --dport 80 -m comment --comment "cid:abc123def456" -j ACCEPT`,
wantOk: true,
wantSrc: "10.210.0.10",
wantDst: "10.210.1.10",
wantPro: "tcp",
wantPrt: 80,
wantCmt: "cid:abc123def456",
},
{
name: "no port, unquoted comment",
line: `-A COOLIFY-ALLOW -s 10.0.0.1 -d 10.0.0.2 -m comment --comment cid:simple123456 -j ACCEPT`,
wantOk: true,
wantSrc: "10.0.0.1",
wantDst: "10.0.0.2",
wantPro: "",
wantPrt: 0,
wantCmt: "cid:simple123456",
},
{
name: "udp with port",
line: `-A COOLIFY-ALLOW -s 10.0.0.1 -d 10.0.0.2 -p udp -m udp --dport 53 -j ACCEPT`,
wantOk: true,
wantSrc: "10.0.0.1",
wantDst: "10.0.0.2",
wantPro: "udp",
wantPrt: 53,
},
{
name: "other chain",
line: `-A FORWARD -j DROP`,
wantOk: false,
},
{
name: "empty",
line: ``,
wantOk: false,
},
{
name: "missing src",
line: `-A COOLIFY-ALLOW -d 10.0.0.2 -j ACCEPT`,
wantOk: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r, ok := ParseChainLine(tt.line)
assert.Equal(t, tt.wantOk, ok)
if !tt.wantOk {
return
}
assert.Equal(t, tt.wantSrc, r.Src.String())
assert.Equal(t, tt.wantDst, r.Dst.String())
assert.Equal(t, tt.wantPro, r.Proto)
assert.Equal(t, tt.wantPrt, r.Port)
if tt.wantCmt != "" {
assert.Equal(t, tt.wantCmt, r.Comment)
}
})
}
}
+37
View File
@@ -0,0 +1,37 @@
package models
// ContainerRow is a table-friendly row for `coolify firewall containers`.
type ContainerRow struct {
Host string `json:"host"`
ID string `json:"id"`
Name string `json:"name"`
IP string `json:"ip"`
}
// AllowRuleRow is a table-friendly row for `coolify firewall list`.
type AllowRuleRow struct {
Host string `json:"host"`
ID string `json:"id"`
Src string `json:"src"`
Dst string `json:"dst"`
Proto string `json:"proto,omitempty"`
Port int `json:"port,omitempty"`
Comment string `json:"comment,omitempty"`
}
// FirewallContainersOutput is the JSON output for `firewall containers`.
type FirewallContainersOutput struct {
Containers []ContainerRow `json:"containers"`
Errors []string `json:"errors,omitempty"`
}
// FirewallListOutput is the JSON output for `firewall list`.
type FirewallListOutput struct {
Rules []AllowRuleRow `json:"rules"`
Errors []string `json:"errors,omitempty"`
}
// FirewallAllowOutput is the JSON output for `firewall allow` / `revoke`.
type FirewallAllowOutput struct {
Rules []AllowRuleRow `json:"rules"`
}