mirror of
https://github.com/MengMengCode/CLICD.git
synced 2026-08-05 05:36:07 +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:
@@ -0,0 +1,94 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"clicd/internal/config"
|
||||
)
|
||||
|
||||
type panelAccessPolicyResponse struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
AllowedSources []string `json:"allowed_sources"`
|
||||
TrustedProxies []string `json:"trusted_proxies"`
|
||||
CurrentSource string `json:"current_source"`
|
||||
DirectSource string `json:"direct_source"`
|
||||
UsingForwarded bool `json:"using_forwarded"`
|
||||
}
|
||||
|
||||
func HandlePanelAccessPolicy(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: panelAccessPolicyStatus(r, config.AppConfig.PanelAccessPolicy)})
|
||||
case http.MethodPut:
|
||||
updatePanelAccessPolicy(w, r)
|
||||
default:
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
}
|
||||
}
|
||||
|
||||
func updatePanelAccessPolicy(w http.ResponseWriter, r *http.Request) {
|
||||
var requested config.PanelAccessPolicy
|
||||
if err := json.NewDecoder(r.Body).Decode(&requested); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
|
||||
return
|
||||
}
|
||||
normalized, err := config.NormalizePanelAccessPolicy(requested)
|
||||
if err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
decision := evaluatePanelRequest(r, normalized)
|
||||
if normalized.Enabled && !decision.Allowed {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{
|
||||
Success: false,
|
||||
Message: "The new access policy does not allow your current source address " + decision.CurrentSource,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
previous := config.AppConfig.PanelAccessPolicy
|
||||
config.AppConfig.PanelAccessPolicy = normalized
|
||||
if err := config.SaveConfig(); err != nil {
|
||||
config.AppConfig.PanelAccessPolicy = previous
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to save panel access policy"})
|
||||
return
|
||||
}
|
||||
detail := "enabled=" + strings.ToLower(strings.TrimSpace(boolText(normalized.Enabled))) +
|
||||
",allowed=" + strings.Join(normalized.AllowedSources, ",") +
|
||||
",trusted_proxies=" + strings.Join(normalized.TrustedProxies, ",")
|
||||
auditRequest(r, "settings.panel_access", "Panel access policy", detail, true, "")
|
||||
jsonResponse(w, http.StatusOK, APIResponse{
|
||||
Success: true,
|
||||
Message: "Panel access policy saved",
|
||||
Data: panelAccessPolicyStatus(r, normalized),
|
||||
})
|
||||
}
|
||||
|
||||
func panelAccessPolicyStatus(r *http.Request, policy config.PanelAccessPolicy) panelAccessPolicyResponse {
|
||||
decision := evaluatePanelRequest(r, policy)
|
||||
return panelAccessPolicyResponse{
|
||||
Enabled: policy.Enabled,
|
||||
AllowedSources: append([]string(nil), policy.AllowedSources...),
|
||||
TrustedProxies: append([]string(nil), policy.TrustedProxies...),
|
||||
CurrentSource: decision.CurrentSource,
|
||||
DirectSource: decision.DirectSource,
|
||||
UsingForwarded: decision.UsedForwarded,
|
||||
}
|
||||
}
|
||||
|
||||
func evaluatePanelRequest(r *http.Request, policy config.PanelAccessPolicy) config.PanelAccessDecision {
|
||||
return config.EvaluatePanelAccess(policy, r.RemoteAddr, config.ForwardedClientHeaders{
|
||||
ForwardedFor: r.Header.Get("X-Forwarded-For"),
|
||||
RealIP: r.Header.Get("X-Real-IP"),
|
||||
CFConnectingIP: r.Header.Get("CF-Connecting-IP"),
|
||||
})
|
||||
}
|
||||
|
||||
func boolText(value bool) string {
|
||||
if value {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
}
|
||||
@@ -2,12 +2,16 @@ package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -38,8 +42,14 @@ type ImageInfo struct {
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
ManualPath string `json:"manual_path,omitempty"`
|
||||
Desktop string `json:"desktop,omitempty"`
|
||||
Provisioner string `json:"provisioner,omitempty"`
|
||||
Custom bool `json:"custom,omitempty"`
|
||||
SHA256 string `json:"sha256,omitempty"`
|
||||
}
|
||||
|
||||
var customImageFieldPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,63}$`)
|
||||
var sha256Pattern = regexp.MustCompile(`^[a-fA-F0-9]{64}$`)
|
||||
|
||||
var imageDownloadsMu sync.Mutex
|
||||
var imageDownloads = map[string]*imageDownloadStatus{}
|
||||
var lxcImageCacheMu sync.Mutex
|
||||
@@ -217,6 +227,13 @@ func imageDownloadedInfo(distro, release, arch string) (bool, int64) {
|
||||
return false, 0
|
||||
}
|
||||
|
||||
func lxcTemplateDownloadedInfo(template lxc.Template) (bool, int64) {
|
||||
if template.Custom {
|
||||
return lxc.CustomImageDownloadedInfo(template.ID)
|
||||
}
|
||||
return imageDownloadedInfo(template.Distro, template.Release, template.Arch)
|
||||
}
|
||||
|
||||
// getEnabledImageSet returns the set of enabled image IDs.
|
||||
// If none have been explicitly set, all templates are enabled by default.
|
||||
func getEnabledImageSet() map[string]bool {
|
||||
@@ -258,7 +275,7 @@ func HandleImages(w http.ResponseWriter, r *http.Request) {
|
||||
images := make([]ImageInfo, 0, len(templates)+len(kvmImages))
|
||||
for _, t := range templates {
|
||||
dl := imageDownloadInfo(t.ID)
|
||||
downloaded, size := imageDownloadedInfo(t.Distro, t.Release, t.Arch)
|
||||
downloaded, size := lxcTemplateDownloadedInfo(t)
|
||||
images = append(images, ImageInfo{
|
||||
ID: t.ID,
|
||||
Name: t.Name,
|
||||
@@ -276,13 +293,15 @@ func HandleImages(w http.ResponseWriter, r *http.Request) {
|
||||
Stage: dl.Stage,
|
||||
Error: dl.Error,
|
||||
SizeBytes: size,
|
||||
Custom: t.Custom,
|
||||
SHA256: t.SHA256,
|
||||
})
|
||||
}
|
||||
for _, t := range kvmImages {
|
||||
dl := imageDownloadInfo(t.ID)
|
||||
downloaded, size := kvm.ImageDownloadedInfo(t.ID)
|
||||
manualPath := ""
|
||||
if t.Distro == "windows" {
|
||||
if t.IsWindows() {
|
||||
manualPath = kvm.ImagePath(t.ID)
|
||||
}
|
||||
images = append(images, ImageInfo{
|
||||
@@ -304,12 +323,261 @@ func HandleImages(w http.ResponseWriter, r *http.Request) {
|
||||
SizeBytes: size,
|
||||
ManualPath: manualPath,
|
||||
Desktop: t.Desktop,
|
||||
Provisioner: t.Provisioner,
|
||||
Custom: t.Custom,
|
||||
SHA256: t.SHA256,
|
||||
})
|
||||
}
|
||||
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: images})
|
||||
}
|
||||
|
||||
// HandleCustomKVMImages creates or removes administrator-defined LXC/KVM image sources.
|
||||
func HandleCustomKVMImages(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodPost:
|
||||
if !requireScope(w, r, "image:download") {
|
||||
return
|
||||
}
|
||||
handleCustomKVMImageCreate(w, r)
|
||||
case http.MethodDelete:
|
||||
if !requireScope(w, r, "image:delete") {
|
||||
return
|
||||
}
|
||||
handleCustomKVMImageDelete(w, r)
|
||||
default:
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
}
|
||||
}
|
||||
|
||||
func handleCustomKVMImageCreate(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Type string `json:"type"`
|
||||
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"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
|
||||
return
|
||||
}
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
req.Type = strings.ToLower(strings.TrimSpace(req.Type))
|
||||
if req.Type == "" {
|
||||
req.Type = config.VirtualizationKVM
|
||||
}
|
||||
req.Description = strings.TrimSpace(req.Description)
|
||||
req.Distro = strings.ToLower(strings.TrimSpace(req.Distro))
|
||||
req.Release = strings.ToLower(strings.TrimSpace(req.Release))
|
||||
req.Arch = strings.ToLower(strings.TrimSpace(req.Arch))
|
||||
req.URL = strings.TrimSpace(req.URL)
|
||||
req.Provisioner = strings.ToLower(strings.TrimSpace(req.Provisioner))
|
||||
req.SHA256 = strings.ToLower(strings.TrimSpace(req.SHA256))
|
||||
|
||||
if req.Name == "" || len(req.Name) > 100 {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "name must be between 1 and 100 characters"})
|
||||
return
|
||||
}
|
||||
if len(req.Description) > 500 {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "description must not exceed 500 characters"})
|
||||
return
|
||||
}
|
||||
if req.Arch != runtime.GOARCH || (req.Arch != "amd64" && req.Arch != "arm64") {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "image architecture must match the host architecture"})
|
||||
return
|
||||
}
|
||||
if req.Type == config.VirtualizationLXC {
|
||||
if !customImageFieldPattern.MatchString(req.Distro) {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "distro contains unsupported characters"})
|
||||
return
|
||||
}
|
||||
if !customImageFieldPattern.MatchString(req.Release) {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "release contains unsupported characters"})
|
||||
return
|
||||
}
|
||||
} else if req.Type == config.VirtualizationKVM {
|
||||
switch req.Provisioner {
|
||||
case config.KVMProvisionerLinuxCloudInit:
|
||||
if !customImageFieldPattern.MatchString(req.Distro) {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "distro contains unsupported characters"})
|
||||
return
|
||||
}
|
||||
if !customImageFieldPattern.MatchString(req.Release) {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "release contains unsupported characters"})
|
||||
return
|
||||
}
|
||||
case config.KVMProvisionerWindows10:
|
||||
if req.Arch != "amd64" {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Windows unattended installation currently requires an amd64 host"})
|
||||
return
|
||||
}
|
||||
req.Distro = "windows"
|
||||
req.Release = "10"
|
||||
case config.KVMProvisionerWindows11:
|
||||
if req.Arch != "amd64" {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Windows unattended installation currently requires an amd64 host"})
|
||||
return
|
||||
}
|
||||
req.Distro = "windows"
|
||||
req.Release = "11"
|
||||
default:
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "unsupported unattended installation template"})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "type must be lxc or kvm"})
|
||||
return
|
||||
}
|
||||
if len(req.URL) > 4096 {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "url must not exceed 4096 characters"})
|
||||
return
|
||||
}
|
||||
parsedURL, err := url.ParseRequestURI(req.URL)
|
||||
if err != nil || parsedURL.Host == "" || (parsedURL.Scheme != "https" && parsedURL.Scheme != "http") || parsedURL.User != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "url must be a valid HTTP or HTTPS download URL without credentials"})
|
||||
return
|
||||
}
|
||||
if req.SHA256 != "" && !sha256Pattern.MatchString(req.SHA256) {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "sha256 must contain exactly 64 hexadecimal characters"})
|
||||
return
|
||||
}
|
||||
if req.Type == config.VirtualizationLXC {
|
||||
for _, existing := range lxc.GetTemplates() {
|
||||
if strings.EqualFold(existing.Name, req.Name) {
|
||||
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "an image with this name already exists"})
|
||||
return
|
||||
}
|
||||
if existing.Custom && existing.URL == req.URL {
|
||||
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "this image URL is already registered"})
|
||||
return
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for _, existing := range kvm.GetImages() {
|
||||
if strings.EqualFold(existing.Name, req.Name) {
|
||||
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "an image with this name already exists"})
|
||||
return
|
||||
}
|
||||
if existing.Custom && existing.URL == req.URL {
|
||||
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "this image URL is already registered"})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
random := make([]byte, 5)
|
||||
if _, err := rand.Read(random); err != nil {
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "failed to generate image ID"})
|
||||
return
|
||||
}
|
||||
createdAt := time.Now().Format("2006-01-02 15:04:05")
|
||||
if req.Type == config.VirtualizationLXC {
|
||||
image := config.CustomLXCImage{
|
||||
ID: "custom-lxc-" + hex.EncodeToString(random),
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
Distro: req.Distro,
|
||||
Release: req.Release,
|
||||
Arch: req.Arch,
|
||||
URL: req.URL,
|
||||
SHA256: req.SHA256,
|
||||
CreatedAt: createdAt,
|
||||
}
|
||||
if err := config.AddCustomLXCImage(image); err != nil {
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "failed to save custom image: " + err.Error()})
|
||||
return
|
||||
}
|
||||
jsonResponse(w, http.StatusCreated, APIResponse{Success: true, Message: "Custom image added", Data: image})
|
||||
return
|
||||
}
|
||||
image := config.CustomKVMImage{
|
||||
ID: "custom-kvm-" + hex.EncodeToString(random), Name: req.Name, Description: req.Description,
|
||||
Distro: req.Distro, Release: req.Release, Arch: req.Arch, URL: req.URL,
|
||||
Provisioner: req.Provisioner, SHA256: req.SHA256, CreatedAt: createdAt,
|
||||
}
|
||||
if err := config.AddCustomKVMImage(image); err != nil {
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "failed to save custom image: " + err.Error()})
|
||||
return
|
||||
}
|
||||
jsonResponse(w, http.StatusCreated, APIResponse{Success: true, Message: "Custom image added", Data: image})
|
||||
}
|
||||
|
||||
func handleCustomKVMImageDelete(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.ID) == "" {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "id required"})
|
||||
return
|
||||
}
|
||||
req.ID = strings.TrimSpace(req.ID)
|
||||
kvmImage := kvm.FindImage(req.ID)
|
||||
lxcImage := lxc.FindTemplate(req.ID)
|
||||
isCustomKVM := kvmImage != nil && kvmImage.Custom
|
||||
isCustomLXC := lxcImage != nil && lxcImage.Custom
|
||||
if !isCustomKVM && !isCustomLXC {
|
||||
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Custom image not found"})
|
||||
return
|
||||
}
|
||||
if isImageDownloadActive(req.ID) {
|
||||
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "Image is downloading; cancel it before removing the source"})
|
||||
return
|
||||
}
|
||||
for i := range config.AppConfig.Containers {
|
||||
if config.AppConfig.Containers[i].Template == req.ID {
|
||||
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "This image is still used by a container"})
|
||||
return
|
||||
}
|
||||
}
|
||||
for i := range config.AppConfig.Tasks {
|
||||
task := &config.AppConfig.Tasks[i]
|
||||
if task.Status != "pending" && task.Status != "running" {
|
||||
continue
|
||||
}
|
||||
var taskConfig struct {
|
||||
TemplateID string `json:"template_id"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(task.Config), &taskConfig)
|
||||
if task.TemplateID == req.ID || taskConfig.TemplateID == req.ID {
|
||||
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "This image is still referenced by an active task"})
|
||||
return
|
||||
}
|
||||
}
|
||||
var deleteErr error
|
||||
if isCustomLXC {
|
||||
deleteErr = lxc.DeleteCustomImage(req.ID)
|
||||
} else {
|
||||
deleteErr = kvm.DeleteImage(req.ID)
|
||||
}
|
||||
if deleteErr != nil {
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to delete image cache: " + deleteErr.Error()})
|
||||
return
|
||||
}
|
||||
removeImageEnabled(req.ID)
|
||||
var removed bool
|
||||
var err error
|
||||
if isCustomLXC {
|
||||
removed, err = config.RemoveCustomLXCImage(req.ID)
|
||||
} else {
|
||||
removed, err = config.RemoveCustomKVMImage(req.ID)
|
||||
}
|
||||
if err != nil {
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to remove custom image: " + err.Error()})
|
||||
return
|
||||
}
|
||||
if !removed {
|
||||
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Custom image not found"})
|
||||
return
|
||||
}
|
||||
clearImageDownload(req.ID)
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Custom image removed"})
|
||||
}
|
||||
|
||||
// HandleImageDownload starts a template image download in the background.
|
||||
func HandleImageDownload(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
@@ -407,19 +675,52 @@ func HandleImageDownload(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// Already downloaded? Just enable if needed.
|
||||
if isImageDownloaded(tmpl.Distro, tmpl.Release, tmpl.Arch) {
|
||||
if downloaded, _ := lxcTemplateDownloadedInfo(*tmpl); downloaded {
|
||||
ensureImageEnabled(tmpl.ID)
|
||||
clearImageDownload(tmpl.ID)
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Already downloaded"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx, ok := startImageDownload(tmpl.ID, "lxc-create")
|
||||
startStage := "lxc-create"
|
||||
if tmpl.Custom {
|
||||
startStage = "downloading"
|
||||
}
|
||||
ctx, ok := startImageDownload(tmpl.ID, startStage)
|
||||
if !ok {
|
||||
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "Already downloading"})
|
||||
return
|
||||
}
|
||||
|
||||
if tmpl.Custom {
|
||||
go func(tmpl lxc.Template) {
|
||||
defer endLXCImageDownload()
|
||||
err := lxc.DownloadCustomImageWithProgress(ctx, tmpl, func(progress lxc.CustomImageDownloadProgress) {
|
||||
updateImageDownload(tmpl.ID, func(status *imageDownloadStatus) {
|
||||
status.Stage = progress.Stage
|
||||
status.DownloadedBytes = progress.DownloadedBytes
|
||||
status.TotalBytes = progress.TotalBytes
|
||||
status.Progress = progress.Percent
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
_ = os.Remove(lxc.CustomImagePath(tmpl.ID) + ".tmp")
|
||||
_ = os.Remove(lxc.CustomImagePath(tmpl.ID))
|
||||
finishImageDownload(tmpl.ID, nil)
|
||||
return
|
||||
}
|
||||
finishImageDownload(tmpl.ID, err)
|
||||
return
|
||||
}
|
||||
ensureImageEnabled(tmpl.ID)
|
||||
finishImageDownload(tmpl.ID, nil)
|
||||
}(*tmpl)
|
||||
lxcDownloadHandedOff = true
|
||||
jsonResponse(w, http.StatusAccepted, APIResponse{Success: true, Message: "Download started"})
|
||||
return
|
||||
}
|
||||
|
||||
go func(tmpl lxc.Template) {
|
||||
defer endLXCImageDownload()
|
||||
// Download via lxc-create with a temp container, then destroy it.
|
||||
@@ -633,7 +934,12 @@ func HandleImageCancel(w http.ResponseWriter, r *http.Request) {
|
||||
os.Remove(kvm.ImagePath(image.ID))
|
||||
}
|
||||
if tmpl := lxc.FindTemplate(req.TemplateID); tmpl != nil {
|
||||
go cleanupLXCImageDownloadTemp(tmpl.ID)
|
||||
if tmpl.Custom {
|
||||
_ = os.Remove(lxc.CustomImagePath(tmpl.ID) + ".tmp")
|
||||
_ = os.Remove(lxc.CustomImagePath(tmpl.ID))
|
||||
} else {
|
||||
go cleanupLXCImageDownloadTemp(tmpl.ID)
|
||||
}
|
||||
}
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Cancel requested"})
|
||||
}
|
||||
@@ -674,6 +980,15 @@ func HandleImageDelete(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Template not found"})
|
||||
return
|
||||
}
|
||||
if tmpl.Custom {
|
||||
if err := lxc.DeleteCustomImage(tmpl.ID); err != nil {
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to delete image cache: " + err.Error()})
|
||||
return
|
||||
}
|
||||
removeImageEnabled(tmpl.ID)
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Deleted"})
|
||||
return
|
||||
}
|
||||
|
||||
// Remove cache directory
|
||||
cachePath := filepath.Join("/var/cache/lxc/download", tmpl.Distro, tmpl.Release, tmpl.Arch)
|
||||
@@ -773,7 +1088,7 @@ func HandleEnabledImages(w http.ResponseWriter, r *http.Request) {
|
||||
if subUser != nil && !isImageAllowedForSubUser(subUser, targetContainer, t.ID) {
|
||||
continue
|
||||
}
|
||||
if downloaded := isImageDownloaded(t.Distro, t.Release, t.Arch); downloaded && (enabledSet[t.ID] || currentImageIDs[t.ID]) {
|
||||
if downloaded, _ := lxcTemplateDownloadedInfo(t); downloaded && (enabledSet[t.ID] || currentImageIDs[t.ID]) {
|
||||
result = append(result, map[string]string{
|
||||
"id": t.ID, "name": t.Name, "distro": t.Distro, "release": t.Release, "arch": t.Arch,
|
||||
"variant": t.Variant, "description": t.Description, "type": config.VirtualizationLXC,
|
||||
@@ -810,7 +1125,8 @@ func isImageDownloadedForRuntime(templateID string, runtime string) bool {
|
||||
if tmpl == nil {
|
||||
return false
|
||||
}
|
||||
return isImageDownloaded(tmpl.Distro, tmpl.Release, tmpl.Arch)
|
||||
downloaded, _ := lxcTemplateDownloadedInfo(*tmpl)
|
||||
return downloaded
|
||||
}
|
||||
|
||||
func isTemplateAvailableForRequest(r *http.Request, c *config.Container, templateID string, runtime string) bool {
|
||||
@@ -844,7 +1160,8 @@ func isImageEnabledAndDownloaded(templateID string, runtime string) bool {
|
||||
return false
|
||||
}
|
||||
enabledSet := getEnabledImageSet()
|
||||
return enabledSet[tmpl.ID] && isImageDownloaded(tmpl.Distro, tmpl.Release, tmpl.Arch)
|
||||
downloaded, _ := lxcTemplateDownloadedInfo(*tmpl)
|
||||
return enabledSet[tmpl.ID] && downloaded
|
||||
}
|
||||
|
||||
func hostKVMAvailable() bool {
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCustomKVMImageCreateRejectsInvalidSource(t *testing.T) {
|
||||
payload := map[string]string{
|
||||
"name": "Invalid Source",
|
||||
"distro": "ubuntu",
|
||||
"release": "noble",
|
||||
"arch": runtime.GOARCH,
|
||||
"url": "file:///etc/passwd",
|
||||
"provisioner": "linux-cloud-init",
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/images/custom", bytes.NewReader(body))
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
HandleCustomKVMImages(response, request)
|
||||
|
||||
if response.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want %d; body=%s", response.Code, http.StatusBadRequest, response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomKVMImageCreateRejectsArchitectureMismatch(t *testing.T) {
|
||||
otherArch := "arm64"
|
||||
if runtime.GOARCH == otherArch {
|
||||
otherArch = "amd64"
|
||||
}
|
||||
payload := map[string]string{
|
||||
"name": "Wrong Architecture",
|
||||
"distro": "ubuntu",
|
||||
"release": "noble",
|
||||
"arch": otherArch,
|
||||
"url": "https://example.test/image.qcow2",
|
||||
"provisioner": "linux-cloud-init",
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/images/custom", bytes.NewReader(body))
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
HandleCustomKVMImages(response, request)
|
||||
|
||||
if response.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want %d; body=%s", response.Code, http.StatusBadRequest, response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomLXCImageCreateRejectsInvalidSource(t *testing.T) {
|
||||
payload := map[string]string{
|
||||
"type": "lxc",
|
||||
"name": "Invalid LXC Source",
|
||||
"distro": "alpine",
|
||||
"release": "3.21",
|
||||
"arch": runtime.GOARCH,
|
||||
"url": "file:///tmp/rootfs.tar.xz",
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/images/custom", bytes.NewReader(body))
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
HandleCustomKVMImages(response, request)
|
||||
|
||||
if response.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want %d; body=%s", response.Code, http.StatusBadRequest, response.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,11 @@ type nat4PortRange struct {
|
||||
End int `json:"end"`
|
||||
}
|
||||
|
||||
type nat4Networks struct {
|
||||
LXC config.NATNetwork `json:"lxc"`
|
||||
KVM config.NATNetwork `json:"kvm"`
|
||||
}
|
||||
|
||||
type nat4Route struct {
|
||||
ContainerID int `json:"container_id"`
|
||||
ContainerName string `json:"container_name"`
|
||||
@@ -72,6 +77,8 @@ type ipv6Route struct {
|
||||
type routingResponse struct {
|
||||
NAT4 routeCapacity `json:"nat4"`
|
||||
NAT4PortRange nat4PortRange `json:"nat4_port_range"`
|
||||
NAT4NextPort int `json:"nat4_next_port"`
|
||||
NAT4Networks nat4Networks `json:"nat4_networks"`
|
||||
IPv4 routeCapacity `json:"ipv4"`
|
||||
LANDHCP routeCapacity `json:"lan_dhcp"`
|
||||
IPv6 routeCapacity `json:"ipv6"`
|
||||
@@ -237,6 +244,7 @@ func handleRoutingGet(w http.ResponseWriter, r *http.Request) {
|
||||
if nat4Remaining < 0 {
|
||||
nat4Remaining = 0
|
||||
}
|
||||
nat4NextPort, _ := config.PreviewSSHPortExcluding(nil)
|
||||
|
||||
prefixes := lxc.DetectPublicIPv6Prefixes()
|
||||
hostPublicIPv4 := lxc.DetectPublicIPv4()
|
||||
@@ -262,6 +270,11 @@ func handleRoutingGet(w http.ResponseWriter, r *http.Request) {
|
||||
Start: nat4StartPort,
|
||||
End: nat4EndPort,
|
||||
},
|
||||
NAT4NextPort: nat4NextPort,
|
||||
NAT4Networks: nat4Networks{
|
||||
LXC: config.LXCNATNetwork(),
|
||||
KVM: config.KVMNATNetwork(),
|
||||
},
|
||||
IPv4: routeCapacity{
|
||||
Used: ipv4Used,
|
||||
Remaining: strconv.Itoa(ipv4Remaining),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
@@ -24,3 +25,44 @@ func TestHandleRoutingGetAllowsRoutingWriteScope(t *testing.T) {
|
||||
t.Fatal("routing:write scope should be able to receive the routing response after updates")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRoutingGetReturnsConfiguredNextNATPort(t *testing.T) {
|
||||
previous := config.AppConfig
|
||||
t.Cleanup(func() { config.AppConfig = previous })
|
||||
config.AppConfig = &config.ClicdConfig{
|
||||
NATPortStart: 30000,
|
||||
NATPortEnd: 35000,
|
||||
NextSSHPort: 30000,
|
||||
Containers: []config.Container{{
|
||||
PortMappings: []config.PortMapping{{HostPort: 30000}},
|
||||
}},
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/routing", nil)
|
||||
req = withAuthContext(req, AuthContext{
|
||||
Type: authTypeAPIKey,
|
||||
Scopes: []string{"routing:read"},
|
||||
})
|
||||
rec := httptest.NewRecorder()
|
||||
handleRoutingGet(rec, req)
|
||||
|
||||
var response struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
NAT4PortRange nat4PortRange `json:"nat4_port_range"`
|
||||
NAT4NextPort int `json:"nat4_next_port"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !response.Success {
|
||||
t.Fatalf("routing response was unsuccessful: %s", rec.Body.String())
|
||||
}
|
||||
if response.Data.NAT4PortRange.Start != 30000 || response.Data.NAT4PortRange.End != 35000 {
|
||||
t.Fatalf("NAT range = %+v", response.Data.NAT4PortRange)
|
||||
}
|
||||
if response.Data.NAT4NextPort != 30001 {
|
||||
t.Fatalf("next NAT port = %d, want 30001", response.Data.NAT4NextPort)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -462,6 +462,8 @@ func (q *TaskQueue) runCreateTask(task *Task) {
|
||||
q.finishTask(task, "failed", err)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
lxc.ReleaseQueuedCreateNATPorts(cfg.Name)
|
||||
}
|
||||
|
||||
q.mu.Lock()
|
||||
@@ -866,7 +868,12 @@ func HandleBatchCreate(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
requestNames[name] = true
|
||||
}
|
||||
ids := globalQueue.EnqueueBatchCreateWithAudit(req.Containers, requestActor(r), clientIP(r), r.UserAgent())
|
||||
planned, err := lxc.ReserveBatchCreateNATPorts(req.Containers)
|
||||
if err != nil {
|
||||
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
ids := globalQueue.EnqueueBatchCreateWithAudit(planned, requestActor(r), clientIP(r), r.UserAgent())
|
||||
jsonResponse(w, http.StatusAccepted, APIResponse{Success: true, Data: ids})
|
||||
}
|
||||
|
||||
@@ -988,7 +995,8 @@ func HandleTaskDelete(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
globalQueue.mu.Lock()
|
||||
if task := globalQueue.tasks[taskID]; task != nil && !isTaskAllowedForRequest(r, task) {
|
||||
task := globalQueue.tasks[taskID]
|
||||
if task != nil && !isTaskAllowedForRequest(r, task) {
|
||||
globalQueue.mu.Unlock()
|
||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to this task"})
|
||||
return
|
||||
@@ -1011,6 +1019,9 @@ func HandleTaskDelete(w http.ResponseWriter, r *http.Request) {
|
||||
globalQueue.opQueue = newOp
|
||||
globalQueue.persistTasks()
|
||||
globalQueue.mu.Unlock()
|
||||
if task != nil && task.Type == TaskCreate {
|
||||
lxc.ReleaseQueuedCreateNATPorts(task.Config.Name)
|
||||
}
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Task deleted"})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"clicd/internal/config"
|
||||
)
|
||||
|
||||
// RunAccessPolicyCommand manages the panel source policy without requiring the
|
||||
// interactive menu. It is intended to remain usable over SSH as a recovery path.
|
||||
func RunAccessPolicyCommand(args []string) error {
|
||||
action := "show"
|
||||
if len(args) > 0 {
|
||||
action = strings.ToLower(strings.TrimSpace(args[0]))
|
||||
args = args[1:]
|
||||
}
|
||||
|
||||
switch action {
|
||||
case "show":
|
||||
printPanelAccessPolicy(config.AppConfig.PanelAccessPolicy)
|
||||
return nil
|
||||
case "disable", "off":
|
||||
next := config.AppConfig.PanelAccessPolicy
|
||||
next.Enabled = false
|
||||
if err := savePanelAccessPolicy(next); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println("Panel access allowlist disabled.")
|
||||
return reloadPanelAfterAccessPolicyCommand()
|
||||
case "set", "enable":
|
||||
flags := flag.NewFlagSet("clicd access-policy set", flag.ContinueOnError)
|
||||
flags.SetOutput(new(strings.Builder))
|
||||
var allowed string
|
||||
var trusted string
|
||||
flags.StringVar(&allowed, "allow", "", "comma-separated allowed IP/CIDR values")
|
||||
flags.StringVar(&trusted, "trusted-proxy", "", "comma-separated trusted proxy IP/CIDR values")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return fmt.Errorf("invalid access-policy arguments: %w", err)
|
||||
}
|
||||
next := config.PanelAccessPolicy{
|
||||
Enabled: true,
|
||||
AllowedSources: splitPanelAccessEntries(allowed),
|
||||
TrustedProxies: splitPanelAccessEntries(trusted),
|
||||
}
|
||||
if err := savePanelAccessPolicy(next); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println("Panel access allowlist saved.")
|
||||
printPanelAccessPolicy(config.AppConfig.PanelAccessPolicy)
|
||||
return reloadPanelAfterAccessPolicyCommand()
|
||||
default:
|
||||
return fmt.Errorf("unknown access-policy action %q; use show, set, or disable", action)
|
||||
}
|
||||
}
|
||||
|
||||
func savePanelAccessPolicy(policy config.PanelAccessPolicy) error {
|
||||
normalized, err := config.NormalizePanelAccessPolicy(policy)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
previous := config.AppConfig.PanelAccessPolicy
|
||||
config.AppConfig.PanelAccessPolicy = normalized
|
||||
if err := config.SaveConfig(); err != nil {
|
||||
config.AppConfig.PanelAccessPolicy = previous
|
||||
return fmt.Errorf("save panel access policy: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func reloadPanelAfterAccessPolicyCommand() error {
|
||||
if !isWebPanelRunning() {
|
||||
return nil
|
||||
}
|
||||
if err := restartService("clicd"); err != nil {
|
||||
return fmt.Errorf("policy was saved but clicd service restart failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func printPanelAccessPolicy(policy config.PanelAccessPolicy) {
|
||||
fmt.Printf("Enabled: %t\n", policy.Enabled)
|
||||
fmt.Printf("Allowed sources: %s\n", strings.Join(policy.AllowedSources, ", "))
|
||||
fmt.Printf("Trusted proxies: %s\n", strings.Join(policy.TrustedProxies, ", "))
|
||||
}
|
||||
@@ -54,6 +54,7 @@ var cliTranslations = map[string]string{
|
||||
"导入现有 LXC 容器": "Import existing LXC containers",
|
||||
"检查并升级 CLICD": "Check and upgrade CLICD",
|
||||
"卸载 CLICD": "Uninstall CLICD",
|
||||
"面板访问白名单": "Panel access allowlist",
|
||||
"系统信息": "System info",
|
||||
"退出": "Exit",
|
||||
"获取容器列表失败": "Failed to get container list",
|
||||
@@ -175,10 +176,26 @@ var cliTranslations = map[string]string{
|
||||
"LXC 版本": "LXC version",
|
||||
"暂无可用容器": "No available containers",
|
||||
"忽略无效端口": "Ignoring invalid port",
|
||||
"?": "? ",
|
||||
"。": ". ",
|
||||
",": ", ",
|
||||
":": ": ",
|
||||
"面板访问来源策略": "Panel access source policy",
|
||||
"当前状态": "Current status",
|
||||
"已启用": "enabled",
|
||||
"已关闭": "disabled",
|
||||
"允许来源": "Allowed sources",
|
||||
"可信代理": "Trusted proxies",
|
||||
"启用或修改白名单": "Enable or update allowlist",
|
||||
"关闭白名单限制": "Disable allowlist",
|
||||
"取消": "Cancel",
|
||||
"允许的 IP/CIDR,多个用逗号分隔": "Allowed IP/CIDR values, comma-separated",
|
||||
"可信代理 IP/CIDR,多个用逗号分隔,可留空": "Trusted proxy IP/CIDR values, comma-separated; optional",
|
||||
"白名单配置无效": "Invalid allowlist configuration",
|
||||
"保存访问来源策略失败": "Failed to save access source policy",
|
||||
"面板访问白名单已保存。": "Panel access allowlist saved.",
|
||||
"面板访问白名单已关闭。": "Panel access allowlist disabled.",
|
||||
"至少填写一个允许的 IP 或网段。": "Enter at least one allowed IP address or network.",
|
||||
"?": "? ",
|
||||
"。": ". ",
|
||||
",": ", ",
|
||||
":": ": ",
|
||||
}
|
||||
|
||||
// Run starts the CLI interface.
|
||||
@@ -193,7 +210,7 @@ func Run() {
|
||||
refreshCLILanguage()
|
||||
clearScreen()
|
||||
printMenu()
|
||||
cliPrint("\n请选择操作 [1-12,l,0/q]: ")
|
||||
cliPrint("\n请选择操作 [1-13,l,0/q]: ")
|
||||
input, _ := reader.ReadString('\n')
|
||||
input = strings.TrimSpace(input)
|
||||
|
||||
@@ -246,6 +263,10 @@ func Run() {
|
||||
clearScreen()
|
||||
cliUninstall(reader)
|
||||
return
|
||||
case "13":
|
||||
clearScreen()
|
||||
cliConfigurePanelAccess(reader)
|
||||
waitEnter(reader)
|
||||
case "0":
|
||||
clearScreen()
|
||||
cliShowInfo()
|
||||
@@ -293,11 +314,80 @@ func printMenu() {
|
||||
cliPrintln(" 10. 导入现有 LXC 容器")
|
||||
cliPrintln(" 11. 检查并升级 CLICD")
|
||||
cliPrintln(" 12. 卸载 CLICD")
|
||||
cliPrintln(" 13. 面板访问白名单")
|
||||
cliPrintln(" 0. 系统信息")
|
||||
cliPrintln(" l. 切换语言")
|
||||
cliPrintln(" q. 退出")
|
||||
}
|
||||
|
||||
func cliConfigurePanelAccess(reader *bufio.Reader) {
|
||||
cliPrintf("\n--- %s ---\n", cliT("面板访问来源策略"))
|
||||
policy := config.AppConfig.PanelAccessPolicy
|
||||
status := cliT("已关闭")
|
||||
if policy.Enabled {
|
||||
status = cliT("已启用")
|
||||
}
|
||||
cliPrintf("%s: %s\n", cliT("当前状态"), status)
|
||||
cliPrintf("%s: %s\n", cliT("允许来源"), strings.Join(policy.AllowedSources, ", "))
|
||||
cliPrintf("%s: %s\n", cliT("可信代理"), strings.Join(policy.TrustedProxies, ", "))
|
||||
cliPrintf("\n 1. %s\n", cliT("启用或修改白名单"))
|
||||
cliPrintf(" 2. %s\n", cliT("关闭白名单限制"))
|
||||
cliPrintf(" 0. %s\n", cliT("取消"))
|
||||
|
||||
choice := promptString(reader, "请选择操作", "0")
|
||||
next := policy
|
||||
switch strings.TrimSpace(choice) {
|
||||
case "1":
|
||||
allowed := promptString(reader, "允许的 IP/CIDR,多个用逗号分隔", strings.Join(policy.AllowedSources, ","))
|
||||
allowedSources := splitPanelAccessEntries(allowed)
|
||||
if len(allowedSources) == 0 {
|
||||
cliPrintln("至少填写一个允许的 IP 或网段。")
|
||||
return
|
||||
}
|
||||
trusted := promptString(reader, "可信代理 IP/CIDR,多个用逗号分隔,可留空", strings.Join(policy.TrustedProxies, ","))
|
||||
next = config.PanelAccessPolicy{
|
||||
Enabled: true,
|
||||
AllowedSources: allowedSources,
|
||||
TrustedProxies: splitPanelAccessEntries(trusted),
|
||||
}
|
||||
case "2":
|
||||
next.Enabled = false
|
||||
case "0", "":
|
||||
cliPrintln("已取消")
|
||||
return
|
||||
default:
|
||||
cliPrintln("无效选择")
|
||||
return
|
||||
}
|
||||
|
||||
normalized, err := config.NormalizePanelAccessPolicy(next)
|
||||
if err != nil {
|
||||
cliPrintf("%s: %v\n", cliT("白名单配置无效"), err)
|
||||
return
|
||||
}
|
||||
previous := config.AppConfig.PanelAccessPolicy
|
||||
config.AppConfig.PanelAccessPolicy = normalized
|
||||
if err := config.SaveConfig(); err != nil {
|
||||
config.AppConfig.PanelAccessPolicy = previous
|
||||
cliPrintf("%s: %v\n", cliT("保存访问来源策略失败"), err)
|
||||
return
|
||||
}
|
||||
if normalized.Enabled {
|
||||
cliPrintln("面板访问白名单已保存。")
|
||||
} else {
|
||||
cliPrintln("面板访问白名单已关闭。")
|
||||
}
|
||||
if isWebPanelRunning() {
|
||||
restartWebPanelForConfigChange()
|
||||
}
|
||||
}
|
||||
|
||||
func splitPanelAccessEntries(value string) []string {
|
||||
return strings.FieldsFunc(value, func(r rune) bool {
|
||||
return r == ',' || r == ';' || r == '\n' || r == '\r' || r == '\t' || r == ' '
|
||||
})
|
||||
}
|
||||
|
||||
func cliSwitchLanguage(reader *bufio.Reader) {
|
||||
cliPrintf("\n--- %s ---\n", cliT("切换语言"))
|
||||
cliPrintf("%s: %s\n", cliT("当前语言"), cliLanguageLabel(config.NormalizeLanguage(config.AppConfig.Language)))
|
||||
@@ -1244,8 +1334,8 @@ func removeCLICDNATRules() {
|
||||
break
|
||||
}
|
||||
}
|
||||
deleteNATRule("POSTROUTING", "-s", "10.0.3.0/24", "-o", "eth+", "-j", "MASQUERADE")
|
||||
deleteNATRule("POSTROUTING", "-s", "192.168.122.0/24", "-o", "eth+", "-j", "MASQUERADE")
|
||||
deleteNATRule("POSTROUTING", "-s", config.LXCNATNetwork().Subnet, "-o", "eth+", "-j", "MASQUERADE")
|
||||
deleteNATRule("POSTROUTING", "-s", config.KVMNATNetwork().Subnet, "-o", "eth+", "-j", "MASQUERADE")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+61
-17
@@ -166,7 +166,7 @@ func DownloadImageWithProgress(ctx context.Context, image Image, progress Downlo
|
||||
return err
|
||||
}
|
||||
ext := ".qcow2"
|
||||
if image.Distro == "windows" {
|
||||
if image.IsWindows() {
|
||||
ext = ".iso"
|
||||
}
|
||||
target := filepath.Join(cacheDir, image.ID+ext)
|
||||
@@ -184,7 +184,7 @@ func DownloadImageWithProgress(ctx context.Context, image Image, progress Downlo
|
||||
}
|
||||
tmp := target + ".tmp"
|
||||
_ = os.Remove(tmp)
|
||||
if image.Distro == "windows" {
|
||||
if image.IsWindows() {
|
||||
if err := downloadFileWithValidator(ctx, image.URL, tmp, validateWindowsISOResponse(target), progress); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return err
|
||||
@@ -197,7 +197,13 @@ func DownloadImageWithProgress(ctx context.Context, image Image, progress Downlo
|
||||
_ = os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
if image.Distro == "windows" {
|
||||
if image.SHA256 != "" {
|
||||
if err := verifyFileSHA256(tmp, image.SHA256); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
}
|
||||
if image.IsWindows() {
|
||||
if err := validateWindowsISO(tmp, target); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return err
|
||||
@@ -221,6 +227,23 @@ func DownloadImageWithProgress(ctx context.Context, image Image, progress Downlo
|
||||
return nil
|
||||
}
|
||||
|
||||
func verifyFileSHA256(path, expected string) error {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
hash := sha256.New()
|
||||
if _, err := io.Copy(hash, file); err != nil {
|
||||
return err
|
||||
}
|
||||
actual := hex.EncodeToString(hash.Sum(nil))
|
||||
if !strings.EqualFold(actual, strings.TrimSpace(expected)) {
|
||||
return fmt.Errorf("SHA-256 mismatch: expected %s, got %s", expected, actual)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func DeleteImage(id string) error {
|
||||
return os.RemoveAll(ImagePath(id))
|
||||
}
|
||||
@@ -500,11 +523,15 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
|
||||
var xml string
|
||||
winAdminPassword := ""
|
||||
if IsWindowsImage(image.ID) {
|
||||
if cfg.RAMMB < 2048 {
|
||||
cfg.RAMMB = 2048
|
||||
minVCPU, minRAMMB, minDiskGB := windowsMinimumResources(image.ID)
|
||||
if cfg.VCPU < minVCPU {
|
||||
cfg.VCPU = minVCPU
|
||||
}
|
||||
if cfg.DiskGB < 30 {
|
||||
cfg.DiskGB = 30
|
||||
if cfg.RAMMB < minRAMMB {
|
||||
cfg.RAMMB = minRAMMB
|
||||
}
|
||||
if cfg.DiskGB < minDiskGB {
|
||||
cfg.DiskGB = minDiskGB
|
||||
}
|
||||
cfg.ReportProgress("disk", "创建 Windows 虚拟磁盘")
|
||||
if err := createEmptyDisk(diskPath, cfg.DiskGB); err != nil {
|
||||
@@ -516,7 +543,7 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
|
||||
winAdminPassword = generateWindowsPassword()
|
||||
unattendPath := filepath.Join(m.instanceDir(vmName), "unattend.iso")
|
||||
cfg.ReportProgress("cloud_init", "生成 Windows 自动应答配置")
|
||||
if err := createWindowsUnattendISO(unattendPath, cfg.Name, winAdminPassword, mac, ipv6List, ipv4List); err != nil {
|
||||
if err := createWindowsUnattendISO(unattendPath, cfg.Name, winAdminPassword, mac, ipv6List, ipv4List, IsWindows11Image(image.ID)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
xml = windowsDomainXML(vmName, int(cfg.VCPU), cfg.RAMMB, diskPath, ImagePath(image.ID), unattendPath, mac, cfg.IOReadMBps, cfg.IOWriteMBps, cfg.NetworkDownMbps, cfg.NetworkUpMbps)
|
||||
@@ -1724,16 +1751,17 @@ func ensureDefaultNetwork() error {
|
||||
// Ensure default network is defined
|
||||
if virshCLocaleCommand("net-info", "default").Run() != nil {
|
||||
// Default network may not be defined; try to define it
|
||||
netXML := `<network>
|
||||
network := config.KVMNATNetwork()
|
||||
netXML := fmt.Sprintf(`<network>
|
||||
<name>default</name>
|
||||
<bridge name='virbr0'/>
|
||||
<forward mode='nat'/>
|
||||
<ip address='192.168.122.1' netmask='255.255.255.0'>
|
||||
<ip address='%s' netmask='%s'>
|
||||
<dhcp>
|
||||
<range start='192.168.122.2' end='192.168.122.254'/>
|
||||
<range start='%s' end='%s'/>
|
||||
</dhcp>
|
||||
</ip>
|
||||
</network>`
|
||||
</network>`, network.Gateway, network.Netmask, network.DHCPStart, network.DHCPEnd)
|
||||
tmpFile := filepath.Join(os.TempDir(), "clicd-default-net.xml")
|
||||
if err := os.WriteFile(tmpFile, []byte(netXML), 0644); err != nil {
|
||||
return fmt.Errorf("failed to write default network XML: %v", err)
|
||||
@@ -1832,7 +1860,7 @@ func createEmptyDisk(target string, diskGB int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func createWindowsUnattendISO(target, hostname, adminPassword, mac string, ipv6s []string, ipv4s []string) error {
|
||||
func createWindowsUnattendISO(target, hostname, adminPassword, mac string, ipv6s []string, ipv4s []string, windows11 bool) error {
|
||||
tool := firstAvailableCommand("genisoimage", "mkisofs", "xorriso")
|
||||
if tool == "" {
|
||||
return fmt.Errorf("one of genisoimage, mkisofs, xorriso is required for Windows unattended setup")
|
||||
@@ -1852,7 +1880,7 @@ func createWindowsUnattendISO(target, hostname, adminPassword, mac string, ipv6s
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(answerPath, []byte(windowsAutounattendXML(hostname, adminPassword)), 0600); err != nil {
|
||||
if err := os.WriteFile(answerPath, []byte(windowsAutounattendXML(hostname, adminPassword, windows11)), 0600); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(setupScriptsDir, "SetupComplete.cmd"), []byte(windowsSetupCompleteCMD()), 0600); err != nil {
|
||||
@@ -1890,12 +1918,21 @@ func firstAvailableCommand(names ...string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func windowsAutounattendXML(hostname, adminPassword string) string {
|
||||
func windowsAutounattendXML(hostname, adminPassword string, windows11 bool) string {
|
||||
if strings.TrimSpace(hostname) == "" {
|
||||
hostname = "clicd-win"
|
||||
}
|
||||
hostname = sanitizeWindowsComputerName(hostname)
|
||||
setupCommand := `cmd.exe /c if exist C:\CLICD\FirstLogon.ps1 (powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\CLICD\FirstLogon.ps1) else (for %%d in (D E F G H I J K L M N O P Q R S T U V W X Y Z) do @if exist %%d:\FirstLogon.ps1 powershell.exe -NoProfile -ExecutionPolicy Bypass -File %%d:\FirstLogon.ps1)`
|
||||
compatibilityCommands := ""
|
||||
if windows11 {
|
||||
compatibilityCommands = `
|
||||
<RunSynchronous>
|
||||
<RunSynchronousCommand wcm:action="add"><Order>1</Order><Description>Allow virtual TPM compatibility</Description><Path>reg.exe add HKLM\SYSTEM\Setup\LabConfig /v BypassTPMCheck /t REG_DWORD /d 1 /f</Path></RunSynchronousCommand>
|
||||
<RunSynchronousCommand wcm:action="add"><Order>2</Order><Description>Allow virtual Secure Boot compatibility</Description><Path>reg.exe add HKLM\SYSTEM\Setup\LabConfig /v BypassSecureBootCheck /t REG_DWORD /d 1 /f</Path></RunSynchronousCommand>
|
||||
<RunSynchronousCommand wcm:action="add"><Order>3</Order><Description>Allow virtual CPU compatibility</Description><Path>reg.exe add HKLM\SYSTEM\Setup\LabConfig /v BypassCPUCheck /t REG_DWORD /d 1 /f</Path></RunSynchronousCommand>
|
||||
</RunSynchronous>`
|
||||
}
|
||||
return fmt.Sprintf(`<?xml version="1.0" encoding="utf-8"?>
|
||||
<unattend xmlns="urn:schemas-microsoft-com:unattend">
|
||||
<settings pass="windowsPE">
|
||||
@@ -1924,7 +1961,7 @@ func windowsAutounattendXML(hostname, adminPassword string) string {
|
||||
<AcceptEula>true</AcceptEula>
|
||||
<FullName>CLICD</FullName>
|
||||
<Organization>CLICD</Organization>
|
||||
</UserData>
|
||||
</UserData>%s
|
||||
</component>
|
||||
</settings>
|
||||
<settings pass="specialize">
|
||||
@@ -1945,7 +1982,14 @@ func windowsAutounattendXML(hostname, adminPassword string) string {
|
||||
</component>
|
||||
</settings>
|
||||
</unattend>
|
||||
`, xmlEscape(hostname), xmlEscape(adminPassword), xmlEscape(adminPassword), xmlEscape(setupCommand))
|
||||
`, compatibilityCommands, xmlEscape(hostname), xmlEscape(adminPassword), xmlEscape(adminPassword), xmlEscape(setupCommand))
|
||||
}
|
||||
|
||||
func windowsMinimumResources(imageID string) (float64, int, int) {
|
||||
if IsWindows11Image(imageID) {
|
||||
return 2, 4096, 64
|
||||
}
|
||||
return 1, 2048, 30
|
||||
}
|
||||
|
||||
func sanitizeWindowsComputerName(name string) string {
|
||||
|
||||
@@ -3,8 +3,14 @@ package kvm
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/xml"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"clicd/internal/config"
|
||||
@@ -24,6 +30,51 @@ func TestImagePathUsesAllowlistedImageID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindows11ImageDefinition(t *testing.T) {
|
||||
image := FindImage("kvm-windows-11")
|
||||
if image == nil {
|
||||
t.Fatal("Windows 11 image is missing from the amd64 image list")
|
||||
}
|
||||
if image.Distro != "windows" || image.Release != "11" || image.Arch != "amd64" {
|
||||
t.Fatalf("Windows 11 image metadata = %+v", image)
|
||||
}
|
||||
if !strings.Contains(image.URL, "microsoft.com/fwlink/") {
|
||||
t.Fatalf("Windows 11 image does not use an official Microsoft URL: %s", image.URL)
|
||||
}
|
||||
if got := filepath.Base(ImagePath(image.ID)); got != "kvm-windows-11.iso" {
|
||||
t.Fatalf("Windows 11 image basename = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindows11UnattendAddsCompatibilityChecksOnlyForWindows11(t *testing.T) {
|
||||
windows11 := windowsAutounattendXML("win11-test", "Password123!", true)
|
||||
windows10 := windowsAutounattendXML("win10-test", "Password123!", false)
|
||||
|
||||
for _, key := range []string{"BypassTPMCheck", "BypassSecureBootCheck", "BypassCPUCheck"} {
|
||||
if !strings.Contains(windows11, key) {
|
||||
t.Fatalf("Windows 11 unattend is missing %s", key)
|
||||
}
|
||||
if strings.Contains(windows10, key) {
|
||||
t.Fatalf("Windows 10 unattend unexpectedly contains %s", key)
|
||||
}
|
||||
}
|
||||
var document struct {
|
||||
XMLName xml.Name
|
||||
}
|
||||
if err := xml.Unmarshal([]byte(windows11), &document); err != nil {
|
||||
t.Fatalf("Windows 11 unattend XML is invalid: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowsMinimumResources(t *testing.T) {
|
||||
if cpu, ram, disk := windowsMinimumResources("kvm-windows-11"); cpu != 2 || ram != 4096 || disk != 64 {
|
||||
t.Fatalf("Windows 11 minimums = %v vCPU, %d MB, %d GB", cpu, ram, disk)
|
||||
}
|
||||
if cpu, ram, disk := windowsMinimumResources("kvm-windows-10"); cpu != 1 || ram != 2048 || disk != 30 {
|
||||
t.Fatalf("Windows 10 minimums = %v vCPU, %d MB, %d GB", cpu, ram, disk)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLibvirtNetworkActiveParsesCLocaleOutput(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -115,6 +166,77 @@ func TestVerifyKVMHostKeyCapturesAndRejectsMismatch(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetImagesIncludesHostArchitectureCustomImage(t *testing.T) {
|
||||
previous := config.AppConfig
|
||||
t.Cleanup(func() { config.AppConfig = previous })
|
||||
config.AppConfig = &config.ClicdConfig{
|
||||
CustomKVMImages: []config.CustomKVMImage{
|
||||
{
|
||||
ID: "custom-kvm-linux",
|
||||
Name: "Custom Linux",
|
||||
Distro: "ubuntu",
|
||||
Release: "noble",
|
||||
Arch: runtime.GOARCH,
|
||||
URL: "https://example.test/linux.qcow2",
|
||||
Provisioner: config.KVMProvisionerLinuxCloudInit,
|
||||
},
|
||||
{
|
||||
ID: "custom-kvm-other-arch",
|
||||
Name: "Other Architecture",
|
||||
Distro: "ubuntu",
|
||||
Release: "noble",
|
||||
Arch: "not-" + runtime.GOARCH,
|
||||
URL: "https://example.test/other.qcow2",
|
||||
Provisioner: config.KVMProvisionerLinuxCloudInit,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
image := FindImage("custom-kvm-linux")
|
||||
if image == nil || !image.Custom || image.Provisioner != config.KVMProvisionerLinuxCloudInit {
|
||||
t.Fatalf("custom image was not exposed correctly: %+v", image)
|
||||
}
|
||||
if FindImage("custom-kvm-other-arch") != nil {
|
||||
t.Fatal("custom image for another architecture was exposed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomWindowsProvisionerControlsImageType(t *testing.T) {
|
||||
previous := config.AppConfig
|
||||
t.Cleanup(func() { config.AppConfig = previous })
|
||||
config.AppConfig = &config.ClicdConfig{CustomKVMImages: []config.CustomKVMImage{{
|
||||
ID: "custom-kvm-windows",
|
||||
Name: "Custom Windows",
|
||||
Distro: "windows",
|
||||
Release: "11",
|
||||
Arch: runtime.GOARCH,
|
||||
URL: "https://example.test/windows.iso",
|
||||
Provisioner: config.KVMProvisionerWindows11,
|
||||
}}}
|
||||
|
||||
if !IsWindowsImage("custom-kvm-windows") || !IsWindows11Image("custom-kvm-windows") {
|
||||
t.Fatal("custom Windows 11 provisioner was not recognized")
|
||||
}
|
||||
if ext := filepath.Ext(ImagePath("custom-kvm-windows")); ext != ".iso" {
|
||||
t.Fatalf("custom Windows image extension = %q, want .iso", ext)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyFileSHA256(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "image")
|
||||
content := []byte("clicd custom image")
|
||||
if err := os.WriteFile(path, content, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sum := sha256.Sum256(content)
|
||||
if err := verifyFileSHA256(path, hex.EncodeToString(sum[:])); err != nil {
|
||||
t.Fatalf("valid checksum failed: %v", err)
|
||||
}
|
||||
if err := verifyFileSHA256(path, strings.Repeat("0", 64)); err == nil {
|
||||
t.Fatal("invalid checksum unexpectedly passed")
|
||||
}
|
||||
}
|
||||
|
||||
func testSSHPublicKey(t *testing.T) ssh.PublicKey {
|
||||
t.Helper()
|
||||
_, privateKey, err := ed25519.GenerateKey(rand.Reader)
|
||||
|
||||
@@ -17,15 +17,37 @@ type Image struct {
|
||||
Description string `json:"description"`
|
||||
URL string `json:"url"`
|
||||
Desktop string `json:"desktop,omitempty"`
|
||||
Provisioner string `json:"provisioner,omitempty"`
|
||||
SHA256 string `json:"sha256,omitempty"`
|
||||
Custom bool `json:"custom,omitempty"`
|
||||
}
|
||||
|
||||
func GetImages() []Image {
|
||||
var images []Image
|
||||
switch runtime.GOARCH {
|
||||
case "arm64":
|
||||
return arm64Images()
|
||||
images = arm64Images()
|
||||
default:
|
||||
return amd64Images()
|
||||
images = amd64Images()
|
||||
}
|
||||
for _, custom := range config.ListCustomKVMImages() {
|
||||
if custom.Arch != runtime.GOARCH {
|
||||
continue
|
||||
}
|
||||
images = append(images, Image{
|
||||
ID: custom.ID,
|
||||
Name: custom.Name,
|
||||
Distro: custom.Distro,
|
||||
Release: custom.Release,
|
||||
Arch: custom.Arch,
|
||||
Description: custom.Description,
|
||||
URL: custom.URL,
|
||||
Provisioner: custom.Provisioner,
|
||||
SHA256: custom.SHA256,
|
||||
Custom: true,
|
||||
})
|
||||
}
|
||||
return images
|
||||
}
|
||||
|
||||
func amd64Images() []Image {
|
||||
@@ -111,6 +133,12 @@ func amd64Images() []Image {
|
||||
Description: "Rocky Linux 9 GenericCloud image for KVM",
|
||||
URL: "https://dl.rockylinux.org/pub/rocky/9/images/x86_64/Rocky-9-GenericCloud-Base.latest.x86_64.qcow2",
|
||||
},
|
||||
{
|
||||
ID: "kvm-windows-11", Name: "Windows 11 KVM",
|
||||
Distro: "windows", Release: "11", Arch: "amd64",
|
||||
Description: "Windows 11 Enterprise LTSC 2024 Evaluation",
|
||||
URL: "https://go.microsoft.com/fwlink/?clcid=0x409&country=us&culture=en-us&linkid=2289029",
|
||||
},
|
||||
{
|
||||
ID: "kvm-windows-10", Name: "Windows 10 KVM",
|
||||
Distro: "windows", Release: "10", Arch: "amd64",
|
||||
@@ -196,7 +224,7 @@ func ImagePath(id string) string {
|
||||
if img != nil {
|
||||
safeID = img.ID
|
||||
}
|
||||
if img != nil && img.Distro == "windows" {
|
||||
if img != nil && img.IsWindows() {
|
||||
ext = ".iso"
|
||||
}
|
||||
fileName := safeID + ext
|
||||
@@ -213,10 +241,26 @@ func ImagePath(id string) string {
|
||||
return filepath.Join(CacheDir(), fileName)
|
||||
}
|
||||
|
||||
// IsWindowsImage returns true if the image distro is "windows".
|
||||
func (image Image) IsWindows() bool {
|
||||
return image.Provisioner == config.KVMProvisionerWindows10 ||
|
||||
image.Provisioner == config.KVMProvisionerWindows11 ||
|
||||
(image.Provisioner == "" && image.Distro == "windows")
|
||||
}
|
||||
|
||||
func (image Image) IsWindows11() bool {
|
||||
return image.Provisioner == config.KVMProvisionerWindows11 ||
|
||||
(image.Provisioner == "" && image.Distro == "windows" && image.Release == "11")
|
||||
}
|
||||
|
||||
// IsWindowsImage returns true if the image uses Windows unattended installation.
|
||||
func IsWindowsImage(id string) bool {
|
||||
img := FindImage(id)
|
||||
return img != nil && img.Distro == "windows"
|
||||
return img != nil && img.IsWindows()
|
||||
}
|
||||
|
||||
func IsWindows11Image(id string) bool {
|
||||
img := FindImage(id)
|
||||
return img != nil && img.IsWindows11()
|
||||
}
|
||||
|
||||
func virtioWinISOPath() string {
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
package lxc
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type CustomImageDownloadProgress struct {
|
||||
Stage string
|
||||
DownloadedBytes int64
|
||||
TotalBytes int64
|
||||
Percent int
|
||||
}
|
||||
|
||||
type CustomImageDownloadProgressFunc func(CustomImageDownloadProgress)
|
||||
|
||||
func CustomImagePath(id string) string {
|
||||
template := FindTemplate(id)
|
||||
if template == nil || !template.Custom {
|
||||
return filepath.Join("/var/cache/lxc/download/custom", "__invalid_image_id__", "rootfs.tar")
|
||||
}
|
||||
return filepath.Join("/var/cache/lxc/download/custom", template.ID, "rootfs.tar")
|
||||
}
|
||||
|
||||
func CustomImageDownloadedInfo(id string) (bool, int64) {
|
||||
info, err := os.Stat(CustomImagePath(id))
|
||||
if err != nil || info.IsDir() {
|
||||
return false, 0
|
||||
}
|
||||
return true, info.Size()
|
||||
}
|
||||
|
||||
func DeleteCustomImage(id string) error {
|
||||
template := FindTemplate(id)
|
||||
if template == nil || !template.Custom {
|
||||
return fmt.Errorf("custom LXC image not found")
|
||||
}
|
||||
return os.RemoveAll(filepath.Dir(CustomImagePath(id)))
|
||||
}
|
||||
|
||||
func DownloadCustomImageWithProgress(ctx context.Context, template Template, progress CustomImageDownloadProgressFunc) error {
|
||||
if !template.Custom {
|
||||
return fmt.Errorf("template is not a custom LXC image")
|
||||
}
|
||||
target := CustomImagePath(template.ID)
|
||||
if ok, _ := CustomImageDownloadedInfo(template.ID); ok {
|
||||
return nil
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := target + ".tmp"
|
||||
_ = os.Remove(tmp)
|
||||
if err := downloadCustomRootfs(ctx, template.URL, tmp, progress); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
if template.SHA256 != "" {
|
||||
if err := verifyCustomRootfsSHA256(tmp, template.SHA256); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
}
|
||||
if progress != nil {
|
||||
progress(CustomImageDownloadProgress{Stage: "validating", Percent: 100})
|
||||
}
|
||||
if err := ValidateCustomRootfsArchive(tmp); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmp, target); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
return os.Chmod(target, 0644)
|
||||
}
|
||||
|
||||
func downloadCustomRootfs(ctx context.Context, sourceURL, target string, progress CustomImageDownloadProgressFunc) error {
|
||||
client := http.Client{
|
||||
Timeout: 30 * time.Minute,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 10 {
|
||||
return fmt.Errorf("too many redirects")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, sourceURL, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Header.Set("User-Agent", "CLICD/1.0 LXC image downloader")
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return fmt.Errorf("download failed: %s", response.Status)
|
||||
}
|
||||
file, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
total := response.ContentLength
|
||||
buffer := make([]byte, 128*1024)
|
||||
var downloaded int64
|
||||
for {
|
||||
count, readErr := response.Body.Read(buffer)
|
||||
if count > 0 {
|
||||
if _, err := file.Write(buffer[:count]); err != nil {
|
||||
return err
|
||||
}
|
||||
downloaded += int64(count)
|
||||
if progress != nil {
|
||||
percent := 0
|
||||
if total > 0 {
|
||||
percent = int(downloaded * 100 / total)
|
||||
if percent > 100 {
|
||||
percent = 100
|
||||
}
|
||||
}
|
||||
progress(CustomImageDownloadProgress{
|
||||
Stage: "downloading",
|
||||
DownloadedBytes: downloaded,
|
||||
TotalBytes: total,
|
||||
Percent: percent,
|
||||
})
|
||||
}
|
||||
}
|
||||
if readErr == io.EOF {
|
||||
break
|
||||
}
|
||||
if readErr != nil {
|
||||
return readErr
|
||||
}
|
||||
}
|
||||
return file.Sync()
|
||||
}
|
||||
|
||||
func verifyCustomRootfsSHA256(filePath, expected string) error {
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
hash := sha256.New()
|
||||
if _, err := io.Copy(hash, file); err != nil {
|
||||
return err
|
||||
}
|
||||
actual := hex.EncodeToString(hash.Sum(nil))
|
||||
if !strings.EqualFold(actual, strings.TrimSpace(expected)) {
|
||||
return fmt.Errorf("SHA-256 mismatch: expected %s, got %s", expected, actual)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateCustomRootfsArchive(archivePath string) error {
|
||||
command := exec.Command("tar", "-tf", archivePath)
|
||||
stdout, err := command.StdoutPipe()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var stderr strings.Builder
|
||||
command.Stderr = &stderr
|
||||
if err := command.Start(); err != nil {
|
||||
return fmt.Errorf("failed to inspect rootfs archive: %v", err)
|
||||
}
|
||||
|
||||
scanner := bufio.NewScanner(stdout)
|
||||
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
|
||||
entries := make([]string, 0, 4096)
|
||||
for scanner.Scan() {
|
||||
if len(entries) >= 2_000_000 {
|
||||
_ = command.Process.Kill()
|
||||
return fmt.Errorf("rootfs archive contains too many entries")
|
||||
}
|
||||
entries = append(entries, scanner.Text())
|
||||
}
|
||||
scanErr := scanner.Err()
|
||||
waitErr := command.Wait()
|
||||
if scanErr != nil {
|
||||
return fmt.Errorf("failed to read rootfs archive: %v", scanErr)
|
||||
}
|
||||
if waitErr != nil {
|
||||
return fmt.Errorf("invalid rootfs archive: %v, output: %s", waitErr, strings.TrimSpace(stderr.String()))
|
||||
}
|
||||
return validateCustomRootfsEntries(entries)
|
||||
}
|
||||
|
||||
func validateCustomRootfsEntries(entries []string) error {
|
||||
hasInit := false
|
||||
for _, entry := range entries {
|
||||
entry = strings.TrimSpace(strings.ReplaceAll(entry, "\\", "/"))
|
||||
entry = strings.TrimPrefix(entry, "./")
|
||||
if entry == "" || entry == "." {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(entry, "/") {
|
||||
return fmt.Errorf("rootfs archive contains an absolute path: %s", entry)
|
||||
}
|
||||
clean := path.Clean(entry)
|
||||
if clean == ".." || strings.HasPrefix(clean, "../") {
|
||||
return fmt.Errorf("rootfs archive contains path traversal: %s", entry)
|
||||
}
|
||||
switch strings.TrimSuffix(clean, "/") {
|
||||
case "sbin/init", "usr/lib/systemd/systemd", "lib/systemd/systemd", "bin/busybox", "bin/sh":
|
||||
hasInit = true
|
||||
}
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
return fmt.Errorf("rootfs archive is empty")
|
||||
}
|
||||
if !hasInit {
|
||||
return fmt.Errorf("rootfs archive does not contain a supported init")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ExtractCustomRootfs(templateID, destination string) error {
|
||||
template := FindTemplate(templateID)
|
||||
if template == nil || !template.Custom {
|
||||
return fmt.Errorf("custom LXC image not found: %s", templateID)
|
||||
}
|
||||
archive := CustomImagePath(template.ID)
|
||||
if ok, _ := CustomImageDownloadedInfo(template.ID); !ok {
|
||||
return fmt.Errorf("custom LXC image is not downloaded: %s", templateID)
|
||||
}
|
||||
if err := ValidateCustomRootfsArchive(archive); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(destination, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
output, err := exec.Command("tar", "-xpf", archive, "-C", destination).CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to extract custom LXC rootfs: %v, output: %s", err, strings.TrimSpace(string(output)))
|
||||
}
|
||||
if err := secureExtractedRootfs(destination); err != nil {
|
||||
return err
|
||||
}
|
||||
if !rootfsHasInit(destination) {
|
||||
return fmt.Errorf("extracted custom LXC rootfs is invalid: init not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func secureExtractedRootfs(root string) error {
|
||||
root, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return filepath.WalkDir(root, func(filePath string, entry os.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink == 0 {
|
||||
return nil
|
||||
}
|
||||
target, err := os.Readlink(filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var resolved string
|
||||
if filepath.IsAbs(target) {
|
||||
resolved = filepath.Join(root, strings.TrimLeft(filepath.ToSlash(target), "/"))
|
||||
relative, err := filepath.Rel(filepath.Dir(filePath), resolved)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Remove(filePath); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Symlink(relative, filePath); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
resolved = filepath.Join(filepath.Dir(filePath), target)
|
||||
}
|
||||
relativeToRoot, err := filepath.Rel(root, filepath.Clean(resolved))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if relativeToRoot == ".." || strings.HasPrefix(relativeToRoot, ".."+string(os.PathSeparator)) {
|
||||
return fmt.Errorf("rootfs symlink escapes the archive root: %s -> %s", filePath, target)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package lxc
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"clicd/internal/config"
|
||||
)
|
||||
|
||||
func TestGetTemplatesIncludesHostArchitectureCustomLXCImage(t *testing.T) {
|
||||
previous := config.AppConfig
|
||||
t.Cleanup(func() { config.AppConfig = previous })
|
||||
config.AppConfig = &config.ClicdConfig{CustomLXCImages: []config.CustomLXCImage{
|
||||
{
|
||||
ID: "custom-lxc-host", Name: "Host Rootfs", Distro: "alpine",
|
||||
Release: "3.21", Arch: runtime.GOARCH, URL: "https://example.test/rootfs.tar.xz",
|
||||
},
|
||||
{
|
||||
ID: "custom-lxc-other", Name: "Other Rootfs", Distro: "alpine",
|
||||
Release: "3.21", Arch: "not-" + runtime.GOARCH, URL: "https://example.test/other.tar.xz",
|
||||
},
|
||||
}}
|
||||
|
||||
template := FindTemplate("custom-lxc-host")
|
||||
if template == nil || !template.Custom || template.URL == "" {
|
||||
t.Fatalf("custom LXC template was not exposed correctly: %+v", template)
|
||||
}
|
||||
if FindTemplate("custom-lxc-other") != nil {
|
||||
t.Fatal("custom LXC template for another architecture was exposed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomImagePathUsesAllowlistedID(t *testing.T) {
|
||||
previous := config.AppConfig
|
||||
t.Cleanup(func() { config.AppConfig = previous })
|
||||
config.AppConfig = &config.ClicdConfig{}
|
||||
|
||||
for _, id := range []string{"", ".", "..", "../../etc/passwd", "/absolute", "unknown"} {
|
||||
got := filepath.ToSlash(CustomImagePath(id))
|
||||
if filepath.Base(filepath.Dir(got)) != "__invalid_image_id__" {
|
||||
t.Fatalf("CustomImagePath(%q) = %q", id, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCustomRootfsEntries(t *testing.T) {
|
||||
if err := validateCustomRootfsEntries([]string{"./etc/", "./bin/", "./bin/sh"}); err != nil {
|
||||
t.Fatalf("valid rootfs entries failed: %v", err)
|
||||
}
|
||||
for _, entries := range [][]string{
|
||||
{},
|
||||
{"etc/passwd"},
|
||||
{"/etc/passwd", "bin/sh"},
|
||||
{"../../etc/passwd", "bin/sh"},
|
||||
} {
|
||||
if err := validateCustomRootfsEntries(entries); err == nil {
|
||||
t.Fatalf("unsafe rootfs entries unexpectedly passed: %#v", entries)
|
||||
}
|
||||
}
|
||||
}
|
||||
+89
-26
@@ -503,15 +503,30 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
|
||||
lxcName, id, tmpl.Distro, tmpl.Release, tmpl.Arch)
|
||||
|
||||
cfg.ReportProgress("rootfs", "下载模板并创建基础文件系统")
|
||||
args := []string{"-n", lxcName, "-t", "download", "--",
|
||||
"-d", tmpl.Distro, "-r", tmpl.Release, "-a", tmpl.Arch}
|
||||
if tmpl.Variant != "" {
|
||||
args = append(args, "--variant", tmpl.Variant)
|
||||
}
|
||||
cmd := exec.Command("lxc-create", args...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("lxc-create failed: %v, output: %s", err, string(output))
|
||||
if tmpl.Custom {
|
||||
output, err := exec.Command("lxc-create", "-n", lxcName, "-t", "none").CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("lxc-create failed for custom rootfs: %v, output: %s", err, string(output))
|
||||
}
|
||||
if err := m.configureCustomLXCBase(lxcName, tmpl); err != nil {
|
||||
_ = m.cleanupContainerStorage(lxcName)
|
||||
return err
|
||||
}
|
||||
if err := ExtractCustomRootfs(tmpl.ID, filepath.Join(containerDir, "rootfs")); err != nil {
|
||||
_ = m.cleanupContainerStorage(lxcName)
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
args := []string{"-n", lxcName, "-t", "download", "--",
|
||||
"-d", tmpl.Distro, "-r", tmpl.Release, "-a", tmpl.Arch}
|
||||
if tmpl.Variant != "" {
|
||||
args = append(args, "--variant", tmpl.Variant)
|
||||
}
|
||||
cmd := exec.Command("lxc-create", args...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("lxc-create failed: %v, output: %s", err, string(output))
|
||||
}
|
||||
}
|
||||
|
||||
cfg.ReportProgress("storage", "复制容器数据到存储磁盘")
|
||||
@@ -676,6 +691,41 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) configureCustomLXCBase(lxcName string, tmpl *Template) error {
|
||||
rootfsPath, err := m.safeRootfsPath(filepath.Join(m.LxcPath, lxcName, "rootfs"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid custom LXC rootfs path: %v", err)
|
||||
}
|
||||
configFile := filepath.Join(filepath.Dir(rootfsPath), "config")
|
||||
data, err := os.ReadFile(configFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read custom LXC base config: %v", err)
|
||||
}
|
||||
if _, err := os.Stat("/usr/share/lxc/config/common.conf"); err != nil {
|
||||
return fmt.Errorf("LXC common configuration is unavailable: %v", err)
|
||||
}
|
||||
|
||||
arch := "linux64"
|
||||
switch strings.ToLower(strings.TrimSpace(tmpl.Arch)) {
|
||||
case "amd64", "x86_64", "arm64", "aarch64":
|
||||
default:
|
||||
return fmt.Errorf("unsupported custom LXC architecture: %s", tmpl.Arch)
|
||||
}
|
||||
|
||||
base := []string{
|
||||
"# CLICD custom rootfs base configuration",
|
||||
"lxc.include = /usr/share/lxc/config/common.conf",
|
||||
"lxc.arch = " + arch,
|
||||
"lxc.rootfs.path = dir:" + rootfsPath,
|
||||
"lxc.uts.name = " + lxcName,
|
||||
"",
|
||||
}
|
||||
if err := os.WriteFile(configFile, []byte(strings.Join(base, "\n")+string(data)), 0644); err != nil {
|
||||
return fmt.Errorf("failed to write custom LXC base config: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) preconfigureNetwork(rootfsPath string, cfg ContainerConfig) {
|
||||
templateID := cfg.TemplateID
|
||||
osRelease := ""
|
||||
@@ -1667,6 +1717,9 @@ func appArmorProfileForTemplate(templateID string) (string, error) {
|
||||
|
||||
func systemdTemplateNeedsUnconfinedAppArmor(templateID string) bool {
|
||||
id := strings.ToLower(strings.TrimSpace(templateID))
|
||||
if template := FindTemplate(templateID); template != nil {
|
||||
id += " " + strings.ToLower(template.Distro+" "+template.Release)
|
||||
}
|
||||
if id == "" || strings.Contains(id, "alpine") {
|
||||
return false
|
||||
}
|
||||
@@ -2564,7 +2617,7 @@ if [ -L /etc/resolv.conf ] 2>/dev/null; then
|
||||
fi
|
||||
# Also try resolvectl for systemd-resolved setups
|
||||
if command -v resolvectl >/dev/null 2>&1; then
|
||||
resolvectl dns eth0 10.0.3.1 2>/dev/null || true
|
||||
resolvectl dns eth0 __CLICD_LXC_GATEWAY__ 2>/dev/null || true
|
||||
resolvectl dns eth0 8.8.8.8 2>/dev/null || true
|
||||
resolvectl domain eth0 '~.' 2>/dev/null || true
|
||||
fi
|
||||
@@ -2572,7 +2625,7 @@ fi
|
||||
# Avoid the trap where systemd stub resolver puts "nameserver 127.0.0.53"
|
||||
# but doesn't actually resolve anything.
|
||||
if ! grep -q '^nameserver [1-9]' /etc/resolv.conf 2>/dev/null; then
|
||||
echo "nameserver 10.0.3.1" > /etc/resolv.conf
|
||||
echo "nameserver __CLICD_LXC_GATEWAY__" > /etc/resolv.conf
|
||||
echo "nameserver 8.8.8.8" >> /etc/resolv.conf
|
||||
fi
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
@@ -2709,6 +2762,7 @@ ensure_sshd_runtime_dir
|
||||
}
|
||||
`
|
||||
script = strings.ReplaceAll(script, "__CLICD_PUBKEY_AUTH__", pubkeyValue)
|
||||
script = strings.ReplaceAll(script, "__CLICD_LXC_GATEWAY__", config.LXCNATNetwork().Gateway)
|
||||
if !startService {
|
||||
return script
|
||||
}
|
||||
@@ -3308,23 +3362,32 @@ func (m *Manager) replaceRootfsFromTemplate(lxcName string, tmpl *Template) erro
|
||||
}
|
||||
defer m.cleanupTemporaryContainer(tmpName)
|
||||
|
||||
args := []string{
|
||||
"-n", tmpName,
|
||||
"-t", "download",
|
||||
"--",
|
||||
"-d", tmpl.Distro,
|
||||
"-r", tmpl.Release,
|
||||
"-a", tmpl.Arch,
|
||||
}
|
||||
if tmpl.Variant != "" {
|
||||
args = append(args, "--variant", tmpl.Variant)
|
||||
}
|
||||
output, err := exec.Command("lxc-create", args...).CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to download replacement rootfs: %v, output: %s", err, string(output))
|
||||
tmpRootfs := filepath.Join(tmpDir, "rootfs")
|
||||
if tmpl.Custom {
|
||||
if err := os.MkdirAll(tmpRootfs, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ExtractCustomRootfs(tmpl.ID, tmpRootfs); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
args := []string{
|
||||
"-n", tmpName,
|
||||
"-t", "download",
|
||||
"--",
|
||||
"-d", tmpl.Distro,
|
||||
"-r", tmpl.Release,
|
||||
"-a", tmpl.Arch,
|
||||
}
|
||||
if tmpl.Variant != "" {
|
||||
args = append(args, "--variant", tmpl.Variant)
|
||||
}
|
||||
output, err := exec.Command("lxc-create", args...).CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to download replacement rootfs: %v, output: %s", err, string(output))
|
||||
}
|
||||
}
|
||||
|
||||
tmpRootfs := filepath.Join(tmpDir, "rootfs")
|
||||
if !rootfsHasInit(tmpRootfs) {
|
||||
return fmt.Errorf("downloaded replacement rootfs is invalid: init not found")
|
||||
}
|
||||
|
||||
@@ -122,6 +122,78 @@ func TestNormalizeCreateNATMappingsRejectsManagementPortConflict(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaggedRuleLineNumbersReturnsMatchingRulesDescending(t *testing.T) {
|
||||
output := []byte(`Chain PREROUTING (policy ACCEPT)
|
||||
num target prot opt source destination
|
||||
2 DNAT tcp -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:30080 /* clicd-c12-any-30080 */
|
||||
7 DNAT tcp -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:30081 /* clicd-c13-any-30081 */
|
||||
11 DNAT tcp -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:30082 /* clicd-c12-any-30082 */
|
||||
`)
|
||||
got := taggedRuleLineNumbers(output, "clicd-c12-")
|
||||
want := []int{11, 2}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("taggedRuleLineNumbers() = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPortMappingConntrackDeleteArgs(t *testing.T) {
|
||||
got := portMappingConntrackDeleteArgs(config.PortMapping{
|
||||
HostIP: "203.0.113.10",
|
||||
HostPort: 32022,
|
||||
Protocol: "TCP",
|
||||
})
|
||||
want := []string{"-D", "-p", "tcp", "--dport", "32022", "--dst", "203.0.113.10"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("portMappingConntrackDeleteArgs() = %v, want %v", got, want)
|
||||
}
|
||||
|
||||
if got := portMappingConntrackDeleteArgs(config.PortMapping{HostPort: 32022, Protocol: "icmp"}); got != nil {
|
||||
t.Fatalf("unsupported protocol returned args: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateSSHPortMappingKeepsIdentityAndSynchronizesSSHPort(t *testing.T) {
|
||||
previous := config.AppConfig
|
||||
t.Cleanup(func() { config.AppConfig = previous })
|
||||
config.AppConfig = &config.ClicdConfig{
|
||||
NATPortStart: 30000,
|
||||
NATPortEnd: 65535,
|
||||
Containers: []config.Container{{
|
||||
ID: 12,
|
||||
Name: "ct-test",
|
||||
Status: "stopped",
|
||||
SSHPort: 30022,
|
||||
PortMappings: []config.PortMapping{{
|
||||
HostPort: 30022,
|
||||
ContainerPort: 22,
|
||||
Protocol: "tcp",
|
||||
Description: "SSH",
|
||||
}},
|
||||
}},
|
||||
}
|
||||
|
||||
manager := NewManager()
|
||||
mappings, err := manager.UpdatePortMapping(12, 0, config.PortMapping{
|
||||
HostPort: 31022,
|
||||
ContainerPort: 22,
|
||||
Protocol: "tcp",
|
||||
Description: "renamed",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(mappings) != 1 || mappings[0].Description != "SSH" {
|
||||
t.Fatalf("updated mappings = %+v", mappings)
|
||||
}
|
||||
container := config.FindContainer(12)
|
||||
if container == nil || container.SSHPort != 31022 {
|
||||
t.Fatalf("container after SSH update = %+v", container)
|
||||
}
|
||||
if _, err := manager.DeletePortMapping(12, 0); err == nil {
|
||||
t.Fatal("updated SSH mapping became deletable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReserveCreateNATPortsProtectsConcurrentTasks(t *testing.T) {
|
||||
previous := config.AppConfig
|
||||
t.Cleanup(func() { config.AppConfig = previous })
|
||||
@@ -133,10 +205,12 @@ func TestReserveCreateNATPortsProtectsConcurrentTasks(t *testing.T) {
|
||||
|
||||
createNATReservationMu.Lock()
|
||||
createNATReservations = map[uint64][]config.PortMapping{}
|
||||
queuedCreateNATReservations = map[string][]config.PortMapping{}
|
||||
createNATReservationMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
createNATReservationMu.Lock()
|
||||
createNATReservations = map[uint64][]config.PortMapping{}
|
||||
queuedCreateNATReservations = map[string][]config.PortMapping{}
|
||||
createNATReservationMu.Unlock()
|
||||
})
|
||||
|
||||
@@ -181,6 +255,109 @@ func TestReserveCreateNATPortsProtectsConcurrentTasks(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReserveBatchCreateNATPortsPlansAllAutomaticPorts(t *testing.T) {
|
||||
previous := config.AppConfig
|
||||
t.Cleanup(func() { config.AppConfig = previous })
|
||||
config.AppConfig = &config.ClicdConfig{
|
||||
NATPortStart: 30000,
|
||||
NATPortEnd: 30010,
|
||||
NextSSHPort: 30001,
|
||||
}
|
||||
|
||||
createNATReservationMu.Lock()
|
||||
createNATReservations = map[uint64][]config.PortMapping{}
|
||||
queuedCreateNATReservations = map[string][]config.PortMapping{}
|
||||
createNATReservationMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
createNATReservationMu.Lock()
|
||||
createNATReservations = map[uint64][]config.PortMapping{}
|
||||
queuedCreateNATReservations = map[string][]config.PortMapping{}
|
||||
createNATReservationMu.Unlock()
|
||||
})
|
||||
|
||||
configs := []ContainerConfig{
|
||||
{Name: "batch-1", PortMappingCount: 2},
|
||||
{Name: "batch-2", PortMappingCount: 2},
|
||||
}
|
||||
for i := range configs {
|
||||
if err := configs[i].NormalizeCreateNATMappings(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
planned, err := ReserveBatchCreateNATPorts(configs)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
used := map[int]string{}
|
||||
for _, cfg := range planned {
|
||||
if cfg.ManagementPort == 0 {
|
||||
t.Fatalf("%s has no planned management port", cfg.Name)
|
||||
}
|
||||
if len(cfg.NATPortMappings) != 1 {
|
||||
t.Fatalf("%s automatic mappings = %d, want 1", cfg.Name, len(cfg.NATPortMappings))
|
||||
}
|
||||
for _, port := range []int{cfg.ManagementPort, cfg.NATPortMappings[0].HostPort} {
|
||||
if owner := used[port]; owner != "" {
|
||||
t.Fatalf("planned port %d is shared by %s and %s", port, owner, cfg.Name)
|
||||
}
|
||||
used[port] = cfg.Name
|
||||
}
|
||||
}
|
||||
|
||||
for _, cfg := range planned {
|
||||
port, release, err := ReserveCreateNATPorts(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("%s could not claim its queued reservation: %v", cfg.Name, err)
|
||||
}
|
||||
if port != cfg.ManagementPort {
|
||||
t.Fatalf("%s claimed management port %d, want %d", cfg.Name, port, cfg.ManagementPort)
|
||||
}
|
||||
release()
|
||||
}
|
||||
if len(queuedCreateNATReservations) != 0 {
|
||||
t.Fatalf("queued reservations remain after claim: %v", queuedCreateNATReservations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReserveBatchCreateNATPortsRejectsWholeConflictingBatch(t *testing.T) {
|
||||
previous := config.AppConfig
|
||||
t.Cleanup(func() { config.AppConfig = previous })
|
||||
config.AppConfig = &config.ClicdConfig{
|
||||
NATPortStart: 30000,
|
||||
NATPortEnd: 30010,
|
||||
NextSSHPort: 30001,
|
||||
}
|
||||
|
||||
createNATReservationMu.Lock()
|
||||
createNATReservations = map[uint64][]config.PortMapping{}
|
||||
queuedCreateNATReservations = map[string][]config.PortMapping{}
|
||||
createNATReservationMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
createNATReservationMu.Lock()
|
||||
createNATReservations = map[uint64][]config.PortMapping{}
|
||||
queuedCreateNATReservations = map[string][]config.PortMapping{}
|
||||
createNATReservationMu.Unlock()
|
||||
})
|
||||
|
||||
configs := []ContainerConfig{
|
||||
{Name: "batch-1", NATPortMappings: []config.PortMapping{{HostPort: 30005, ContainerPort: 80, Protocol: "tcp"}}},
|
||||
{Name: "batch-2", NATPortMappings: []config.PortMapping{{HostPort: 30005, ContainerPort: 8080, Protocol: "tcp"}}},
|
||||
}
|
||||
for i := range configs {
|
||||
if err := configs[i].NormalizeCreateNATMappings(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := ReserveBatchCreateNATPorts(configs); err == nil {
|
||||
t.Fatal("conflicting batch was accepted")
|
||||
}
|
||||
if len(queuedCreateNATReservations) != 0 {
|
||||
t.Fatalf("conflicting batch left partial reservations: %v", queuedCreateNATReservations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootfsCommandRejectsUnmanagedCommand(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
rootfs := filepath.Join(base, "ct-1", "rootfs")
|
||||
|
||||
+376
-40
@@ -1,9 +1,12 @@
|
||||
package lxc
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -12,9 +15,10 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
createNATReservationMu sync.Mutex
|
||||
createNATReservationNextID uint64
|
||||
createNATReservations = map[uint64][]config.PortMapping{}
|
||||
createNATReservationMu sync.Mutex
|
||||
createNATReservationNextID uint64
|
||||
createNATReservations = map[uint64][]config.PortMapping{}
|
||||
queuedCreateNATReservations = map[string][]config.PortMapping{}
|
||||
)
|
||||
|
||||
// ApplyPortMappings applies iptables DNAT rules for a container's port mappings
|
||||
@@ -29,14 +33,16 @@ func (m *Manager) ApplyPortMappings(id int) error {
|
||||
EnsureAssignedPublicIPv4s(c.PublicIPv4s)
|
||||
tag := clicdTag(id)
|
||||
bridge := "lxcbr0"
|
||||
subnet := "10.0.3.0/24"
|
||||
subnet := config.LXCNATNetwork().Subnet
|
||||
if c.IsKVM() {
|
||||
bridge = "virbr0"
|
||||
subnet = "192.168.122.0/24"
|
||||
subnet = config.KVMNATNetwork().Subnet
|
||||
}
|
||||
|
||||
EnsureForwardRules(bridge)
|
||||
m.CleanPortMappings(id)
|
||||
if err := m.CleanPortMappings(id); err != nil {
|
||||
return fmt.Errorf("clean existing port mappings for container %d: %w", id, err)
|
||||
}
|
||||
deleteBridgeMasquerade(subnet)
|
||||
|
||||
for _, pm := range c.PortMappings {
|
||||
@@ -243,9 +249,13 @@ func clicdTag(id int) string { return "c" + strconv.Itoa(id) }
|
||||
|
||||
func EnsureAllRunningPortMappings() {
|
||||
m := NewManager()
|
||||
m.cleanOrphanedPortMappings()
|
||||
for i := range config.AppConfig.Containers {
|
||||
c := &config.AppConfig.Containers[i]
|
||||
if c.Status != "running" || strings.TrimSpace(c.IP) == "" {
|
||||
if err := m.CleanPortMappings(c.ID); err != nil {
|
||||
fmt.Printf("Warning: failed to clean inactive port mappings for %s: %v\n", c.Name, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := m.ApplyPortMappings(c.ID); err != nil {
|
||||
@@ -254,6 +264,33 @@ func EnsureAllRunningPortMappings() {
|
||||
}
|
||||
}
|
||||
|
||||
var taggedContainerIDPattern = regexp.MustCompile(`clicd-c([0-9]+)-`)
|
||||
|
||||
func (m *Manager) cleanOrphanedPortMappings() {
|
||||
output, err := exec.Command("iptables-save").Output()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
configured := make(map[int]bool, len(config.AppConfig.Containers))
|
||||
for i := range config.AppConfig.Containers {
|
||||
configured[config.AppConfig.Containers[i].ID] = true
|
||||
}
|
||||
seen := map[int]bool{}
|
||||
for _, match := range taggedContainerIDPattern.FindAllSubmatch(output, -1) {
|
||||
if len(match) < 2 {
|
||||
continue
|
||||
}
|
||||
id, err := strconv.Atoi(string(match[1]))
|
||||
if err != nil || configured[id] || seen[id] {
|
||||
continue
|
||||
}
|
||||
seen[id] = true
|
||||
if err := m.CleanPortMappings(id); err != nil {
|
||||
fmt.Printf("Warning: failed to clean orphaned port mappings for container %d: %v\n", id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// EnsureForwardRules makes sure iptables FORWARD chain allows bridge traffic.
|
||||
func EnsureForwardRules(bridge string) {
|
||||
if bridge == "" {
|
||||
@@ -306,16 +343,108 @@ func ensureLibvirtForwardRules(bridge string) {
|
||||
|
||||
// CleanPortMappings removes all iptables rules for a container
|
||||
func (m *Manager) CleanPortMappings(id int) error {
|
||||
tag := clicdTag(id)
|
||||
for _, chain := range []string{"PREROUTING", "POSTROUTING"} {
|
||||
cmd := exec.Command("sh", "-c",
|
||||
fmt.Sprintf("iptables -t nat -L %s -n --line-numbers 2>/dev/null | grep 'clicd-%s-' | awk '{print $1}' | sort -rn | while read num; do iptables -t nat -D %s $num; done", chain, tag, chain))
|
||||
cmd.Run()
|
||||
marker := "clicd-" + clicdTag(id) + "-"
|
||||
var cleanupErrors []error
|
||||
for _, target := range []struct {
|
||||
table string
|
||||
chain string
|
||||
}{
|
||||
{table: "nat", chain: "PREROUTING"},
|
||||
{table: "nat", chain: "POSTROUTING"},
|
||||
{chain: "FORWARD"},
|
||||
} {
|
||||
if err := deleteTaggedIPTablesRules(target.table, target.chain, marker); err != nil {
|
||||
cleanupErrors = append(cleanupErrors, err)
|
||||
}
|
||||
}
|
||||
cmd := exec.Command("sh", "-c",
|
||||
fmt.Sprintf("iptables -S FORWARD 2>/dev/null | grep 'clicd-%s-' | sed 's/^-A /-D /' | while read rule; do iptables $rule; done", tag))
|
||||
cmd.Run()
|
||||
return nil
|
||||
if c := config.FindContainer(id); c != nil {
|
||||
for _, mapping := range c.PortMappings {
|
||||
clearPortMappingConntrack(mapping)
|
||||
}
|
||||
}
|
||||
return errors.Join(cleanupErrors...)
|
||||
}
|
||||
|
||||
func deleteTaggedIPTablesRules(table, chain, marker string) error {
|
||||
listArgs := []string{"-w", "5"}
|
||||
if table != "" {
|
||||
listArgs = append(listArgs, "-t", table)
|
||||
}
|
||||
listArgs = append(listArgs, "-L", chain, "-n", "--line-numbers")
|
||||
output, err := exec.Command("iptables", listArgs...).CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("list iptables %s/%s: %w: %s", tableName(table), chain, err, strings.TrimSpace(string(output)))
|
||||
}
|
||||
|
||||
var deleteErrors []error
|
||||
for _, lineNumber := range taggedRuleLineNumbers(output, marker) {
|
||||
deleteArgs := []string{"-w", "5"}
|
||||
if table != "" {
|
||||
deleteArgs = append(deleteArgs, "-t", table)
|
||||
}
|
||||
deleteArgs = append(deleteArgs, "-D", chain, strconv.Itoa(lineNumber))
|
||||
if output, err := exec.Command("iptables", deleteArgs...).CombinedOutput(); err != nil {
|
||||
deleteErrors = append(deleteErrors, fmt.Errorf(
|
||||
"delete iptables %s/%s rule %d: %w: %s",
|
||||
tableName(table), chain, lineNumber, err, strings.TrimSpace(string(output)),
|
||||
))
|
||||
}
|
||||
}
|
||||
return errors.Join(deleteErrors...)
|
||||
}
|
||||
|
||||
func taggedRuleLineNumbers(output []byte, marker string) []int {
|
||||
lineNumbers := make([]int, 0)
|
||||
for _, line := range strings.Split(string(output), "\n") {
|
||||
if !strings.Contains(line, marker) {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) == 0 {
|
||||
continue
|
||||
}
|
||||
lineNumber, err := strconv.Atoi(fields[0])
|
||||
if err == nil && lineNumber > 0 {
|
||||
lineNumbers = append(lineNumbers, lineNumber)
|
||||
}
|
||||
}
|
||||
sort.Sort(sort.Reverse(sort.IntSlice(lineNumbers)))
|
||||
return lineNumbers
|
||||
}
|
||||
|
||||
func tableName(table string) string {
|
||||
if table == "" {
|
||||
return "filter"
|
||||
}
|
||||
return table
|
||||
}
|
||||
|
||||
func clearPortMappingConntrack(mapping config.PortMapping) {
|
||||
args := portMappingConntrackDeleteArgs(mapping)
|
||||
if len(args) == 0 {
|
||||
return
|
||||
}
|
||||
// conntrack exits non-zero when no matching flow exists; that is already clean.
|
||||
_ = exec.Command("conntrack", args...).Run()
|
||||
}
|
||||
|
||||
func portMappingConntrackDeleteArgs(mapping config.PortMapping) []string {
|
||||
protocol := strings.ToLower(strings.TrimSpace(mapping.Protocol))
|
||||
if protocol != "tcp" && protocol != "udp" {
|
||||
return nil
|
||||
}
|
||||
if mapping.HostPort < 1 || mapping.HostPort > 65535 {
|
||||
return nil
|
||||
}
|
||||
args := []string{
|
||||
"-D",
|
||||
"-p", protocol,
|
||||
"--dport", strconv.Itoa(mapping.HostPort),
|
||||
}
|
||||
if hostIP := strings.TrimSpace(mapping.HostIP); hostIP != "" {
|
||||
args = append(args, "--dst", hostIP)
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
// SetupDefaultPortMappings creates default port mappings
|
||||
@@ -368,14 +497,19 @@ func (m *Manager) UpdatePortMapping(id int, index int, pm config.PortMapping) ([
|
||||
if index < 0 || index >= len(c.PortMappings) {
|
||||
return nil, fmt.Errorf("invalid port mapping index: %d", index)
|
||||
}
|
||||
existing := c.PortMappings[index]
|
||||
normalized, err := normalizePortMapping(c, index, pm)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.EqualFold(existing.Description, "SSH") {
|
||||
normalized.Description = "SSH"
|
||||
}
|
||||
c.PortMappings[index] = normalized
|
||||
if err := persistAndReloadMappings(m, c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clearPortMappingConntrack(existing)
|
||||
return c.PortMappings, nil
|
||||
}
|
||||
|
||||
@@ -388,17 +522,20 @@ func (m *Manager) DeletePortMapping(id int, index int) ([]config.PortMapping, er
|
||||
if index < 0 || index >= len(c.PortMappings) {
|
||||
return nil, fmt.Errorf("invalid port mapping index: %d", index)
|
||||
}
|
||||
if c.PortMappings[index].Description == "SSH" {
|
||||
removed := c.PortMappings[index]
|
||||
if strings.EqualFold(removed.Description, "SSH") {
|
||||
return nil, fmt.Errorf("SSH default mapping cannot be deleted")
|
||||
}
|
||||
c.PortMappings = append(c.PortMappings[:index], c.PortMappings[index+1:]...)
|
||||
if err := persistAndReloadMappings(m, c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clearPortMappingConntrack(removed)
|
||||
return c.PortMappings, nil
|
||||
}
|
||||
|
||||
func persistAndReloadMappings(m *Manager, c *config.Container) error {
|
||||
syncContainerSSHPort(c)
|
||||
config.SaveConfig()
|
||||
if c.Status == "running" && c.IP != "" {
|
||||
return m.ApplyPortMappings(c.ID)
|
||||
@@ -406,6 +543,18 @@ func persistAndReloadMappings(m *Manager, c *config.Container) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func syncContainerSSHPort(c *config.Container) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
for _, mapping := range c.PortMappings {
|
||||
if strings.EqualFold(mapping.Description, "SSH") {
|
||||
c.SSHPort = mapping.HostPort
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) UpdatePublicIPv4Assignments(id int, requested []string, count int, auto bool) (*config.Container, error) {
|
||||
c := config.FindContainer(id)
|
||||
if c == nil {
|
||||
@@ -553,32 +702,25 @@ func ReserveCreateNATPorts(cfg ContainerConfig) (int, func(), error) {
|
||||
createNATReservationMu.Lock()
|
||||
defer createNATReservationMu.Unlock()
|
||||
|
||||
owner := createNATReservationOwner(cfg.Name)
|
||||
requestedReservations := createNATReservationMappings(cfg, cfg.ManagementPort)
|
||||
if queued, ok := queuedCreateNATReservations[owner]; ok {
|
||||
if !sameCreateNATReservations(queued, requestedReservations) {
|
||||
return 0, nil, fmt.Errorf("queued NAT port plan for %s no longer matches the create task", cfg.Name)
|
||||
}
|
||||
delete(queuedCreateNATReservations, owner)
|
||||
return activateCreateNATReservationLocked(cfg.ManagementPort, queued)
|
||||
}
|
||||
|
||||
if err := ValidateCreateNATPortAvailability(cfg); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
requestedReservations := append([]config.PortMapping(nil), cfg.NATPortMappings...)
|
||||
if cfg.ManagementPort > 0 {
|
||||
requestedReservations = append(requestedReservations, config.PortMapping{
|
||||
HostPort: cfg.ManagementPort,
|
||||
Protocol: "tcp",
|
||||
})
|
||||
}
|
||||
for _, requested := range requestedReservations {
|
||||
for _, reservations := range createNATReservations {
|
||||
for _, reserved := range reservations {
|
||||
if requested.HostPort == reserved.HostPort && protocolsOverlap(requested.Protocol, reserved.Protocol) {
|
||||
return 0, nil, fmt.Errorf("NAT host port %d/%s is reserved by another create task", requested.HostPort, requested.Protocol)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := validateCreateNATReservationsAvailableLocked(requestedReservations, owner); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
|
||||
excluded := cfg.RequestedNATHostPorts()
|
||||
for _, reservations := range createNATReservations {
|
||||
for _, reserved := range reservations {
|
||||
excluded = append(excluded, reserved.HostPort)
|
||||
}
|
||||
}
|
||||
excluded = append(excluded, allReservedCreateNATHostPortsLocked(owner)...)
|
||||
managementPort := cfg.ManagementPort
|
||||
if managementPort == 0 {
|
||||
var err error
|
||||
@@ -588,12 +730,96 @@ func ReserveCreateNATPorts(cfg ContainerConfig) (int, func(), error) {
|
||||
}
|
||||
}
|
||||
|
||||
reservations := createNATReservationMappings(cfg, managementPort)
|
||||
return activateCreateNATReservationLocked(managementPort, reservations)
|
||||
}
|
||||
|
||||
// ReserveBatchCreateNATPorts resolves every automatic NAT port and reserves
|
||||
// the complete batch before any create task is enqueued.
|
||||
func ReserveBatchCreateNATPorts(configs []ContainerConfig) ([]ContainerConfig, error) {
|
||||
createNATReservationMu.Lock()
|
||||
defer createNATReservationMu.Unlock()
|
||||
|
||||
planned := append([]ContainerConfig(nil), configs...)
|
||||
addedOwners := make([]string, 0, len(planned))
|
||||
rollback := func() {
|
||||
for _, owner := range addedOwners {
|
||||
delete(queuedCreateNATReservations, owner)
|
||||
}
|
||||
}
|
||||
|
||||
for i := range planned {
|
||||
cfg := &planned[i]
|
||||
cfg.NATPortMappings = append([]config.PortMapping(nil), cfg.NATPortMappings...)
|
||||
if !cfg.WantsNAT() {
|
||||
continue
|
||||
}
|
||||
owner := createNATReservationOwner(cfg.Name)
|
||||
if owner == "" {
|
||||
rollback()
|
||||
return nil, fmt.Errorf("container name is required for NAT port reservation")
|
||||
}
|
||||
if _, exists := queuedCreateNATReservations[owner]; exists {
|
||||
rollback()
|
||||
return nil, fmt.Errorf("container creation already has reserved NAT ports: %s", cfg.Name)
|
||||
}
|
||||
if err := ValidateCreateNATPortAvailability(*cfg); err != nil {
|
||||
rollback()
|
||||
return nil, fmt.Errorf("%s: %w", cfg.Name, err)
|
||||
}
|
||||
|
||||
explicit := createNATReservationMappings(*cfg, cfg.ManagementPort)
|
||||
if err := validateCreateNATReservationsAvailableLocked(explicit, owner); err != nil {
|
||||
rollback()
|
||||
return nil, fmt.Errorf("%s: %w", cfg.Name, err)
|
||||
}
|
||||
|
||||
excluded := cfg.RequestedNATHostPorts()
|
||||
excluded = append(excluded, allReservedCreateNATHostPortsLocked(owner)...)
|
||||
if cfg.ManagementPort == 0 {
|
||||
port, err := config.AllocateSSHPortExcluding(excluded)
|
||||
if err != nil {
|
||||
rollback()
|
||||
return nil, fmt.Errorf("%s: %w", cfg.Name, err)
|
||||
}
|
||||
cfg.ManagementPort = port
|
||||
}
|
||||
|
||||
if len(cfg.NATPortMappings) == 0 && cfg.PortMappingCount > 1 {
|
||||
generated, err := planDefaultCreateNATMappingsLocked(*cfg, cfg.PortMappingCount-1, owner)
|
||||
if err != nil {
|
||||
rollback()
|
||||
return nil, fmt.Errorf("%s: %w", cfg.Name, err)
|
||||
}
|
||||
cfg.NATPortMappings = generated
|
||||
cfg.PortMappingCount = len(generated) + 1
|
||||
}
|
||||
|
||||
reservations := createNATReservationMappings(*cfg, cfg.ManagementPort)
|
||||
if err := validateCreateNATReservationsAvailableLocked(reservations, owner); err != nil {
|
||||
rollback()
|
||||
return nil, fmt.Errorf("%s: %w", cfg.Name, err)
|
||||
}
|
||||
queuedCreateNATReservations[owner] = reservations
|
||||
addedOwners = append(addedOwners, owner)
|
||||
}
|
||||
return planned, nil
|
||||
}
|
||||
|
||||
func ReleaseQueuedCreateNATPorts(name string) {
|
||||
owner := createNATReservationOwner(name)
|
||||
if owner == "" {
|
||||
return
|
||||
}
|
||||
createNATReservationMu.Lock()
|
||||
delete(queuedCreateNATReservations, owner)
|
||||
createNATReservationMu.Unlock()
|
||||
}
|
||||
|
||||
func activateCreateNATReservationLocked(managementPort int, reservations []config.PortMapping) (int, func(), error) {
|
||||
createNATReservationNextID++
|
||||
reservationID := createNATReservationNextID
|
||||
reservations := make([]config.PortMapping, 0, len(cfg.NATPortMappings)+1)
|
||||
reservations = append(reservations, config.PortMapping{HostPort: managementPort, Protocol: "tcp"})
|
||||
reservations = append(reservations, cfg.NATPortMappings...)
|
||||
createNATReservations[reservationID] = reservations
|
||||
createNATReservations[reservationID] = append([]config.PortMapping(nil), reservations...)
|
||||
|
||||
var once sync.Once
|
||||
release := func() {
|
||||
@@ -606,6 +832,116 @@ func ReserveCreateNATPorts(cfg ContainerConfig) (int, func(), error) {
|
||||
return managementPort, release, nil
|
||||
}
|
||||
|
||||
func createNATReservationOwner(name string) string {
|
||||
return strings.ToLower(strings.TrimSpace(name))
|
||||
}
|
||||
|
||||
func createNATReservationMappings(cfg ContainerConfig, managementPort int) []config.PortMapping {
|
||||
reservations := make([]config.PortMapping, 0, len(cfg.NATPortMappings)+1)
|
||||
if managementPort > 0 {
|
||||
reservations = append(reservations, config.PortMapping{HostPort: managementPort, Protocol: "tcp"})
|
||||
}
|
||||
reservations = append(reservations, cfg.NATPortMappings...)
|
||||
return reservations
|
||||
}
|
||||
|
||||
func validateCreateNATReservationsAvailableLocked(requested []config.PortMapping, exceptOwner string) error {
|
||||
for _, candidate := range requested {
|
||||
for _, reservations := range createNATReservations {
|
||||
if conflictingCreateNATReservation(candidate, reservations) {
|
||||
return fmt.Errorf("NAT host port %d/%s is reserved by another create task", candidate.HostPort, candidate.Protocol)
|
||||
}
|
||||
}
|
||||
for owner, reservations := range queuedCreateNATReservations {
|
||||
if owner == exceptOwner {
|
||||
continue
|
||||
}
|
||||
if conflictingCreateNATReservation(candidate, reservations) {
|
||||
return fmt.Errorf("NAT host port %d/%s is reserved by queued create task %s", candidate.HostPort, candidate.Protocol, owner)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func conflictingCreateNATReservation(candidate config.PortMapping, reservations []config.PortMapping) bool {
|
||||
for _, reserved := range reservations {
|
||||
if candidate.HostPort == reserved.HostPort && protocolsOverlap(candidate.Protocol, reserved.Protocol) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func allReservedCreateNATHostPortsLocked(exceptOwner string) []int {
|
||||
ports := make([]int, 0)
|
||||
for _, reservations := range createNATReservations {
|
||||
for _, reserved := range reservations {
|
||||
ports = append(ports, reserved.HostPort)
|
||||
}
|
||||
}
|
||||
for owner, reservations := range queuedCreateNATReservations {
|
||||
if owner == exceptOwner {
|
||||
continue
|
||||
}
|
||||
for _, reserved := range reservations {
|
||||
ports = append(ports, reserved.HostPort)
|
||||
}
|
||||
}
|
||||
return ports
|
||||
}
|
||||
|
||||
func planDefaultCreateNATMappingsLocked(cfg ContainerConfig, count int, owner string) ([]config.PortMapping, error) {
|
||||
if count <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
unavailable := map[int]bool{cfg.ManagementPort: true}
|
||||
for _, port := range allReservedCreateNATHostPortsLocked(owner) {
|
||||
unavailable[port] = true
|
||||
}
|
||||
for _, mapping := range cfg.NATPortMappings {
|
||||
unavailable[mapping.HostPort] = true
|
||||
}
|
||||
|
||||
candidate := &config.Container{ID: -1}
|
||||
start, end := config.NATPortRange()
|
||||
mappings := make([]config.PortMapping, 0, count)
|
||||
for port := start; port <= end && len(mappings) < count; port++ {
|
||||
if unavailable[port] || !HostPortAvailable(candidate, "", port, "tcp") {
|
||||
continue
|
||||
}
|
||||
unavailable[port] = true
|
||||
mappings = append(mappings, config.PortMapping{
|
||||
HostPort: port,
|
||||
ContainerPort: port,
|
||||
Protocol: "tcp",
|
||||
Description: fmt.Sprintf("Port-%d", port),
|
||||
})
|
||||
}
|
||||
if len(mappings) != count {
|
||||
return nil, fmt.Errorf("not enough free NAT4 host ports for %d automatic mappings", count)
|
||||
}
|
||||
return mappings, nil
|
||||
}
|
||||
|
||||
func sameCreateNATReservations(left, right []config.PortMapping) bool {
|
||||
if len(left) != len(right) {
|
||||
return false
|
||||
}
|
||||
counts := make(map[string]int, len(left))
|
||||
for _, mapping := range left {
|
||||
counts[fmt.Sprintf("%d/%s", mapping.HostPort, strings.ToLower(mapping.Protocol))]++
|
||||
}
|
||||
for _, mapping := range right {
|
||||
key := fmt.Sprintf("%d/%s", mapping.HostPort, strings.ToLower(mapping.Protocol))
|
||||
if counts[key] == 0 {
|
||||
return false
|
||||
}
|
||||
counts[key]--
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func allocateDefaultEqualPorts(c *config.Container, count int) []int {
|
||||
if count <= 0 {
|
||||
return nil
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package lxc
|
||||
|
||||
import "runtime"
|
||||
import (
|
||||
"runtime"
|
||||
|
||||
"clicd/internal/config"
|
||||
)
|
||||
|
||||
// Template represents an LXC image template
|
||||
type Template struct {
|
||||
@@ -11,12 +15,15 @@ type Template struct {
|
||||
Arch string `json:"arch"`
|
||||
Variant string `json:"variant"`
|
||||
Description string `json:"description"`
|
||||
URL string `json:"url,omitempty"`
|
||||
SHA256 string `json:"sha256,omitempty"`
|
||||
Custom bool `json:"custom,omitempty"`
|
||||
}
|
||||
|
||||
// GetTemplates returns available LXC image templates (only verified working ones)
|
||||
func GetTemplates() []Template {
|
||||
arch := defaultTemplateArch()
|
||||
return []Template{
|
||||
templates := []Template{
|
||||
{
|
||||
ID: "ubuntu-noble", Name: "Ubuntu 24.04",
|
||||
Distro: "ubuntu", Release: "noble", Arch: arch,
|
||||
@@ -68,6 +75,23 @@ func GetTemplates() []Template {
|
||||
Description: "Rocky Linux 10",
|
||||
},
|
||||
}
|
||||
for _, custom := range config.ListCustomLXCImages() {
|
||||
if custom.Arch != arch {
|
||||
continue
|
||||
}
|
||||
templates = append(templates, Template{
|
||||
ID: custom.ID,
|
||||
Name: custom.Name,
|
||||
Distro: custom.Distro,
|
||||
Release: custom.Release,
|
||||
Arch: custom.Arch,
|
||||
Description: custom.Description,
|
||||
URL: custom.URL,
|
||||
SHA256: custom.SHA256,
|
||||
Custom: true,
|
||||
})
|
||||
}
|
||||
return templates
|
||||
}
|
||||
|
||||
func defaultTemplateArch() string {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"clicd/internal/config"
|
||||
)
|
||||
|
||||
func panelAccessMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
decision := config.EvaluatePanelAccess(
|
||||
config.AppConfig.PanelAccessPolicy,
|
||||
r.RemoteAddr,
|
||||
config.ForwardedClientHeaders{
|
||||
ForwardedFor: r.Header.Get("X-Forwarded-For"),
|
||||
RealIP: r.Header.Get("X-Real-IP"),
|
||||
CFConnectingIP: r.Header.Get("CF-Connecting-IP"),
|
||||
},
|
||||
)
|
||||
if decision.Allowed {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
if strings.HasPrefix(r.URL.Path, "/api/") {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"success": false,
|
||||
"message": "Access denied by panel source policy",
|
||||
})
|
||||
return
|
||||
}
|
||||
http.Error(w, "Access denied by panel source policy", http.StatusForbidden)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"clicd/internal/config"
|
||||
)
|
||||
|
||||
func TestPanelAccessMiddleware(t *testing.T) {
|
||||
previous := config.AppConfig
|
||||
config.AppConfig = &config.ClicdConfig{
|
||||
PanelAccessPolicy: config.PanelAccessPolicy{
|
||||
Enabled: true,
|
||||
AllowedSources: []string{"192.0.2.0/24"},
|
||||
TrustedProxies: []string{"10.0.0.1"},
|
||||
},
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
config.AppConfig = previous
|
||||
})
|
||||
|
||||
handler := panelAccessMiddleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
|
||||
allowed := httptest.NewRequest(http.MethodGet, "/api/version", nil)
|
||||
allowed.RemoteAddr = "192.0.2.8:50000"
|
||||
allowedRecorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(allowedRecorder, allowed)
|
||||
if allowedRecorder.Code != http.StatusNoContent {
|
||||
t.Fatalf("allowed status = %d", allowedRecorder.Code)
|
||||
}
|
||||
|
||||
denied := httptest.NewRequest(http.MethodGet, "/api/version", nil)
|
||||
denied.RemoteAddr = "198.51.100.8:50000"
|
||||
deniedRecorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(deniedRecorder, denied)
|
||||
if deniedRecorder.Code != http.StatusForbidden {
|
||||
t.Fatalf("denied status = %d", deniedRecorder.Code)
|
||||
}
|
||||
if got := deniedRecorder.Header().Get("Content-Type"); got != "application/json" {
|
||||
t.Fatalf("denied content type = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -49,11 +49,13 @@ func setupRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/login-logs", corsMiddleware(api.AdminMiddleware(api.HandleLoginLogs)))
|
||||
mux.HandleFunc("/api/ssl", corsMiddleware(api.AdminMiddleware(api.HandleSSLSettings)))
|
||||
mux.HandleFunc("/api/webssh-origins", corsMiddleware(api.AdminMiddleware(api.HandleWebSSHOriginSettings)))
|
||||
mux.HandleFunc("/api/access-policy", corsMiddleware(api.AdminMiddleware(api.HandlePanelAccessPolicy)))
|
||||
mux.HandleFunc("/api/containers", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleContainers))))
|
||||
mux.HandleFunc("/api/containers/list", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleContainerListAlias))))
|
||||
mux.HandleFunc("/api/containers/", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleSingleContainer))))
|
||||
mux.HandleFunc("/api/templates", corsMiddleware(api.AuthMiddleware(api.HandleTemplates)))
|
||||
mux.HandleFunc("/api/images", corsMiddleware(api.AdminMiddleware(api.HandleImages)))
|
||||
mux.HandleFunc("/api/images/custom", corsMiddleware(api.AdminMiddleware(api.HandleCustomKVMImages)))
|
||||
mux.HandleFunc("/api/images/download", corsMiddleware(api.AdminMiddleware(api.HandleImageDownload)))
|
||||
mux.HandleFunc("/api/images/cancel", corsMiddleware(api.AdminMiddleware(api.HandleImageCancel)))
|
||||
mux.HandleFunc("/api/images/delete", corsMiddleware(api.AdminMiddleware(api.HandleImageDelete)))
|
||||
@@ -101,6 +103,7 @@ func setupRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/containers/", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleSingleContainer))))
|
||||
mux.HandleFunc("/api/v1/templates", corsMiddleware(api.AuthMiddleware(api.HandleTemplates)))
|
||||
mux.HandleFunc("/api/v1/images", corsMiddleware(api.AuthMiddleware(api.HandleImages)))
|
||||
mux.HandleFunc("/api/v1/images/custom", corsMiddleware(api.AuthMiddleware(api.HandleCustomKVMImages)))
|
||||
mux.HandleFunc("/api/v1/images/download", corsMiddleware(api.AuthMiddleware(api.HandleImageDownload)))
|
||||
mux.HandleFunc("/api/v1/images/cancel", corsMiddleware(api.AuthMiddleware(api.HandleImageCancel)))
|
||||
mux.HandleFunc("/api/v1/images/delete", corsMiddleware(api.AuthMiddleware(api.HandleImageDelete)))
|
||||
@@ -126,6 +129,7 @@ func setupRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/login-logs", corsMiddleware(api.AuthMiddleware(api.HandleLoginLogs)))
|
||||
mux.HandleFunc("/api/v1/ssl", corsMiddleware(api.AdminMiddleware(api.HandleSSLSettings)))
|
||||
mux.HandleFunc("/api/v1/webssh-origins", corsMiddleware(api.AdminMiddleware(api.HandleWebSSHOriginSettings)))
|
||||
mux.HandleFunc("/api/v1/access-policy", corsMiddleware(api.AdminMiddleware(api.HandlePanelAccessPolicy)))
|
||||
mux.HandleFunc("/api/v1/security/alerts", corsMiddleware(api.AuthMiddleware(api.ScopeMiddleware("security:read", api.HandleSecurityAlerts))))
|
||||
mux.HandleFunc("/api/v1/security/check", corsMiddleware(api.AuthMiddleware(api.ScopeMiddleware("security:check", api.HandleSecurityCheck))))
|
||||
mux.HandleFunc("/api/v1/security/logs", corsMiddleware(api.AuthMiddleware(api.ScopeMiddleware("security:read", api.HandleSecurityLogs))))
|
||||
@@ -192,7 +196,7 @@ func Run() error {
|
||||
|
||||
server := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: mux,
|
||||
Handler: panelAccessMiddleware(mux),
|
||||
}
|
||||
|
||||
if sslEnabled() {
|
||||
|
||||
@@ -27,6 +27,7 @@ func main() {
|
||||
isServerMode := false
|
||||
isCliMode := false
|
||||
noWebAutostart := false
|
||||
isAccessPolicyCommand := len(os.Args) > 1 && os.Args[1] == "access-policy"
|
||||
for _, arg := range os.Args[1:] {
|
||||
if arg == "server" || arg == "-s" || arg == "--server" {
|
||||
isServerMode = true
|
||||
@@ -48,6 +49,14 @@ func main() {
|
||||
}
|
||||
_ = cfg
|
||||
|
||||
if isAccessPolicyCommand {
|
||||
if err := cli.RunAccessPolicyCommand(os.Args[2:]); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Access policy error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if isServerMode || (!isTerminal && !isCliMode) {
|
||||
installShutdownStateCapture()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user