forked from mirror/coolify-cli
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:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
Reference in New Issue
Block a user