Files
coolify-cli/internal/wireguard/plan.go
T
Andras Bacsai 346320504c style: align struct literals and promote deps to direct
Promote golang-jwt/jwt/v5, mattn/go-isatty, golang.org/x/crypto, and
golang.org/x/term from indirect to direct dependencies in go.mod.

Fix data races in firewall test fakes by guarding calls slice with sync.Mutex.

Reformat struct literals and map literals across cmd, internal/wireguard,
and internal/firewall for consistent column alignment.
2026-04-21 21:24:21 +02:00

554 lines
17 KiB
Go

package wireguard
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"net"
"sort"
"strings"
"github.com/coollabsio/coolify-cli/internal/services"
)
// ActionType identifies the kind of change required.
type ActionType string
const (
ActionInstallWG ActionType = "install-wg"
ActionGenKeyPair ActionType = "gen-keypair"
ActionAllocateMgmtIP ActionType = "allocate-mgmt-ip"
ActionAllocateContainerSubnet ActionType = "allocate-container-subnet"
ActionWriteConfig ActionType = "write-config"
ActionEnableService ActionType = "enable-service"
ActionReloadService ActionType = "reload-service"
ActionAddPeer ActionType = "add-peer"
ActionRemovePeer ActionType = "remove-peer"
ActionInstallPodman ActionType = "install-podman"
ActionEnablePodmanSocket ActionType = "enable-podman-socket"
ActionEnableIPForward ActionType = "enable-ip-forward"
ActionCreatePodmanNet ActionType = "create-podman-network"
ActionRecreatePodmanNet ActionType = "recreate-podman-network"
ActionInstallFirewall ActionType = "install-firewall"
ActionInstallCorrosion ActionType = "install-corrosion"
ActionInstallCoold ActionType = "install-coold"
ActionWriteCorrosionConfig ActionType = "write-corrosion-config"
ActionWriteCorrosionSchema ActionType = "write-corrosion-schema"
ActionInstallCorrosionService ActionType = "install-corrosion-service"
ActionInstallCooldService ActionType = "install-coold-service"
ActionInstallRedis ActionType = "install-redis"
ActionInstallBroker ActionType = "install-broker"
ActionGenerateJWTKeypair ActionType = "generate-jwt-keypair"
ActionInstallBrokerService ActionType = "install-broker-service"
ActionWriteHostJWT ActionType = "write-host-jwt"
ActionUpdateCooldBrokerEnv ActionType = "update-coold-broker-env"
)
// PlannedAction is one step that apply must execute on a host.
type PlannedAction struct {
Host string
Namespace string // empty for host-global actions
Type ActionType
Detail string
}
// Plan is the list of actions needed to converge the mesh to the desired state.
type Plan struct {
Actions []PlannedAction
// MgmtAssignments maps host → planned WG management /32 IP.
MgmtAssignments map[string]net.IP
// SubnetAssignments maps namespace → host → planned container subnet.
SubnetAssignments map[string]map[string]*net.IPNet
// Warnings contains non-fatal conflict messages from the IP allocator.
Warnings []Warning
}
// IsEmpty returns true when the mesh is already converged (no changes needed).
func (p *Plan) IsEmpty() bool { return len(p.Actions) == 0 }
// BuildPlan computes the actions required to bring current into alignment
// with desired. It is a pure function: no SSH, no I/O.
func BuildPlan(desired *DesiredMesh, current MeshState) (*Plan, error) {
if desired.DefaultDenyContainers && !desired.InstallPodman {
return nil, fmt.Errorf("--default-deny requires --podman")
}
if desired.InstallCoold && !desired.InstallPodman {
return nil, fmt.Errorf("--install-coold requires --podman")
}
if desired.InstallPodman && len(desired.Namespaces) == 0 {
return nil, fmt.Errorf("at least one namespace is required")
}
// Validate per-host preconditions before computing actions.
for _, host := range desired.Hosts {
if state, ok := current.Servers[host]; ok && desired.DefaultDenyContainers {
if !state.NftAvailable {
return nil, fmt.Errorf(
"host %s: nft binary not available; install nftables or pass --skip-default-deny",
host,
)
}
}
}
mgmtAssignments, mgmtWarns, err := AllocateMgmtIPs(desired.MgmtPool, current.AssignedMgmtIPs(), desired.Hosts)
if err != nil {
return nil, fmt.Errorf("mgmt IP allocation: %w", err)
}
containerAssignments, contWarns, err := AllocateNamespaced(
desired.ContainerPool, desired.ContainerPrefix,
current.AssignedContainerSubnets(), desired.Namespaces, desired.Hosts)
if err != nil {
return nil, fmt.Errorf("container subnet allocation: %w", err)
}
plan := &Plan{
MgmtAssignments: mgmtAssignments,
SubnetAssignments: containerAssignments,
Warnings: append(mgmtWarns, contWarns...),
}
nsSorted := desired.SortedNamespaces()
for _, host := range desired.Hosts {
state, ok := current.Servers[host]
if !ok {
state = &ServerState{Host: host, Namespaces: map[string]*NamespaceServerState{}}
}
if state.Namespaces == nil {
state.Namespaces = map[string]*NamespaceServerState{}
}
// --- WireGuard installation ---
if !state.Installed {
plan.Actions = append(plan.Actions, PlannedAction{
Host: host,
Type: ActionInstallWG,
Detail: "wireguard not installed",
})
}
// --- Key generation ---
if !state.KeysExist {
plan.Actions = append(plan.Actions, PlannedAction{
Host: host,
Type: ActionGenKeyPair,
Detail: "no keys at /etc/wireguard/privatekey",
})
}
// --- Mgmt IP allocation ---
mgmtIP := mgmtAssignments[host]
if state.WireGuardMgmtIP == nil ||
!state.WireGuardMgmtIP.Equal(mgmtIP) {
plan.Actions = append(plan.Actions, PlannedAction{
Host: host,
Type: ActionAllocateMgmtIP,
Detail: fmt.Sprintf("%s/32", mgmtIP),
})
}
// --- Container subnet allocation (one per namespace) ---
if desired.InstallPodman {
for _, ns := range nsSorted {
contSubnet := containerAssignments[ns][host]
current := state.Namespaces[ns]
if current == nil || current.ContainerSubnet == nil ||
current.ContainerSubnet.String() != contSubnet.String() {
plan.Actions = append(plan.Actions, PlannedAction{
Host: host,
Namespace: ns,
Type: ActionAllocateContainerSubnet,
Detail: contSubnet.String(),
})
}
}
}
// --- Peer diff ---
desiredPeerKeys := make(map[string]bool)
for _, peer := range desired.Hosts {
if peer == host {
continue
}
if ps, ok2 := current.Servers[peer]; ok2 && ps.PublicKey != "" {
desiredPeerKeys[ps.PublicKey] = true
}
}
currentPeerKeys := make(map[string]bool)
for _, p := range state.Peers {
currentPeerKeys[p.PublicKey] = true
}
for key := range desiredPeerKeys {
if !currentPeerKeys[key] {
plan.Actions = append(plan.Actions, PlannedAction{
Host: host,
Type: ActionAddPeer,
Detail: truncateKey(key),
})
}
}
for key := range currentPeerKeys {
if !desiredPeerKeys[key] {
plan.Actions = append(plan.Actions, PlannedAction{
Host: host,
Type: ActionRemovePeer,
Detail: truncateKey(key),
})
}
}
// --- Config write ---
mgmtMismatch := state.WireGuardMgmtIP == nil || !state.WireGuardMgmtIP.Equal(mgmtIP)
allowedIPsDrift := allowedIPsNeedsRewrite(host, desired, current, containerAssignments, mgmtAssignments, state)
needsConfig := mgmtMismatch ||
allowedIPsDrift ||
len(plan.actionsForHost(host, ActionAddPeer)) > 0 ||
len(plan.actionsForHost(host, ActionRemovePeer)) > 0 ||
!state.KeysExist ||
!state.Installed ||
len(desired.Hosts) > 1 && state.ListenPort != desired.ListenPort
if needsConfig {
plan.Actions = append(plan.Actions, PlannedAction{
Host: host,
Type: ActionWriteConfig,
Detail: fmt.Sprintf("%s.conf (%d peer(s))", desired.Interface, len(desired.Hosts)-1),
})
}
// --- WG service ---
if !state.Active {
plan.Actions = append(plan.Actions, PlannedAction{
Host: host,
Type: ActionEnableService,
Detail: fmt.Sprintf("systemctl enable --now wg-quick@%s", desired.Interface),
})
} else if needsConfig {
plan.Actions = append(plan.Actions, PlannedAction{
Host: host,
Type: ActionReloadService,
Detail: fmt.Sprintf("systemctl reload wg-quick@%s (config changed)", desired.Interface),
})
}
// --- Podman stack ---
if desired.InstallPodman {
if !state.PodmanInstalled {
plan.Actions = append(plan.Actions, PlannedAction{
Host: host,
Type: ActionInstallPodman,
Detail: "podman not installed",
})
}
if !state.PodmanSocketActive {
plan.Actions = append(plan.Actions, PlannedAction{
Host: host,
Type: ActionEnablePodmanSocket,
Detail: "systemctl enable --now podman.socket",
})
}
if !state.IPForwardEnabled {
plan.Actions = append(plan.Actions, PlannedAction{
Host: host,
Type: ActionEnableIPForward,
Detail: "net.ipv4.ip_forward=1",
})
}
for _, ns := range nsSorted {
contSubnet := containerAssignments[ns][host]
netName := PodmanNetworkFor(ns)
nss := state.Namespaces[ns]
gw := MachineIP(contSubnet)
if nss == nil || !nss.NetworkExists {
plan.Actions = append(plan.Actions, PlannedAction{
Host: host,
Namespace: ns,
Type: ActionCreatePodmanNet,
Detail: fmt.Sprintf("%s subnet=%s gateway=%s", netName, contSubnet, gw),
})
continue
}
if nss.DNSEnabled ||
(nss.ContainerSubnet != nil && nss.ContainerSubnet.String() != contSubnet.String()) ||
nss.Label != ns {
reasons := []string{}
if nss.DNSEnabled {
reasons = append(reasons, "dns_enabled=true")
}
if nss.ContainerSubnet != nil && nss.ContainerSubnet.String() != contSubnet.String() {
reasons = append(reasons, fmt.Sprintf("subnet drift (have %s, want %s)", nss.ContainerSubnet, contSubnet))
}
if nss.Label != ns {
reasons = append(reasons, fmt.Sprintf("label=%q mismatch", nss.Label))
}
plan.Actions = append(plan.Actions, PlannedAction{
Host: host,
Namespace: ns,
Type: ActionRecreatePodmanNet,
Detail: fmt.Sprintf("%s — %s", netName, strings.Join(reasons, "; ")),
})
}
}
// Expected firewall unit text — hash it and compare against the
// remote unit so adding/removing a namespace reinstalls the unit.
var subnets []*net.IPNet
for _, ns := range nsSorted {
subnets = append(subnets, containerAssignments[ns][host])
}
expectedUnit := FirewallServiceUnit(desired.Interface, desired.SortedNamespaces(), subnets, desired.DefaultDenyContainers)
expectedUnitHash := sha256Hex([]byte(expectedUnit))
unitDrift := state.FirewallUnitSha256 != expectedUnitHash
if !state.FirewallActive ||
state.DefaultDenyActive != desired.DefaultDenyContainers ||
unitDrift {
detail := fmt.Sprintf("coolify-mesh-fw.service (%s, %d namespace(s), default-deny=%v)",
desired.Interface, len(subnets), desired.DefaultDenyContainers)
if unitDrift && state.FirewallUnitSha256 != "" {
detail += " [unit drift]"
}
plan.Actions = append(plan.Actions, PlannedAction{
Host: host,
Type: ActionInstallFirewall,
Detail: detail,
})
}
}
// --- Corrosion + coold stack (v5 control plane) ---
if desired.InstallCoold {
corrosionDrift := binaryVersionDrift(desired.CorrosionVersion, state.CorrosionInstalled, state.CorrosionVersion)
cooldDrift := binaryVersionDrift(desired.CooldVersion, state.CooldInstalled, state.CooldVersion)
if corrosionDrift {
plan.Actions = append(plan.Actions, PlannedAction{
Host: host,
Type: ActionInstallCorrosion,
Detail: fmt.Sprintf("corrosion %s → /usr/local/bin/corrosion", desired.CorrosionVersion),
})
}
if cooldDrift {
plan.Actions = append(plan.Actions, PlannedAction{
Host: host,
Type: ActionInstallCoold,
Detail: fmt.Sprintf("coold %s → /usr/local/bin/coold", desired.CooldVersion),
})
}
peers := peerMgmtIPs(host, desired.Hosts, mgmtAssignments)
expectedConfig := services.CorrosionConfigBytes(mgmtIP,
desired.CorrosionGossipPort, desired.CorrosionAPIPort, peers)
expectedHash := sha256Hex(expectedConfig)
configDrift := state.CorrosionConfigHash != expectedHash
if configDrift {
plan.Actions = append(plan.Actions, PlannedAction{
Host: host,
Type: ActionWriteCorrosionConfig,
Detail: fmt.Sprintf("/etc/corrosion/config.toml (peers=%d)", len(peers)),
})
}
expectedSchemaSha := sha256Hex([]byte(services.CoolifySchemaSQL))
schemaDrift := state.CorrosionSchemaSha256 != expectedSchemaSha
if !state.CorrosionSchemaExists || schemaDrift {
detail := "/etc/corrosion/schemas/coolify.sql"
if schemaDrift && state.CorrosionSchemaSha256 != "" {
detail += " [schema drift — DB will be reset]"
}
plan.Actions = append(plan.Actions, PlannedAction{
Host: host,
Type: ActionWriteCorrosionSchema,
Detail: detail,
})
}
nsConfigs := buildNamespaceConfigs(host, nsSorted, containerAssignments)
expectedCooldUnit := services.CooldServiceUnit(mgmtIP, nsConfigs)
cooldUnitDrift := state.CooldUnitSha256 != sha256Hex([]byte(expectedCooldUnit))
if !state.CorrosionActive || configDrift || corrosionDrift || schemaDrift {
plan.Actions = append(plan.Actions, PlannedAction{
Host: host,
Type: ActionInstallCorrosionService,
Detail: "systemctl enable --now corrosion",
})
}
if !state.CooldActive || configDrift || cooldDrift || cooldUnitDrift {
detail := fmt.Sprintf("systemctl enable --now coold (mgmt=%s, namespaces=%d)", mgmtIP, len(nsConfigs))
if cooldUnitDrift && state.CooldUnitSha256 != "" {
detail += " [unit drift]"
}
plan.Actions = append(plan.Actions, PlannedAction{
Host: host,
Type: ActionInstallCooldService,
Detail: detail,
})
}
}
}
return plan, nil
}
// buildNamespaceConfigs builds the per-namespace CooldNamespace slice for this
// host, in namespace name order. Gateway IP for each namespace is the .1 of
// that namespace's per-host container subnet.
func buildNamespaceConfigs(host string, nsSorted []string, assignments map[string]map[string]*net.IPNet) []services.CooldNamespace {
out := make([]services.CooldNamespace, 0, len(nsSorted))
for _, ns := range nsSorted {
subnet := assignments[ns][host]
if subnet == nil {
continue
}
out = append(out, services.CooldNamespace{
Name: ns,
Network: PodmanNetworkFor(ns),
BridgeGateway: MachineIP(subnet),
})
}
return out
}
// binaryVersionDrift returns true when a binary needs (re-)installation.
// Rules:
// - not installed → always drift
// - marker absent (empty haveVersion) → treat as drift (first-migration case)
// - "nightly" tag → always re-install (moving target)
// - pinned tag → drift only when marker differs from desired
func binaryVersionDrift(desiredVersion string, installed bool, haveVersion string) bool {
if !installed || haveVersion == "" {
return true
}
if desiredVersion == "nightly" {
return true
}
return haveVersion != desiredVersion
}
// allowedIPsNeedsRewrite returns true when any [Peer] block on host does not
// have the expected AllowedIPs (peer mgmt /32 + every namespace subnet).
func allowedIPsNeedsRewrite(
host string,
desired *DesiredMesh,
current MeshState,
containerAssignments map[string]map[string]*net.IPNet,
mgmtAssignments map[string]net.IP,
state *ServerState,
) bool {
if state == nil {
return false
}
nsSorted := desired.SortedNamespaces()
// Build pub-key → expected AllowedIPs set for every peer we should have.
want := map[string]map[string]struct{}{}
for _, peer := range desired.Hosts {
if peer == host {
continue
}
ps, ok := current.Servers[peer]
if !ok || ps.PublicKey == "" {
continue
}
mgmtIP := mgmtAssignments[peer]
if mgmtIP == nil {
continue
}
entries := map[string]struct{}{fmt.Sprintf("%s/32", mgmtIP): {}}
for _, ns := range nsSorted {
if sn := containerAssignments[ns][peer]; sn != nil {
entries[sn.String()] = struct{}{}
}
}
want[ps.PublicKey] = entries
}
// Compare against parsed peers in the current config. If any desired peer
// has different AllowedIPs (missing or extra), we need to rewrite.
have := map[string]map[string]struct{}{}
for _, p := range state.Peers {
s := map[string]struct{}{}
for _, a := range p.AllowedIPs {
s[strings.TrimSpace(a)] = struct{}{}
}
have[p.PublicKey] = s
}
for pk, wantSet := range want {
haveSet, ok := have[pk]
if !ok {
return true
}
if !sameStringSet(wantSet, haveSet) {
return true
}
}
return false
}
func sameStringSet(a, b map[string]struct{}) bool {
if len(a) != len(b) {
return false
}
for k := range a {
if _, ok := b[k]; !ok {
return false
}
}
return true
}
// peerMgmtIPs returns the mgmt IPs of all hosts except self, drawn from the
// planned assignments so the result is stable even before any host has been
// probed.
func peerMgmtIPs(self string, hosts []string, assignments map[string]net.IP) []net.IP {
out := make([]net.IP, 0, len(hosts)-1)
for _, h := range hosts {
if h == self {
continue
}
if ip, ok := assignments[h]; ok && ip != nil {
out = append(out, ip)
}
}
return out
}
func sha256Hex(b []byte) string {
sum := sha256.Sum256(b)
return hex.EncodeToString(sum[:])
}
// actionsForHost returns the subset of plan.Actions matching host and atype.
func (p *Plan) actionsForHost(host string, atype ActionType) []PlannedAction {
var out []PlannedAction
for _, a := range p.Actions {
if a.Host == host && a.Type == atype {
out = append(out, a)
}
}
return out
}
// truncateKey shortens a base64 key to the first 8 chars + "…" for display.
func truncateKey(key string) string {
if len(key) <= 8 {
return key
}
return key[:8] + "..."
}
// nsSortedCopy returns a fresh sorted copy (package-private helper exposed
// for tests and for callers that can't reach into DesiredMesh directly).
func nsSortedCopy(ns []string) []string {
out := append([]string(nil), ns...)
sort.Strings(out)
return out
}