From aed11af105d2beaafbddc79f48f6c038d165405d Mon Sep 17 00:00:00 2001 From: MengMengCode <227010654+MengMengCode@users.noreply.github.com> Date: Mon, 8 Jun 2026 02:23:48 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=BA=86=E4=B8=80=E4=BA=9B?= =?UTF-8?q?=E5=B7=B2=E7=9F=A5=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/api/apikey.go | 127 +++++++++++++++++++++------ backend/internal/api/apikey_test.go | 102 +++++++++++++++++++++ backend/internal/cli/cli.go | 79 ++++++++++++++--- backend/internal/cli/cli_test.go | 54 ++++++++++++ backend/internal/kvm/kvm.go | 55 ++++++++++-- backend/internal/kvm/kvm_test.go | 95 ++++++++++++++++++++ backend/internal/lxc/lxc.go | 90 +++++++++++++++---- backend/internal/lxc/lxc_test.go | 83 +++++++++++++++++ backend/internal/server/web/.gitkeep | 1 + 9 files changed, 626 insertions(+), 60 deletions(-) create mode 100644 backend/internal/api/apikey_test.go create mode 100644 backend/internal/cli/cli_test.go create mode 100644 backend/internal/kvm/kvm_test.go create mode 100644 backend/internal/lxc/lxc_test.go diff --git a/backend/internal/api/apikey.go b/backend/internal/api/apikey.go index 74268ae..bb289dc 100644 --- a/backend/internal/api/apikey.go +++ b/backend/internal/api/apikey.go @@ -2,10 +2,10 @@ package api import ( "crypto/rand" - "crypto/sha256" "crypto/subtle" "encoding/hex" "encoding/json" + "fmt" "net" "net/http" "strconv" @@ -13,6 +13,8 @@ import ( "time" "clicd/internal/config" + + "golang.org/x/crypto/argon2" ) type ApiKey struct { @@ -79,14 +81,23 @@ func createApiKey(w http.ResponseWriter, r *http.Request) { // Generate key: clicd_sk_ + 32 hex chars rawBytes := make([]byte, 16) - rand.Read(rawBytes) + if _, err := rand.Read(rawBytes); err != nil { + jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to generate API key"}) + return + } rawKey := "clicd_sk_" + hex.EncodeToString(rawBytes) + keyHash, err := hashAPIKey(rawKey) + if err != nil { + jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to store API key"}) + return + } + now := time.Now().Format("2006-01-02 15:04:05") key := config.ApiKeyConfig{ ID: generateShortID(), Name: req.Name, - KeyHash: hashKey(rawKey), + KeyHash: keyHash, Prefix: rawKey[:13] + "...", IPWhitelist: strings.TrimSpace(req.IPWhitelist), CreatedAt: now, @@ -114,10 +125,59 @@ func generateShortID() string { return hex.EncodeToString(b) } -// hashKey creates a simple hash for storage (not reversible) -func hashKey(key string) string { - sum := sha256.Sum256([]byte(key)) - return hex.EncodeToString(sum[:]) +const ( + apiKeyHashPrefix = "argon2id" + apiKeyHashTime = uint32(3) + apiKeyHashMemory = uint32(64 * 1024) + apiKeyHashThreads = uint8(1) + apiKeyHashSaltLength = 16 + apiKeyHashKeyLength = uint32(32) +) + +// hashAPIKey stores API keys using a salted slow password-hash style function. +func hashAPIKey(key string) (string, error) { + salt := make([]byte, apiKeyHashSaltLength) + if _, err := rand.Read(salt); err != nil { + return "", err + } + return hashAPIKeyWithSalt(key, salt), nil +} + +func hashAPIKeyWithSalt(key string, salt []byte) string { + digest := argon2.IDKey([]byte(key), salt, apiKeyHashTime, apiKeyHashMemory, apiKeyHashThreads, apiKeyHashKeyLength) + return fmt.Sprintf("%s$v=19$m=%d,t=%d,p=%d$%s$%s", + apiKeyHashPrefix, + apiKeyHashMemory, + apiKeyHashTime, + apiKeyHashThreads, + hex.EncodeToString(salt), + hex.EncodeToString(digest), + ) +} + +func verifyAPIKeyHash(rawKey, storedHash string) bool { + parts := strings.Split(storedHash, "$") + if len(parts) != 5 || parts[0] != apiKeyHashPrefix || parts[1] != "v=19" { + return false + } + var memory, iterations uint32 + var threads uint8 + if _, err := fmt.Sscanf(parts[2], "m=%d,t=%d,p=%d", &memory, &iterations, &threads); err != nil { + return false + } + if memory != apiKeyHashMemory || iterations != apiKeyHashTime || threads != apiKeyHashThreads { + return false + } + salt, err := hex.DecodeString(parts[3]) + if err != nil || len(salt) == 0 { + return false + } + expected, err := hex.DecodeString(parts[4]) + if err != nil || len(expected) == 0 { + return false + } + digest := argon2.IDKey([]byte(rawKey), salt, iterations, memory, threads, uint32(len(expected))) + return subtle.ConstantTimeCompare(digest, expected) == 1 } func legacyHashKey(key string) string { @@ -128,20 +188,36 @@ func legacyHashKey(key string) string { return hex.EncodeToString(b) } -// validateApiKey checks if the given key is valid and IP is allowed -func validateApiKey(rawKey, clientIP string) bool { - hashed := hashKey(rawKey) +func matchApiKey(rawKey string) (idx int, needsRehash bool) { legacyHashed := legacyHashKey(rawKey) - for _, k := range config.AppConfig.ApiKeys { - if subtle.ConstantTimeCompare([]byte(k.KeyHash), []byte(hashed)) == 1 || - subtle.ConstantTimeCompare([]byte(k.KeyHash), []byte(legacyHashed)) == 1 { - if k.IPWhitelist == "" { - return true - } - return isIPAllowed(clientIP, k.IPWhitelist) + for i, k := range config.AppConfig.ApiKeys { + if verifyAPIKeyHash(rawKey, k.KeyHash) { + return i, false + } + if subtle.ConstantTimeCompare([]byte(k.KeyHash), []byte(legacyHashed)) == 1 { + return i, true } } - return false + return -1, false +} + +// validateApiKey checks if the given key is valid and IP is allowed. +func validateApiKey(rawKey, clientIP string) bool { + idx, needsRehash := matchApiKey(rawKey) + if idx < 0 { + return false + } + k := config.AppConfig.ApiKeys[idx] + if k.IPWhitelist != "" && !isIPAllowed(clientIP, k.IPWhitelist) { + return false + } + if needsRehash { + if newHash, err := hashAPIKey(rawKey); err == nil { + config.AppConfig.ApiKeys[idx].KeyHash = newHash + config.SaveConfig() + } + } + return true } func apiKeyFromRequest(r *http.Request) string { @@ -228,17 +304,14 @@ func ip4ToUint32(ip net.IP) uint32 { return uint32(ip[0])<<24 | uint32(ip[1])<<16 | uint32(ip[2])<<8 | uint32(ip[3]) } -// updateApiKeyLastUsed marks the key as recently used +// updateApiKeyLastUsed marks the key as recently used. func updateApiKeyLastUsed(rawKey string) { - hashed := hashKey(rawKey) - now := time.Now().Format("2006-01-02 15:04:05") - for i := range config.AppConfig.ApiKeys { - if config.AppConfig.ApiKeys[i].KeyHash == hashed { - config.AppConfig.ApiKeys[i].LastUsed = now - config.SaveConfig() - return - } + idx, _ := matchApiKey(rawKey) + if idx < 0 { + return } + config.AppConfig.ApiKeys[idx].LastUsed = time.Now().Format("2006-01-02 15:04:05") + config.SaveConfig() } // ApiKeyMiddleware authenticates requests via X-API-Key header or Authorization bearer. diff --git a/backend/internal/api/apikey_test.go b/backend/internal/api/apikey_test.go new file mode 100644 index 0000000..06257ba --- /dev/null +++ b/backend/internal/api/apikey_test.go @@ -0,0 +1,102 @@ +package api + +import ( + "strings" + "testing" + + "clicd/internal/config" +) + +func TestHashAPIKeyUsesSaltedArgon2idHash(t *testing.T) { + raw := "clicd_sk_0123456789abcdef0123456789abcdef" + + h1, err := hashAPIKey(raw) + if err != nil { + t.Fatal(err) + } + h2, err := hashAPIKey(raw) + if err != nil { + t.Fatal(err) + } + + if h1 == h2 { + t.Fatal("expected salted hashes to differ") + } + if !strings.HasPrefix(h1, apiKeyHashPrefix+"$") || !strings.HasPrefix(h2, apiKeyHashPrefix+"$") { + t.Fatalf("expected argon2id hashes, got %q and %q", h1, h2) + } + if !verifyAPIKeyHash(raw, h1) || !verifyAPIKeyHash(raw, h2) { + t.Fatal("argon2id hashes did not verify") + } + if verifyAPIKeyHash(raw+"x", h1) { + t.Fatal("argon2id hash verified wrong key") + } +} + +func TestValidateApiKeyAllowsArgon2idAndUpdatesLastUsed(t *testing.T) { + raw := "clicd_sk_0123456789abcdef0123456789abcdef" + hash, err := hashAPIKey(raw) + if err != nil { + t.Fatal(err) + } + config.AppConfig = &config.ClicdConfig{ + ApiKeys: []config.ApiKeyConfig{{ + ID: "key1", + Name: "test", + KeyHash: hash, + }}, + } + + if !validateApiKey(raw, "127.0.0.1") { + t.Fatal("validateApiKey rejected valid argon2id key") + } + updateApiKeyLastUsed(raw) + if config.AppConfig.ApiKeys[0].LastUsed == "" { + t.Fatal("LastUsed was not updated") + } +} + +func TestValidateApiKeyMigratesLegacyHash(t *testing.T) { + raw := "clicd_sk_0123456789abcdef0123456789abcdef" + config.AppConfig = &config.ClicdConfig{ + ApiKeys: []config.ApiKeyConfig{{ + ID: "legacy", + Name: "legacy", + KeyHash: legacyHashKey(raw), + }}, + } + + if !validateApiKey(raw, "127.0.0.1") { + t.Fatal("validateApiKey rejected valid legacy key") + } + migrated := config.AppConfig.ApiKeys[0].KeyHash + if migrated == legacyHashKey(raw) { + t.Fatal("legacy key hash was not migrated") + } + if !verifyAPIKeyHash(raw, migrated) { + t.Fatal("migrated key hash does not verify") + } +} + +func TestValidateApiKeyAppliesIPWhitelist(t *testing.T) { + raw := "clicd_sk_0123456789abcdef0123456789abcdef" + hash, err := hashAPIKey(raw) + if err != nil { + t.Fatal(err) + } + config.AppConfig = &config.ClicdConfig{ + ApiKeys: []config.ApiKeyConfig{{ + ID: "key1", + Name: "test", + KeyHash: hash, + IPWhitelist: "192.0.2.10", + }}, + } + + if validateApiKey(raw, "198.51.100.10") { + t.Fatal("validateApiKey allowed disallowed IP") + } + if !validateApiKey(raw, "192.0.2.10") { + t.Fatal("validateApiKey rejected allowed IP") + } +} diff --git a/backend/internal/cli/cli.go b/backend/internal/cli/cli.go index 13f48b7..2238727 100644 --- a/backend/internal/cli/cli.go +++ b/backend/internal/cli/cli.go @@ -20,6 +20,11 @@ import ( var manager = lxc.NewManager() +const ( + clicdBackupDir = "/root/clicd-backups" + clicdNewBinaryPath = "/usr/local/bin/clicd.new" +) + // Run starts the CLI interface. func Run() { reader := bufio.NewReader(os.Stdin) @@ -198,11 +203,18 @@ func cliCreateContainer(reader *bufio.Reader) { container := config.FindContainerByName(name) fmt.Printf("容器 %s 创建成功\n", name) if container != nil { - fmt.Printf("SSH: root / %s, port %d -> 22\n", container.SSHPassword, container.SSHPort) + fmt.Print(formatSSHAccess(container.SSHPort)) } restartWebPanelForConfigChange() } +func formatSSHAccess(sshPort int) string { + if sshPort <= 0 { + return "SSH: root, 端口未分配。密码已保存,请在 Web 面板中查看或重置。\n" + } + return fmt.Sprintf("SSH: root, port %d -> 22。密码已保存,请在 Web 面板中查看或重置。\n", sshPort) +} + func cliStartContainer(reader *bufio.Reader) { id, name := selectContainer(reader, "开机") if id == 0 { @@ -532,13 +544,14 @@ func upgradeFromReleaseAsset(assetURL, latest string) error { return err } - backupDir := "/root/clicd-backups" + backupDir := clicdBackupDir if err := os.MkdirAll(backupDir, 0700); err != nil { return err } - backupPath := filepath.Join(backupDir, fmt.Sprintf("clicd.%s.%s", strings.TrimPrefix(latest, "v"), time.Now().Format("20060102-150405"))) + backupName := fmt.Sprintf("clicd.%s.%s", safeReleaseBackupComponent(latest), time.Now().Format("20060102-150405")) if _, err := os.Stat("/usr/local/bin/clicd"); err == nil { - if err := copyFile("/usr/local/bin/clicd", backupPath, 0755); err != nil { + backupPath, err := copyFileToBackup("/usr/local/bin/clicd", backupName, 0755) + if err != nil { return fmt.Errorf("备份旧二进制失败: %w", err) } fmt.Printf("旧版本已备份: %s\n", backupPath) @@ -548,8 +561,8 @@ func upgradeFromReleaseAsset(assetURL, latest string) error { if err := stopService("clicd"); err != nil { fmt.Printf("停止 Web 服务失败,继续尝试替换: %v\n", err) } - tmpBin := "/usr/local/bin/clicd.new" - if err := copyFile(newBinary, tmpBin, 0755); err != nil { + tmpBin := clicdNewBinaryPath + if err := copyFileToUpgradeTemp(newBinary, 0755); err != nil { return err } if err := os.Rename(tmpBin, "/usr/local/bin/clicd"); err != nil { @@ -614,25 +627,69 @@ func findFile(root, name string) (string, error) { return found, nil } -func copyFile(src, dst string, mode os.FileMode) error { +func copyFileToBackup(src, fileName string, mode os.FileMode) (string, error) { + if fileName == "" || strings.Contains(fileName, "/") || strings.Contains(fileName, "\\") || strings.Contains(fileName, "..") { + return "", fmt.Errorf("unsafe backup file name: %s", fileName) + } + dst := filepath.Join(clicdBackupDir, fileName) + out, err := os.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode) + if err != nil { + return "", err + } + if err := copyIntoOpenFile(src, out, mode); err != nil { + return "", err + } + return dst, nil +} + +func copyFileToUpgradeTemp(src string, mode os.FileMode) error { + out, err := os.OpenFile(clicdNewBinaryPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode) + if err != nil { + return err + } + return copyIntoOpenFile(src, out, mode) +} + +func copyIntoOpenFile(src string, out *os.File, mode os.FileMode) error { in, err := os.Open(src) if err != nil { + out.Close() return err } defer in.Close() - out, err := os.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode) - if err != nil { + if _, err := io.Copy(out, in); err != nil { + out.Close() return err } - if _, err := io.Copy(out, in); err != nil { + if err := out.Chmod(mode); err != nil { out.Close() return err } if err := out.Close(); err != nil { return err } - return os.Chmod(dst, mode) + return nil +} + +func safeReleaseBackupComponent(tag string) string { + tag = strings.TrimPrefix(strings.TrimSpace(tag), "v") + var b strings.Builder + for _, r := range tag { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '.' || r == '_' || r == '-' { + b.WriteRune(r) + continue + } + b.WriteByte('_') + } + component := strings.Trim(b.String(), "._-") + if component == "" { + return "unknown" + } + if len(component) > 64 { + return component[:64] + } + return component } func sameVersion(current, latest string) bool { diff --git a/backend/internal/cli/cli_test.go b/backend/internal/cli/cli_test.go new file mode 100644 index 0000000..3c0cffa --- /dev/null +++ b/backend/internal/cli/cli_test.go @@ -0,0 +1,54 @@ +package cli + +import ( + "strings" + "testing" +) + +func TestSafeReleaseBackupComponent(t *testing.T) { + tests := map[string]string{ + "v1.2.3": "1.2.3", + " release/candidate ": "release_candidate", + "../../etc/passwd": "etc_passwd", + "": "unknown", + } + for input, want := range tests { + if got := safeReleaseBackupComponent(input); got != want { + t.Fatalf("safeReleaseBackupComponent(%q) = %q, want %q", input, got, want) + } + } +} + +func TestCopyFileToBackupRejectsUnsafeFileName(t *testing.T) { + unsafeNames := []string{ + "../clicd", + "..\\clicd", + "subdir/clicd", + "", + } + for _, name := range unsafeNames { + if _, err := copyFileToBackup("missing-source", name, 0755); err == nil || !strings.Contains(err.Error(), "unsafe backup file name") { + t.Fatalf("copyFileToBackup(%q) error = %v, want unsafe backup file name", name, err) + } + } +} + +func TestFormatSSHAccessDoesNotExposePassword(t *testing.T) { + out := formatSSHAccess(2222) + if strings.Contains(out, "/") { + t.Fatalf("formatSSHAccess output contains credential separator: %q", out) + } + if strings.Contains(strings.ToLower(out), "password123") { + t.Fatalf("formatSSHAccess output exposed password: %q", out) + } + if !strings.Contains(out, "2222 -> 22") { + t.Fatalf("formatSSHAccess output = %q, want SSH port mapping", out) + } +} + +func TestFormatSSHAccessHandlesMissingPort(t *testing.T) { + out := formatSSHAccess(0) + if !strings.Contains(out, "端口未分配") { + t.Fatalf("formatSSHAccess output = %q, want missing port message", out) + } +} diff --git a/backend/internal/kvm/kvm.go b/backend/internal/kvm/kvm.go index 9ba4d1d..1e04146 100644 --- a/backend/internal/kvm/kvm.go +++ b/backend/internal/kvm/kvm.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "crypto/rand" + "crypto/sha256" "encoding/base64" "encoding/binary" "encoding/hex" @@ -738,10 +739,14 @@ func (m *Manager) ResetSSHPassword(id int, password string) (string, error) { if err := m.EnsureSSH(id); err != nil { return "", err } + chpasswdInput, err := chpasswdStdin("root", password) + if err != nil { + return "", err + } client, err := ssh.Dial("tcp", net.JoinHostPort(c.IP, "22"), &ssh.ClientConfig{ User: "root", Auth: []ssh.AuthMethod{ssh.Password(c.SSHPassword)}, - HostKeyCallback: ssh.InsecureIgnoreHostKey(), + HostKeyCallback: kvmHostKeyCallback(c), Timeout: 8 * time.Second, }) if err != nil { @@ -753,8 +758,8 @@ func (m *Manager) ResetSSHPassword(id int, password string) (string, error) { return "", err } defer session.Close() - cmd := fmt.Sprintf("printf 'root:%s\\n' | chpasswd", shellQuote(password)) - if output, err := session.CombinedOutput(cmd); err != nil { + session.Stdin = bytes.NewReader(chpasswdInput) + if output, err := session.CombinedOutput("chpasswd"); err != nil { return "", fmt.Errorf("failed to reset password: %v, output: %s", err, string(output)) } c.SSHPassword = password @@ -2224,7 +2229,7 @@ func (m *Manager) EnsureSSH(id int) error { client, err := ssh.Dial("tcp", net.JoinHostPort(c.IP, "22"), &ssh.ClientConfig{ User: "root", Auth: []ssh.AuthMethod{ssh.Password(c.SSHPassword)}, - HostKeyCallback: ssh.InsecureIgnoreHostKey(), + HostKeyCallback: kvmHostKeyCallback(c), Timeout: 8 * time.Second, }) if err != nil { @@ -3190,7 +3195,7 @@ func (m *Manager) applyGuestIPv6OverSSH(c *config.Container) error { client, err := ssh.Dial("tcp", net.JoinHostPort(c.IP, "22"), &ssh.ClientConfig{ User: "root", Auth: []ssh.AuthMethod{ssh.Password(c.SSHPassword)}, - HostKeyCallback: ssh.InsecureIgnoreHostKey(), + HostKeyCallback: kvmHostKeyCallback(c), Timeout: 8 * time.Second, }) if err != nil { @@ -3377,6 +3382,46 @@ func shellQuote(value string) string { return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'" } +func chpasswdStdin(username, password string) ([]byte, error) { + if username == "" || strings.ContainsAny(username, ":\n\r") { + return nil, fmt.Errorf("invalid chpasswd username") + } + if strings.ContainsAny(password, "\n\r") { + return nil, fmt.Errorf("password cannot contain newlines") + } + return []byte(username + ":" + password + "\n"), nil +} + +func kvmHostKeyCallback(c *config.Container) ssh.HostKeyCallback { + return func(hostname string, remote net.Addr, key ssh.PublicKey) error { + return verifyKVMHostKey(c, key, config.SaveConfig) + } +} + +func verifyKVMHostKey(c *config.Container, key ssh.PublicKey, save func() error) error { + if c == nil { + return fmt.Errorf("KVM container is nil") + } + fingerprint := sshHostKeyFingerprint(key) + if c.SSHHostKey != "" && c.SSHHostKey != fingerprint { + return fmt.Errorf("KVM SSH host key mismatch") + } + if c.SSHHostKey == "" { + c.SSHHostKey = fingerprint + if save != nil { + if err := save(); err != nil { + return fmt.Errorf("failed to save KVM SSH host key: %v", err) + } + } + } + return nil +} + +func sshHostKeyFingerprint(key ssh.PublicKey) string { + sum := sha256.Sum256(key.Marshal()) + return hex.EncodeToString(sum[:]) +} + func allocateDefaultEqualPorts(c *config.Container, count int) []int { if count <= 0 { return nil diff --git a/backend/internal/kvm/kvm_test.go b/backend/internal/kvm/kvm_test.go new file mode 100644 index 0000000..665e36a --- /dev/null +++ b/backend/internal/kvm/kvm_test.go @@ -0,0 +1,95 @@ +package kvm + +import ( + "crypto/ed25519" + "crypto/rand" + "reflect" + "testing" + + "clicd/internal/config" + + "golang.org/x/crypto/ssh" +) + +func TestChpasswdStdinPreservesShellMetacharacters(t *testing.T) { + password := `pa'";$(touch /tmp/pwned); echo #\\word` + got, err := chpasswdStdin("root", password) + if err != nil { + t.Fatalf("chpasswdStdin returned error: %v", err) + } + + want := []byte("root:" + password + "\n") + if !reflect.DeepEqual(got, want) { + t.Fatalf("chpasswdStdin = %#v, want %#v", got, want) + } +} + +func TestChpasswdStdinRejectsNewlines(t *testing.T) { + tests := []struct { + name string + username string + password string + }{ + {name: "username newline", username: "root\nadmin", password: "safe"}, + {name: "username colon", username: "root:admin", password: "safe"}, + {name: "password newline", username: "root", password: "safe\nroot:evil"}, + {name: "password carriage return", username: "root", password: "safe\rroot:evil"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if _, err := chpasswdStdin(tc.username, tc.password); err == nil { + t.Fatal("chpasswdStdin returned nil error") + } + }) + } +} + +func TestVerifyKVMHostKeyCapturesAndRejectsMismatch(t *testing.T) { + key1 := testSSHPublicKey(t) + key2 := testSSHPublicKey(t) + + saves := 0 + c := &config.Container{} + save := func() error { + saves++ + return nil + } + + if err := verifyKVMHostKey(c, key1, save); err != nil { + t.Fatalf("first host key verification returned error: %v", err) + } + if c.SSHHostKey == "" { + t.Fatal("first host key verification did not capture fingerprint") + } + if c.SSHHostKey != sshHostKeyFingerprint(key1) { + t.Fatalf("captured fingerprint = %q, want %q", c.SSHHostKey, sshHostKeyFingerprint(key1)) + } + if saves != 1 { + t.Fatalf("save count = %d, want 1", saves) + } + + if err := verifyKVMHostKey(c, key1, save); err != nil { + t.Fatalf("same host key verification returned error: %v", err) + } + if saves != 1 { + t.Fatalf("save count after same key = %d, want 1", saves) + } + + if err := verifyKVMHostKey(c, key2, save); err == nil { + t.Fatal("mismatched host key verification returned nil error") + } +} + +func testSSHPublicKey(t *testing.T) ssh.PublicKey { + t.Helper() + _, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + signer, err := ssh.NewSignerFromKey(privateKey) + if err != nil { + t.Fatal(err) + } + return signer.PublicKey() +} diff --git a/backend/internal/lxc/lxc.go b/backend/internal/lxc/lxc.go index 4e28032..c0fbf87 100644 --- a/backend/internal/lxc/lxc.go +++ b/backend/internal/lxc/lxc.go @@ -402,9 +402,10 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error { // Set root password AFTER shiftRootfsForUnprivileged, // otherwise /etc/shadow ownership breaks and SSHD cannot authenticate. - setCmd := m.rootfsCommand(rootfsPath, - "sh", "-c", fmt.Sprintf("printf '%%s:%%s\\n' root %s | chpasswd", shellQuote(sshPassword))) - setCmd.Run() + if err := m.runRootfsCommand(rootfsPath, + "sh", "-c", fmt.Sprintf("printf '%%s:%%s\\n' root %s | chpasswd", shellQuote(sshPassword))); err != nil { + fmt.Printf("Warning: failed to set root password in %s: %v\n", lxcName, err) + } fmt.Printf("Container %d (%s) created successfully\n", id, cfg.Name) return nil @@ -429,7 +430,7 @@ func (m *Manager) preconfigureNetwork(rootfsPath, templateID string) { content := "auto lo\niface lo inet loopback\n\nauto eth0\niface eth0 inet dhcp\n" _ = os.MkdirAll(filepath.Dir(interfaces), 0755) _ = os.WriteFile(interfaces, []byte(content), 0644) - _ = exec.Command("chroot", rootfsPath, "rc-update", "add", "networking", "boot").Run() + _ = m.runRootfsCommand(rootfsPath, "rc-update", "add", "networking", "boot") return } @@ -451,7 +452,7 @@ method=ignore path := filepath.Join(nmDir, "eth0.nmconnection") _ = os.WriteFile(path, []byte(keyfile), 0600) } - _ = exec.Command("chroot", rootfsPath, "systemctl", "enable", "NetworkManager").Run() + _ = m.runRootfsCommand(rootfsPath, "systemctl", "enable", "NetworkManager") } networkdDir := filepath.Join(rootfsPath, "etc", "systemd", "network") @@ -466,7 +467,7 @@ IPv6AcceptRA=no _ = os.WriteFile(filepath.Join(networkdDir, "10-eth0.network"), []byte(network), 0644) } if !isRHELFamily { - _ = exec.Command("chroot", rootfsPath, "systemctl", "enable", "systemd-networkd").Run() + _ = m.runRootfsCommand(rootfsPath, "systemctl", "enable", "systemd-networkd") } } @@ -475,7 +476,10 @@ func (m *Manager) preconfigureSSH(rootfsPath, password, templateID string) error _ = templateID ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second) defer cancel() - cmd := m.rootfsCommand(rootfsPath, "sh", "-c", sshSetupScript(password, false)) + cmd, err := m.rootfsCommand(rootfsPath, "sh", "-c", sshSetupScript(password, false)) + if err != nil { + return err + } cmd = exec.CommandContext(ctx, cmd.Path, cmd.Args[1:]...) output, err := cmd.CombinedOutput() if ctx.Err() == context.DeadlineExceeded { @@ -1887,7 +1891,10 @@ func (m *Manager) ResetSSHPassword(id int, password string) (string, error) { if err := m.preconfigureSSH(rootfsPath, newPassword, c.Template); err != nil { return "", fmt.Errorf("failed to configure SSH: %v", err) } - cmd := m.rootfsCommand(rootfsPath, "sh", "-c", fmt.Sprintf("printf '%%s:%%s\\n' root %s | chpasswd", shellQuote(newPassword))) + cmd, err := m.rootfsCommand(rootfsPath, "sh", "-c", fmt.Sprintf("printf '%%s:%%s\\n' root %s | chpasswd", shellQuote(newPassword))) + if err != nil { + return "", err + } output, err := cmd.CombinedOutput() if err != nil { return "", fmt.Errorf("failed to set password: %v, output: %s", err, string(output)) @@ -1899,22 +1906,70 @@ func (m *Manager) ResetSSHPassword(id int, password string) (string, error) { return newPassword, nil } -func (m *Manager) rootfsCommand(rootfsPath string, args ...string) *exec.Cmd { - marker := filepath.Join(rootfsPath, ".clicd-unprivileged-shifted") +func (m *Manager) rootfsCommand(rootfsPath string, args ...string) (*exec.Cmd, error) { + cleanRootfsPath, err := m.safeRootfsPath(rootfsPath) + if err != nil { + return nil, err + } + + marker := filepath.Join(cleanRootfsPath, ".clicd-unprivileged-shifted") if _, err := os.Stat(marker); err == nil { uidBase, gidBase, mapErr := unprivilegedIDMap() if mapErr == nil { cmdArgs := []string{ "-m", fmt.Sprintf("u:0:%d:65536", uidBase), "-m", fmt.Sprintf("g:0:%d:65536", gidBase), - "--", "chroot", rootfsPath, + "--", "chroot", "--", cleanRootfsPath, } cmdArgs = append(cmdArgs, args...) - return exec.Command("lxc-usernsexec", cmdArgs...) + return exec.Command("lxc-usernsexec", cmdArgs...), nil } } - cmdArgs := append([]string{rootfsPath}, args...) - return exec.Command("chroot", cmdArgs...) + cmdArgs := append([]string{"--", cleanRootfsPath}, args...) + return exec.Command("chroot", cmdArgs...), nil +} + +func (m *Manager) runRootfsCommand(rootfsPath string, args ...string) error { + cmd, err := m.rootfsCommand(rootfsPath, args...) + if err != nil { + return err + } + return cmd.Run() +} + +func (m *Manager) safeRootfsPath(rootfsPath string) (string, error) { + if rootfsPath == "" { + return "", fmt.Errorf("empty rootfs path") + } + if !filepath.IsAbs(rootfsPath) { + return "", fmt.Errorf("rootfs path must be absolute: %s", rootfsPath) + } + + cleanRootfsPath := filepath.Clean(rootfsPath) + cleanLxcPath, err := filepath.Abs(m.LxcPath) + if err != nil { + return "", fmt.Errorf("failed to resolve LXC path: %v", err) + } + cleanLxcPath = filepath.Clean(cleanLxcPath) + + if cleanRootfsPath == cleanLxcPath { + return "", fmt.Errorf("refusing LXC base path as rootfs: %s", cleanRootfsPath) + } + if filepath.Base(cleanRootfsPath) != "rootfs" { + return "", fmt.Errorf("refusing non-rootfs path: %s", cleanRootfsPath) + } + if filepath.Dir(cleanRootfsPath) == cleanLxcPath { + return "", fmt.Errorf("refusing rootfs directly under LXC path: %s", cleanRootfsPath) + } + + rel, err := filepath.Rel(cleanLxcPath, cleanRootfsPath) + if err != nil { + return "", fmt.Errorf("failed to validate rootfs path: %v", err) + } + if rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) || filepath.IsAbs(rel) { + return "", fmt.Errorf("refusing unsafe rootfs path: %s", cleanRootfsPath) + } + return cleanRootfsPath, nil } func (m *Manager) cleanupContainerStorage(lxcName string) error { @@ -2244,9 +2299,10 @@ func (m *Manager) ReinstallContainer(id int, templateID string) error { if err := m.shiftRootfsForUnprivileged(lxcName); err != nil { return err } - setCmd := m.rootfsCommand(rootfsPath, - "sh", "-c", fmt.Sprintf("printf '%%s:%%s\\n' root %s | chpasswd", shellQuote(c.SSHPassword))) - setCmd.Run() + if err := m.runRootfsCommand(rootfsPath, + "sh", "-c", fmt.Sprintf("printf '%%s:%%s\\n' root %s | chpasswd", shellQuote(c.SSHPassword))); err != nil { + fmt.Printf("Warning: failed to set root password in %s after reinstall: %v\n", lxcName, err) + } // Update template and keep everything else the same c.Template = templateID diff --git a/backend/internal/lxc/lxc_test.go b/backend/internal/lxc/lxc_test.go new file mode 100644 index 0000000..8f93c27 --- /dev/null +++ b/backend/internal/lxc/lxc_test.go @@ -0,0 +1,83 @@ +package lxc + +import ( + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +func TestRootfsCommandAddsSeparatorAndPreservesArgs(t *testing.T) { + base := t.TempDir() + rootfs := filepath.Join(base, "ct-1", "rootfs") + if err := os.MkdirAll(rootfs, 0755); err != nil { + t.Fatal(err) + } + + m := &Manager{LxcPath: base} + cmd, err := m.rootfsCommand(rootfs, "sh", "-c", "true", "--flag") + if err != nil { + t.Fatalf("rootfsCommand returned error: %v", err) + } + + want := []string{"chroot", "--", rootfs, "sh", "-c", "true", "--flag"} + if !reflect.DeepEqual(cmd.Args, want) { + t.Fatalf("cmd.Args = %#v, want %#v", cmd.Args, want) + } +} + +func TestRootfsCommandAllowsLeadingDashContainerName(t *testing.T) { + base := t.TempDir() + rootfs := filepath.Join(base, "-ct", "rootfs") + if err := os.MkdirAll(rootfs, 0755); err != nil { + t.Fatal(err) + } + + m := &Manager{LxcPath: base} + cmd, err := m.rootfsCommand(rootfs, "true") + if err != nil { + t.Fatalf("rootfsCommand returned error: %v", err) + } + + want := []string{"chroot", "--", rootfs, "true"} + if !reflect.DeepEqual(cmd.Args, want) { + t.Fatalf("cmd.Args = %#v, want %#v", cmd.Args, want) + } +} + +func TestRootfsCommandRejectsUnsafeRootfsPaths(t *testing.T) { + base := t.TempDir() + outside := t.TempDir() + m := &Manager{LxcPath: base} + + tests := []struct { + name string + path string + }{ + {name: "outside base", path: filepath.Join(outside, "ct-1", "rootfs")}, + {name: "base path", path: base}, + {name: "not rootfs", path: filepath.Join(base, "ct-1", "not-rootfs")}, + {name: "rootfs directly under base", path: filepath.Join(base, "rootfs")}, + {name: "relative rootfs", path: filepath.Join("ct-1", "rootfs")}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if _, err := m.rootfsCommand(tc.path, "true"); err == nil { + t.Fatalf("rootfsCommand(%q) returned nil error", tc.path) + } + }) + } +} + +func TestSafeRootfsPathRejectsSiblingPrefix(t *testing.T) { + parent := t.TempDir() + base := filepath.Join(parent, "lxc") + siblingRootfs := filepath.Join(parent, "lxc-evil", "ct-1", "rootfs") + m := &Manager{LxcPath: base} + + if _, err := m.safeRootfsPath(siblingRootfs); err == nil || !strings.Contains(err.Error(), "unsafe rootfs path") { + t.Fatalf("safeRootfsPath returned %v, want unsafe rootfs path error", err) + } +} diff --git a/backend/internal/server/web/.gitkeep b/backend/internal/server/web/.gitkeep index e69de29..30259b2 100644 --- a/backend/internal/server/web/.gitkeep +++ b/backend/internal/server/web/.gitkeep @@ -0,0 +1 @@ +