mirror of
https://github.com/MengMengCode/CLICD.git
synced 2026-08-08 06:24:44 +08:00
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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user