Add custom image handling and access policy management

- Implement tests for custom KVM and LXC image creation, ensuring invalid sources and architecture mismatches are rejected.
- Introduce access policy management in CLI, allowing configuration of allowed sources and trusted proxies.
- Add NAT network configuration with validation for RFC1918 compliance and subnet parsing.
- Create panel access policy management, including normalization and evaluation of access decisions based on client IPs and forwarded headers.
- Develop middleware for enforcing access policies in the server, returning appropriate responses for allowed and denied requests.
- Enhance custom image downloading and validation, ensuring integrity and security of downloaded root filesystem archives.
- Include comprehensive tests for all new functionalities to ensure reliability and correctness.
This commit is contained in:
MengMengCode
2026-07-26 04:04:45 +08:00
parent 8283b88ded
commit 38debab1aa
49 changed files with 4504 additions and 246 deletions
+189 -7
View File
@@ -794,6 +794,8 @@ type ClicdConfig struct {
NextSSHPort int `json:"next_ssh_port"`
NATPortStart int `json:"nat_port_start"`
NATPortEnd int `json:"nat_port_end"`
LXCNATSubnet string `json:"lxc_nat_subnet"`
KVMNATSubnet string `json:"kvm_nat_subnet"`
SetupComplete bool `json:"setup_complete"`
SubUsers []SubUser `json:"sub_users"`
ApiKeys []ApiKeyConfig `json:"api_keys"`
@@ -801,10 +803,13 @@ type ClicdConfig struct {
Tasks []SavedTask `json:"tasks"`
LoginLogs []SavedLoginLog `json:"login_logs"`
EnabledImages []string `json:"enabled_images"`
CustomKVMImages []CustomKVMImage `json:"custom_kvm_images"`
CustomLXCImages []CustomLXCImage `json:"custom_lxc_images"`
Snapshots []Snapshot `json:"snapshots"`
PublicIPv4Pool []PublicIPv4Assignment `json:"public_ipv4_pool"`
PublicIPv6Prefixes []PublicIPv6Prefix `json:"public_ipv6_prefixes"`
WebSSHAllowedOrigins []string `json:"webssh_allowed_origins"`
PanelAccessPolicy PanelAccessPolicy `json:"panel_access_policy"`
SecurityAutoShutdown bool `json:"security_auto_shutdown"`
TaskConcurrency int `json:"task_concurrency"`
Language string `json:"language"`
@@ -813,6 +818,39 @@ type ClicdConfig struct {
StoragePools []StoragePool `json:"storage_pools"`
}
const (
KVMProvisionerLinuxCloudInit = "linux-cloud-init"
KVMProvisionerWindows10 = "windows-10"
KVMProvisionerWindows11 = "windows-11"
)
// CustomKVMImage is an administrator-defined KVM image source.
type CustomKVMImage struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Distro string `json:"distro"`
Release string `json:"release"`
Arch string `json:"arch"`
URL string `json:"url"`
Provisioner string `json:"provisioner"`
SHA256 string `json:"sha256,omitempty"`
CreatedAt string `json:"created_at"`
}
// CustomLXCImage is an administrator-defined LXC rootfs archive source.
type CustomLXCImage struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Distro string `json:"distro"`
Release string `json:"release"`
Arch string `json:"arch"`
URL string `json:"url"`
SHA256 string `json:"sha256,omitempty"`
CreatedAt string `json:"created_at"`
}
var configPath string
var AppConfig *ClicdConfig
var allocationMu sync.Mutex
@@ -946,6 +984,8 @@ func InitConfig() (*ClicdConfig, error) {
NextSSHPort: 22000,
NATPortStart: DefaultNATPortStart,
NATPortEnd: DefaultNATPortEnd,
LXCNATSubnet: configuredSubnetValue("", "CLICD_LXC_SUBNET", DefaultLXCNATSubnet),
KVMNATSubnet: configuredSubnetValue("", "CLICD_KVM_SUBNET", DefaultKVMNATSubnet),
SetupComplete: false,
SubUsers: []SubUser{},
AuditLogs: []AuditLog{},
@@ -955,8 +995,12 @@ func InitConfig() (*ClicdConfig, error) {
PublicIPv4Pool: []PublicIPv4Assignment{},
PublicIPv6Prefixes: []PublicIPv6Prefix{},
WebSSHAllowedOrigins: []string{},
TaskConcurrency: DefaultTaskConcurrency,
StoragePools: []StoragePool{defaultPrimaryStoragePool()},
PanelAccessPolicy: PanelAccessPolicy{
AllowedSources: []string{},
TrustedProxies: []string{},
},
TaskConcurrency: DefaultTaskConcurrency,
StoragePools: []StoragePool{defaultPrimaryStoragePool()},
}
if err := SaveConfig(); err != nil {
@@ -994,6 +1038,9 @@ func normalizeConfigDefaults(dataDir string) bool {
if normalizeNATPortRangeDefaults() {
changed = true
}
if normalizeNATNetworkDefaults() {
changed = true
}
if AppConfig.NextContainerID == 0 {
AppConfig.NextContainerID = 1
changed = true
@@ -1029,6 +1076,18 @@ func normalizeConfigDefaults(dataDir string) bool {
AppConfig.WebSSHAllowedOrigins = normalized
changed = true
}
if normalized, err := NormalizePanelAccessPolicy(AppConfig.PanelAccessPolicy); err == nil {
if !panelAccessPoliciesEqual(AppConfig.PanelAccessPolicy, normalized) {
AppConfig.PanelAccessPolicy = normalized
changed = true
}
} else {
AppConfig.PanelAccessPolicy = PanelAccessPolicy{
AllowedSources: []string{},
TrustedProxies: []string{},
}
changed = true
}
if len(AppConfig.StoragePools) == 0 {
AppConfig.StoragePools = []StoragePool{defaultPrimaryStoragePool()}
changed = true
@@ -1067,6 +1126,14 @@ func normalizeConfigDefaults(dataDir string) bool {
AppConfig.EnabledImages = make([]string, 0)
changed = true
}
if AppConfig.CustomKVMImages == nil {
AppConfig.CustomKVMImages = make([]CustomKVMImage, 0)
changed = true
}
if AppConfig.CustomLXCImages == nil {
AppConfig.CustomLXCImages = make([]CustomLXCImage, 0)
changed = true
}
if AppConfig.Language == "" {
AppConfig.Language = "zh"
changed = true
@@ -1435,6 +1502,104 @@ func SaveConfig() error {
return saveConfigToDB()
}
func ListCustomKVMImages() []CustomKVMImage {
allocationMu.Lock()
defer allocationMu.Unlock()
if AppConfig == nil {
return nil
}
return append([]CustomKVMImage(nil), AppConfig.CustomKVMImages...)
}
func AddCustomKVMImage(image CustomKVMImage) error {
allocationMu.Lock()
defer allocationMu.Unlock()
for _, existing := range AppConfig.CustomKVMImages {
if existing.ID == image.ID {
return fmt.Errorf("custom KVM image %q already exists", image.ID)
}
}
AppConfig.CustomKVMImages = append(AppConfig.CustomKVMImages, image)
if err := SaveConfig(); err != nil {
AppConfig.CustomKVMImages = AppConfig.CustomKVMImages[:len(AppConfig.CustomKVMImages)-1]
return err
}
return nil
}
func RemoveCustomKVMImage(id string) (bool, error) {
allocationMu.Lock()
defer allocationMu.Unlock()
filtered := make([]CustomKVMImage, 0, len(AppConfig.CustomKVMImages))
found := false
for _, image := range AppConfig.CustomKVMImages {
if image.ID == id {
found = true
continue
}
filtered = append(filtered, image)
}
if !found {
return false, nil
}
previous := AppConfig.CustomKVMImages
AppConfig.CustomKVMImages = filtered
if err := SaveConfig(); err != nil {
AppConfig.CustomKVMImages = previous
return false, err
}
return true, nil
}
func ListCustomLXCImages() []CustomLXCImage {
allocationMu.Lock()
defer allocationMu.Unlock()
if AppConfig == nil {
return nil
}
return append([]CustomLXCImage(nil), AppConfig.CustomLXCImages...)
}
func AddCustomLXCImage(image CustomLXCImage) error {
allocationMu.Lock()
defer allocationMu.Unlock()
for _, existing := range AppConfig.CustomLXCImages {
if existing.ID == image.ID {
return fmt.Errorf("custom LXC image %q already exists", image.ID)
}
}
AppConfig.CustomLXCImages = append(AppConfig.CustomLXCImages, image)
if err := SaveConfig(); err != nil {
AppConfig.CustomLXCImages = AppConfig.CustomLXCImages[:len(AppConfig.CustomLXCImages)-1]
return err
}
return nil
}
func RemoveCustomLXCImage(id string) (bool, error) {
allocationMu.Lock()
defer allocationMu.Unlock()
filtered := make([]CustomLXCImage, 0, len(AppConfig.CustomLXCImages))
found := false
for _, image := range AppConfig.CustomLXCImages {
if image.ID == id {
found = true
continue
}
filtered = append(filtered, image)
}
if !found {
return false, nil
}
previous := AppConfig.CustomLXCImages
AppConfig.CustomLXCImages = filtered
if err := SaveConfig(); err != nil {
AppConfig.CustomLXCImages = previous
return false, err
}
return true, nil
}
// AddContainer adds a container to the config
func AddContainer(c Container) {
allocationMu.Lock()
@@ -1736,6 +1901,28 @@ func AllocateSSHPort() (int, error) {
func AllocateSSHPortExcluding(excluded []int) (int, error) {
allocationMu.Lock()
defer allocationMu.Unlock()
candidate, err := previewSSHPortExcluding(excluded)
if err != nil {
return 0, err
}
start, end := NATPortRange()
AppConfig.NextSSHPort = candidate + 1
if AppConfig.NextSSHPort > end {
AppConfig.NextSSHPort = start
}
SaveConfig()
return candidate, nil
}
// PreviewSSHPortExcluding returns the management port that the allocator would
// choose without advancing or persisting the allocation cursor.
func PreviewSSHPortExcluding(excluded []int) (int, error) {
allocationMu.Lock()
defer allocationMu.Unlock()
return previewSSHPortExcluding(excluded)
}
func previewSSHPortExcluding(excluded []int) (int, error) {
used := collectAllHostPorts()
for _, port := range excluded {
if port > 0 {
@@ -1753,11 +1940,6 @@ func AllocateSSHPortExcluding(excluded []int) (int, error) {
if used[candidate] {
continue
}
AppConfig.NextSSHPort = candidate + 1
if AppConfig.NextSSHPort > end {
AppConfig.NextSSHPort = start
}
SaveConfig()
return candidate, nil
}
return 0, fmt.Errorf("no free NAT4 host port in configured range %d-%d", start, end)
+144
View File
@@ -0,0 +1,144 @@
package config
import (
"encoding/binary"
"fmt"
"net/netip"
"os"
"strings"
)
const (
DefaultLXCNATSubnet = "10.0.3.0/24"
DefaultKVMNATSubnet = "192.168.122.0/24"
)
type NATNetwork struct {
Subnet string `json:"subnet"`
Gateway string `json:"gateway"`
Netmask string `json:"netmask"`
DHCPStart string `json:"dhcp_start"`
DHCPEnd string `json:"dhcp_end"`
DHCPMax int `json:"dhcp_max"`
PrefixBits int `json:"prefix_bits"`
}
func ParseNATNetwork(raw string) (NATNetwork, error) {
prefix, err := netip.ParsePrefix(strings.TrimSpace(raw))
if err != nil || !prefix.Addr().Is4() {
return NATNetwork{}, fmt.Errorf("NAT subnet must be a valid IPv4 CIDR")
}
prefix = prefix.Masked()
if prefix.Bits() < 16 || prefix.Bits() > 28 {
return NATNetwork{}, fmt.Errorf("NAT subnet prefix must be between /16 and /28")
}
if !isRFC1918Prefix(prefix) {
return NATNetwork{}, fmt.Errorf("NAT subnet must use an RFC1918 private IPv4 range")
}
network := ipv4Uint32(prefix.Addr())
hostBits := 32 - prefix.Bits()
broadcast := network | uint32((uint64(1)<<hostBits)-1)
gateway := uint32IPv4(network + 1)
dhcpStart := uint32IPv4(network + 2)
dhcpEnd := uint32IPv4(broadcast - 1)
return NATNetwork{
Subnet: prefix.String(),
Gateway: gateway.String(),
Netmask: netmaskString(prefix.Bits()),
DHCPStart: dhcpStart.String(),
DHCPEnd: dhcpEnd.String(),
DHCPMax: int(broadcast - network - 2),
PrefixBits: prefix.Bits(),
}, nil
}
func LXCNATNetwork() NATNetwork {
return configuredNATNetwork(false)
}
func KVMNATNetwork() NATNetwork {
return configuredNATNetwork(true)
}
func normalizeNATNetworkDefaults() bool {
changed := false
lxcSubnet := configuredSubnetValue(AppConfig.LXCNATSubnet, "CLICD_LXC_SUBNET", DefaultLXCNATSubnet)
kvmSubnet := configuredSubnetValue(AppConfig.KVMNATSubnet, "CLICD_KVM_SUBNET", DefaultKVMNATSubnet)
if AppConfig.LXCNATSubnet != lxcSubnet {
AppConfig.LXCNATSubnet = lxcSubnet
changed = true
}
if AppConfig.KVMNATSubnet != kvmSubnet {
AppConfig.KVMNATSubnet = kvmSubnet
changed = true
}
return changed
}
func configuredNATNetwork(kvm bool) NATNetwork {
raw := DefaultLXCNATSubnet
if kvm {
raw = DefaultKVMNATSubnet
}
if AppConfig != nil {
if kvm && AppConfig.KVMNATSubnet != "" {
raw = AppConfig.KVMNATSubnet
}
if !kvm && AppConfig.LXCNATSubnet != "" {
raw = AppConfig.LXCNATSubnet
}
}
network, err := ParseNATNetwork(raw)
if err == nil {
return network
}
network, _ = ParseNATNetwork(map[bool]string{false: DefaultLXCNATSubnet, true: DefaultKVMNATSubnet}[kvm])
return network
}
func configuredSubnetValue(current, envName, fallback string) string {
raw := strings.TrimSpace(current)
if envValue := strings.TrimSpace(os.Getenv(envName)); envValue != "" {
raw = envValue
}
if network, err := ParseNATNetwork(raw); err == nil {
return network.Subnet
}
network, _ := ParseNATNetwork(fallback)
return network.Subnet
}
func isRFC1918Prefix(prefix netip.Prefix) bool {
privateRanges := []netip.Prefix{
netip.MustParsePrefix("10.0.0.0/8"),
netip.MustParsePrefix("172.16.0.0/12"),
netip.MustParsePrefix("192.168.0.0/16"),
}
for _, privateRange := range privateRanges {
if privateRange.Contains(prefix.Addr()) {
last := uint32IPv4(ipv4Uint32(prefix.Addr()) | uint32((uint64(1)<<(32-prefix.Bits()))-1))
return privateRange.Contains(last)
}
}
return false
}
func ipv4Uint32(addr netip.Addr) uint32 {
bytes := addr.As4()
return binary.BigEndian.Uint32(bytes[:])
}
func uint32IPv4(value uint32) netip.Addr {
var bytes [4]byte
binary.BigEndian.PutUint32(bytes[:], value)
return netip.AddrFrom4(bytes)
}
func netmaskString(bits int) string {
mask := uint32(0)
if bits > 0 {
mask = ^uint32(0) << (32 - bits)
}
return uint32IPv4(mask).String()
}
@@ -0,0 +1,56 @@
package config
import "testing"
func TestParseNATNetwork(t *testing.T) {
network, err := ParseNATNetwork("172.28.40.0/24")
if err != nil {
t.Fatalf("ParseNATNetwork returned error: %v", err)
}
if network.Subnet != "172.28.40.0/24" ||
network.Gateway != "172.28.40.1" ||
network.Netmask != "255.255.255.0" ||
network.DHCPStart != "172.28.40.2" ||
network.DHCPEnd != "172.28.40.254" ||
network.DHCPMax != 253 {
t.Fatalf("unexpected network values: %+v", network)
}
}
func TestParseNATNetworkMasksHostBits(t *testing.T) {
network, err := ParseNATNetwork("10.44.8.99/20")
if err != nil {
t.Fatalf("ParseNATNetwork returned error: %v", err)
}
if network.Subnet != "10.44.0.0/20" || network.Gateway != "10.44.0.1" || network.DHCPEnd != "10.44.15.254" {
t.Fatalf("unexpected masked network values: %+v", network)
}
}
func TestParseNATNetworkRejectsUnsafeRanges(t *testing.T) {
for _, raw := range []string{
"203.0.113.0/24",
"10.0.0.0/15",
"10.0.0.0/29",
"not-a-subnet",
} {
if _, err := ParseNATNetwork(raw); err == nil {
t.Fatalf("ParseNATNetwork(%q) unexpectedly succeeded", raw)
}
}
}
func TestNormalizeNATNetworkDefaultsUsesEnvironment(t *testing.T) {
t.Setenv("CLICD_LXC_SUBNET", "172.30.8.0/24")
t.Setenv("CLICD_KVM_SUBNET", "10.230.0.0/20")
previous := AppConfig
AppConfig = &ClicdConfig{}
t.Cleanup(func() { AppConfig = previous })
if !normalizeNATNetworkDefaults() {
t.Fatal("expected defaults to change")
}
if AppConfig.LXCNATSubnet != "172.30.8.0/24" || AppConfig.KVMNATSubnet != "10.230.0.0/20" {
t.Fatalf("unexpected configured subnets: LXC=%s KVM=%s", AppConfig.LXCNATSubnet, AppConfig.KVMNATSubnet)
}
}
+24
View File
@@ -62,3 +62,27 @@ func TestAllocateSSHPortExcludingRequestedMappings(t *testing.T) {
t.Fatalf("allocated port = %d, want 32002", port)
}
}
func TestPreviewSSHPortUsesRangeWithoutAdvancingCursor(t *testing.T) {
previous := AppConfig
t.Cleanup(func() { AppConfig = previous })
AppConfig = &ClicdConfig{
NATPortStart: 30000,
NATPortEnd: 35000,
NextSSHPort: 30000,
Containers: []Container{{
PortMappings: []PortMapping{{HostPort: 30000}},
}},
}
port, err := PreviewSSHPortExcluding([]int{30001})
if err != nil {
t.Fatal(err)
}
if port != 30002 {
t.Fatalf("preview port = %d, want 30002", port)
}
if AppConfig.NextSSHPort != 30000 {
t.Fatalf("preview advanced cursor to %d", AppConfig.NextSSHPort)
}
}
+198
View File
@@ -0,0 +1,198 @@
package config
import (
"fmt"
"net"
"net/netip"
"strings"
)
// PanelAccessPolicy limits access to the complete web panel and API surface.
type PanelAccessPolicy struct {
Enabled bool `json:"enabled"`
AllowedSources []string `json:"allowed_sources"`
TrustedProxies []string `json:"trusted_proxies"`
}
// ForwardedClientHeaders contains proxy-provided client address headers.
type ForwardedClientHeaders struct {
ForwardedFor string
RealIP string
CFConnectingIP string
}
// PanelAccessDecision describes the address used by the access policy.
type PanelAccessDecision struct {
Allowed bool
DirectSource string
CurrentSource string
UsedForwarded bool
}
func NormalizePanelAccessPolicy(policy PanelAccessPolicy) (PanelAccessPolicy, error) {
allowed, err := normalizeIPRanges(policy.AllowedSources, "allowed source")
if err != nil {
return PanelAccessPolicy{}, err
}
trusted, err := normalizeIPRanges(policy.TrustedProxies, "trusted proxy")
if err != nil {
return PanelAccessPolicy{}, err
}
if policy.Enabled && len(allowed) == 0 {
return PanelAccessPolicy{}, fmt.Errorf("at least one allowed IP address or CIDR is required")
}
return PanelAccessPolicy{
Enabled: policy.Enabled,
AllowedSources: allowed,
TrustedProxies: trusted,
}, nil
}
func normalizeIPRanges(values []string, label string) ([]string, error) {
result := make([]string, 0, len(values))
seen := make(map[string]struct{}, len(values))
for _, raw := range values {
value := strings.TrimSpace(raw)
if value == "" {
continue
}
normalized, err := normalizeIPRange(value)
if err != nil {
return nil, fmt.Errorf("invalid %s %q: %w", label, value, err)
}
if _, exists := seen[normalized]; exists {
continue
}
seen[normalized] = struct{}{}
result = append(result, normalized)
}
return result, nil
}
func normalizeIPRange(value string) (string, error) {
if strings.Contains(value, "/") {
prefix, err := netip.ParsePrefix(value)
if err != nil {
return "", err
}
if prefix.Addr().Zone() != "" {
return "", fmt.Errorf("IPv6 zones are not supported")
}
return prefix.Masked().String(), nil
}
addr, err := netip.ParseAddr(value)
if err != nil {
return "", err
}
if addr.Zone() != "" {
return "", fmt.Errorf("IPv6 zones are not supported")
}
return addr.Unmap().String(), nil
}
func panelAccessPoliciesEqual(a, b PanelAccessPolicy) bool {
return a.Enabled == b.Enabled &&
stringSlicesEqual(a.AllowedSources, b.AllowedSources) &&
stringSlicesEqual(a.TrustedProxies, b.TrustedProxies)
}
func stringSlicesEqual(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
// EvaluatePanelAccess resolves the effective client address and applies policy.
// Forwarded headers are only considered when the TCP peer is trusted.
func EvaluatePanelAccess(policy PanelAccessPolicy, remoteAddr string, headers ForwardedClientHeaders) PanelAccessDecision {
direct, ok := parseRemoteIP(remoteAddr)
decision := PanelAccessDecision{}
if ok {
decision.DirectSource = direct.String()
decision.CurrentSource = direct.String()
}
if !policy.Enabled {
decision.Allowed = true
return decision
}
if !ok {
return decision
}
current := direct
if ipInRanges(direct, policy.TrustedProxies) {
if forwarded, forwardedOK := resolveForwardedIP(direct, policy.TrustedProxies, headers); forwardedOK {
current = forwarded
decision.CurrentSource = forwarded.String()
decision.UsedForwarded = true
}
}
// A direct local connection remains an emergency recovery path. When a
// trusted local reverse proxy forwards a client address, that client is
// still checked normally.
if current.IsLoopback() && !decision.UsedForwarded {
decision.Allowed = true
return decision
}
decision.Allowed = ipInRanges(current, policy.AllowedSources)
return decision
}
func parseRemoteIP(value string) (netip.Addr, bool) {
value = strings.TrimSpace(value)
if host, _, err := net.SplitHostPort(value); err == nil {
value = host
}
value = strings.TrimPrefix(strings.TrimSuffix(value, "]"), "[")
addr, err := netip.ParseAddr(value)
if err != nil {
return netip.Addr{}, false
}
return addr.Unmap(), true
}
func resolveForwardedIP(direct netip.Addr, trusted []string, headers ForwardedClientHeaders) (netip.Addr, bool) {
for _, raw := range []string{headers.CFConnectingIP, headers.RealIP} {
if addr, ok := parseRemoteIP(strings.TrimSpace(strings.Split(raw, ",")[0])); ok {
return addr, true
}
}
parts := strings.Split(headers.ForwardedFor, ",")
current := direct
found := false
for i := len(parts) - 1; i >= 0 && ipInRanges(current, trusted); i-- {
addr, ok := parseRemoteIP(strings.TrimSpace(parts[i]))
if !ok {
continue
}
current = addr
found = true
}
return current, found
}
func ipInRanges(addr netip.Addr, ranges []string) bool {
addr = addr.Unmap()
for _, raw := range ranges {
if strings.Contains(raw, "/") {
prefix, err := netip.ParsePrefix(raw)
if err == nil && prefix.Contains(addr) {
return true
}
continue
}
candidate, err := netip.ParseAddr(raw)
if err == nil && candidate.Unmap() == addr {
return true
}
}
return false
}
@@ -0,0 +1,146 @@
package config
import (
"reflect"
"testing"
)
func TestNormalizePanelAccessPolicy(t *testing.T) {
policy, err := NormalizePanelAccessPolicy(PanelAccessPolicy{
Enabled: true,
AllowedSources: []string{" 192.0.2.8 ", "10.20.30.44/24", "192.0.2.8", "2001:db8::1"},
TrustedProxies: []string{"127.0.0.1", "2001:db8:1::/64"},
})
if err != nil {
t.Fatalf("NormalizePanelAccessPolicy() error = %v", err)
}
if want := []string{"192.0.2.8", "10.20.30.0/24", "2001:db8::1"}; !reflect.DeepEqual(policy.AllowedSources, want) {
t.Fatalf("AllowedSources = %#v, want %#v", policy.AllowedSources, want)
}
if want := []string{"127.0.0.1", "2001:db8:1::/64"}; !reflect.DeepEqual(policy.TrustedProxies, want) {
t.Fatalf("TrustedProxies = %#v, want %#v", policy.TrustedProxies, want)
}
}
func TestNormalizePanelAccessPolicyRejectsEmptyEnabledPolicy(t *testing.T) {
if _, err := NormalizePanelAccessPolicy(PanelAccessPolicy{Enabled: true}); err == nil {
t.Fatal("expected enabled empty policy to fail")
}
}
func TestEvaluatePanelAccess(t *testing.T) {
base := PanelAccessPolicy{
Enabled: true,
AllowedSources: []string{"192.0.2.0/24", "2001:db8::/32"},
TrustedProxies: []string{"10.0.0.1", "127.0.0.1"},
}
tests := []struct {
name string
policy PanelAccessPolicy
remote string
headers ForwardedClientHeaders
allowed bool
current string
usedForwarded bool
}{
{
name: "disabled",
policy: PanelAccessPolicy{},
remote: "198.51.100.9:44321",
allowed: true,
current: "198.51.100.9",
},
{
name: "direct CIDR match",
policy: base,
remote: "192.0.2.25:44321",
allowed: true,
current: "192.0.2.25",
},
{
name: "direct denied",
policy: base,
remote: "198.51.100.9:44321",
allowed: false,
current: "198.51.100.9",
},
{
name: "spoofed forwarding header ignored",
policy: base,
remote: "198.51.100.9:44321",
headers: ForwardedClientHeaders{
ForwardedFor: "192.0.2.10",
},
allowed: false,
current: "198.51.100.9",
},
{
name: "trusted proxy forwards allowed source",
policy: base,
remote: "10.0.0.1:44321",
headers: ForwardedClientHeaders{
ForwardedFor: "192.0.2.10",
},
allowed: true,
current: "192.0.2.10",
usedForwarded: true,
},
{
name: "trusted proxy forwards denied source",
policy: base,
remote: "10.0.0.1:44321",
headers: ForwardedClientHeaders{
RealIP: "198.51.100.20",
},
allowed: false,
current: "198.51.100.20",
usedForwarded: true,
},
{
name: "direct loopback recovery",
policy: base,
remote: "127.0.0.1:44321",
allowed: true,
current: "127.0.0.1",
usedForwarded: false,
},
{
name: "trusted loopback proxy is enforced",
policy: base,
remote: "127.0.0.1:44321",
headers: ForwardedClientHeaders{
ForwardedFor: "198.51.100.20",
},
allowed: false,
current: "198.51.100.20",
usedForwarded: true,
},
{
name: "IPv6 source",
policy: base,
remote: "[2001:db8::88]:44321",
allowed: true,
current: "2001:db8::88",
},
{
name: "trusted proxy chain",
policy: base,
remote: "10.0.0.1:44321",
headers: ForwardedClientHeaders{
ForwardedFor: "192.0.2.70, 10.0.0.1",
},
allowed: true,
current: "192.0.2.70",
usedForwarded: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := EvaluatePanelAccess(tt.policy, tt.remote, tt.headers)
if got.Allowed != tt.allowed || got.CurrentSource != tt.current || got.UsedForwarded != tt.usedForwarded {
t.Fatalf("EvaluatePanelAccess() = %#v", got)
}
})
}
}
+19
View File
@@ -606,6 +606,8 @@ func loadConfigFromDB() (*ClicdConfig, bool, error) {
NextSSHPort: atoi(meta["next_ssh_port"]),
NATPortStart: atoi(meta["nat_port_start"]),
NATPortEnd: atoi(meta["nat_port_end"]),
LXCNATSubnet: meta["lxc_nat_subnet"],
KVMNATSubnet: meta["kvm_nat_subnet"],
SetupComplete: atob(meta["setup_complete"]),
SecurityAutoShutdown: atob(meta["security_auto_shutdown"]),
TaskConcurrency: atoi(meta["task_concurrency"]),
@@ -626,9 +628,18 @@ func loadConfigFromDB() (*ClicdConfig, bool, error) {
if raw := strings.TrimSpace(meta["webssh_allowed_origins"]); raw != "" {
_ = json.Unmarshal([]byte(raw), &cfg.WebSSHAllowedOrigins)
}
if raw := strings.TrimSpace(meta["panel_access_policy"]); raw != "" {
_ = json.Unmarshal([]byte(raw), &cfg.PanelAccessPolicy)
}
if raw := strings.TrimSpace(meta["storage_pools"]); raw != "" {
_ = json.Unmarshal([]byte(raw), &cfg.StoragePools)
}
if raw := strings.TrimSpace(meta["custom_kvm_images"]); raw != "" {
_ = json.Unmarshal([]byte(raw), &cfg.CustomKVMImages)
}
if raw := strings.TrimSpace(meta["custom_lxc_images"]); raw != "" {
_ = json.Unmarshal([]byte(raw), &cfg.CustomLXCImages)
}
if cfg.Containers, err = loadContainers(); err != nil {
return nil, false, err
@@ -729,7 +740,10 @@ func saveMeta(tx *sql.Tx) error {
publicIPv4PoolJSON, _ := json.Marshal(AppConfig.PublicIPv4Pool)
publicIPv6PrefixesJSON, _ := json.Marshal(AppConfig.PublicIPv6Prefixes)
webSSHAllowedOriginsJSON, _ := json.Marshal(AppConfig.WebSSHAllowedOrigins)
panelAccessPolicyJSON, _ := json.Marshal(AppConfig.PanelAccessPolicy)
storagePoolsJSON, _ := json.Marshal(AppConfig.StoragePools)
customKVMImagesJSON, _ := json.Marshal(AppConfig.CustomKVMImages)
customLXCImagesJSON, _ := json.Marshal(AppConfig.CustomLXCImages)
values := map[string]string{
"admin_user": AppConfig.AdminUser,
"admin_pass_hash": AppConfig.AdminPassHash,
@@ -741,6 +755,8 @@ func saveMeta(tx *sql.Tx) error {
"next_ssh_port": strconv.Itoa(AppConfig.NextSSHPort),
"nat_port_start": strconv.Itoa(AppConfig.NATPortStart),
"nat_port_end": strconv.Itoa(AppConfig.NATPortEnd),
"lxc_nat_subnet": AppConfig.LXCNATSubnet,
"kvm_nat_subnet": AppConfig.KVMNATSubnet,
"setup_complete": btoa(AppConfig.SetupComplete),
"security_auto_shutdown": btoa(AppConfig.SecurityAutoShutdown),
"task_concurrency": strconv.Itoa(AppConfig.TaskConcurrency),
@@ -750,7 +766,10 @@ func saveMeta(tx *sql.Tx) error {
"public_ipv4_pool": string(publicIPv4PoolJSON),
"public_ipv6_prefixes": string(publicIPv6PrefixesJSON),
"webssh_allowed_origins": string(webSSHAllowedOriginsJSON),
"panel_access_policy": string(panelAccessPolicyJSON),
"storage_pools": string(storagePoolsJSON),
"custom_kvm_images": string(customKVMImagesJSON),
"custom_lxc_images": string(customLXCImagesJSON),
"schema_version": "1",
"updated_at": time.Now().Format("2006-01-02 15:04:05"),
}
@@ -66,6 +66,34 @@ func TestSQLiteConfigMigratesLegacyJSONAndPersists(t *testing.T) {
Config: `{"name":"ct2","template_id":"debian-12","vcpu":1,"ram_mb":512,"disk_gb":5,"extra_ports":[80,443],"nat_port_mappings":[{"host_port":30080,"container_port":80,"protocol":"tcp","description":"HTTP"}],"management_port":30022,"assign_ipv6":true}`,
}},
EnabledImages: []string{"debian-12"},
CustomKVMImages: []CustomKVMImage{{
ID: "custom-kvm-test",
Name: "Test Cloud Image",
Description: "third-party image",
Distro: "ubuntu",
Release: "noble",
Arch: "amd64",
URL: "https://images.example.test/ubuntu.qcow2",
Provisioner: KVMProvisionerLinuxCloudInit,
SHA256: strings.Repeat("a", 64),
CreatedAt: "2026-07-26 10:00:00",
}},
CustomLXCImages: []CustomLXCImage{{
ID: "custom-lxc-test",
Name: "Test Rootfs",
Description: "third-party LXC image",
Distro: "alpine",
Release: "3.21",
Arch: "amd64",
URL: "https://images.example.test/alpine-rootfs.tar.xz",
SHA256: strings.Repeat("b", 64),
CreatedAt: "2026-07-26 10:00:00",
}},
PanelAccessPolicy: PanelAccessPolicy{
Enabled: true,
AllowedSources: []string{"192.0.2.0/24"},
TrustedProxies: []string{"127.0.0.1"},
},
Snapshots: []Snapshot{{
ID: "snap-1",
ContainerID: 1,
@@ -102,6 +130,15 @@ func TestSQLiteConfigMigratesLegacyJSONAndPersists(t *testing.T) {
if cfg.TaskConcurrency != DefaultTaskConcurrency {
t.Fatalf("legacy task concurrency = %d, want default %d", cfg.TaskConcurrency, DefaultTaskConcurrency)
}
if !cfg.PanelAccessPolicy.Enabled || len(cfg.PanelAccessPolicy.AllowedSources) != 1 {
t.Fatalf("legacy panel access policy was not migrated: %+v", cfg.PanelAccessPolicy)
}
if len(cfg.CustomKVMImages) != 1 || cfg.CustomKVMImages[0].ID != "custom-kvm-test" {
t.Fatalf("legacy custom KVM images were not migrated: %+v", cfg.CustomKVMImages)
}
if len(cfg.CustomLXCImages) != 1 || cfg.CustomLXCImages[0].ID != "custom-lxc-test" {
t.Fatalf("legacy custom LXC images were not migrated: %+v", cfg.CustomLXCImages)
}
if _, err := os.Stat(filepath.Join(dir, "config.db")); err != nil {
t.Fatalf("sqlite database was not created: %v", err)
}
@@ -124,6 +161,15 @@ func TestSQLiteConfigMigratesLegacyJSONAndPersists(t *testing.T) {
if got := cfg.TaskConcurrency; got != 6 {
t.Fatalf("persisted task concurrency = %d, want 6", got)
}
if !cfg.PanelAccessPolicy.Enabled || cfg.PanelAccessPolicy.AllowedSources[0] != "192.0.2.0/24" {
t.Fatalf("persisted panel access policy = %+v", cfg.PanelAccessPolicy)
}
if len(cfg.CustomKVMImages) != 1 || cfg.CustomKVMImages[0].SHA256 != strings.Repeat("a", 64) {
t.Fatalf("persisted custom KVM images = %+v", cfg.CustomKVMImages)
}
if len(cfg.CustomLXCImages) != 1 || cfg.CustomLXCImages[0].SHA256 != strings.Repeat("b", 64) {
t.Fatalf("persisted custom LXC images = %+v", cfg.CustomLXCImages)
}
}
func resetConfigStoreForTest(t *testing.T) {