mirror of
https://github.com/MengMengCode/CLICD.git
synced 2026-08-06 05:52:19 +08:00
修复了一些已知问题
This commit is contained in:
@@ -2,10 +2,10 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"crypto/sha256"
|
|
||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -13,6 +13,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"clicd/internal/config"
|
"clicd/internal/config"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/argon2"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ApiKey struct {
|
type ApiKey struct {
|
||||||
@@ -79,14 +81,23 @@ func createApiKey(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// Generate key: clicd_sk_ + 32 hex chars
|
// Generate key: clicd_sk_ + 32 hex chars
|
||||||
rawBytes := make([]byte, 16)
|
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)
|
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")
|
now := time.Now().Format("2006-01-02 15:04:05")
|
||||||
key := config.ApiKeyConfig{
|
key := config.ApiKeyConfig{
|
||||||
ID: generateShortID(),
|
ID: generateShortID(),
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
KeyHash: hashKey(rawKey),
|
KeyHash: keyHash,
|
||||||
Prefix: rawKey[:13] + "...",
|
Prefix: rawKey[:13] + "...",
|
||||||
IPWhitelist: strings.TrimSpace(req.IPWhitelist),
|
IPWhitelist: strings.TrimSpace(req.IPWhitelist),
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
@@ -114,10 +125,59 @@ func generateShortID() string {
|
|||||||
return hex.EncodeToString(b)
|
return hex.EncodeToString(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
// hashKey creates a simple hash for storage (not reversible)
|
const (
|
||||||
func hashKey(key string) string {
|
apiKeyHashPrefix = "argon2id"
|
||||||
sum := sha256.Sum256([]byte(key))
|
apiKeyHashTime = uint32(3)
|
||||||
return hex.EncodeToString(sum[:])
|
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 {
|
func legacyHashKey(key string) string {
|
||||||
@@ -128,20 +188,36 @@ func legacyHashKey(key string) string {
|
|||||||
return hex.EncodeToString(b)
|
return hex.EncodeToString(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
// validateApiKey checks if the given key is valid and IP is allowed
|
func matchApiKey(rawKey string) (idx int, needsRehash bool) {
|
||||||
func validateApiKey(rawKey, clientIP string) bool {
|
|
||||||
hashed := hashKey(rawKey)
|
|
||||||
legacyHashed := legacyHashKey(rawKey)
|
legacyHashed := legacyHashKey(rawKey)
|
||||||
for _, k := range config.AppConfig.ApiKeys {
|
for i, k := range config.AppConfig.ApiKeys {
|
||||||
if subtle.ConstantTimeCompare([]byte(k.KeyHash), []byte(hashed)) == 1 ||
|
if verifyAPIKeyHash(rawKey, k.KeyHash) {
|
||||||
subtle.ConstantTimeCompare([]byte(k.KeyHash), []byte(legacyHashed)) == 1 {
|
return i, false
|
||||||
if k.IPWhitelist == "" {
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
return isIPAllowed(clientIP, k.IPWhitelist)
|
if subtle.ConstantTimeCompare([]byte(k.KeyHash), []byte(legacyHashed)) == 1 {
|
||||||
|
return i, true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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
|
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 {
|
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])
|
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) {
|
func updateApiKeyLastUsed(rawKey string) {
|
||||||
hashed := hashKey(rawKey)
|
idx, _ := matchApiKey(rawKey)
|
||||||
now := time.Now().Format("2006-01-02 15:04:05")
|
if idx < 0 {
|
||||||
for i := range config.AppConfig.ApiKeys {
|
|
||||||
if config.AppConfig.ApiKeys[i].KeyHash == hashed {
|
|
||||||
config.AppConfig.ApiKeys[i].LastUsed = now
|
|
||||||
config.SaveConfig()
|
|
||||||
return
|
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.
|
// ApiKeyMiddleware authenticates requests via X-API-Key header or Authorization bearer.
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
+68
-11
@@ -20,6 +20,11 @@ import (
|
|||||||
|
|
||||||
var manager = lxc.NewManager()
|
var manager = lxc.NewManager()
|
||||||
|
|
||||||
|
const (
|
||||||
|
clicdBackupDir = "/root/clicd-backups"
|
||||||
|
clicdNewBinaryPath = "/usr/local/bin/clicd.new"
|
||||||
|
)
|
||||||
|
|
||||||
// Run starts the CLI interface.
|
// Run starts the CLI interface.
|
||||||
func Run() {
|
func Run() {
|
||||||
reader := bufio.NewReader(os.Stdin)
|
reader := bufio.NewReader(os.Stdin)
|
||||||
@@ -198,11 +203,18 @@ func cliCreateContainer(reader *bufio.Reader) {
|
|||||||
container := config.FindContainerByName(name)
|
container := config.FindContainerByName(name)
|
||||||
fmt.Printf("容器 %s 创建成功\n", name)
|
fmt.Printf("容器 %s 创建成功\n", name)
|
||||||
if container != nil {
|
if container != nil {
|
||||||
fmt.Printf("SSH: root / %s, port %d -> 22\n", container.SSHPassword, container.SSHPort)
|
fmt.Print(formatSSHAccess(container.SSHPort))
|
||||||
}
|
}
|
||||||
restartWebPanelForConfigChange()
|
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) {
|
func cliStartContainer(reader *bufio.Reader) {
|
||||||
id, name := selectContainer(reader, "开机")
|
id, name := selectContainer(reader, "开机")
|
||||||
if id == 0 {
|
if id == 0 {
|
||||||
@@ -532,13 +544,14 @@ func upgradeFromReleaseAsset(assetURL, latest string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
backupDir := "/root/clicd-backups"
|
backupDir := clicdBackupDir
|
||||||
if err := os.MkdirAll(backupDir, 0700); err != nil {
|
if err := os.MkdirAll(backupDir, 0700); err != nil {
|
||||||
return err
|
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 := 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)
|
return fmt.Errorf("备份旧二进制失败: %w", err)
|
||||||
}
|
}
|
||||||
fmt.Printf("旧版本已备份: %s\n", backupPath)
|
fmt.Printf("旧版本已备份: %s\n", backupPath)
|
||||||
@@ -548,8 +561,8 @@ func upgradeFromReleaseAsset(assetURL, latest string) error {
|
|||||||
if err := stopService("clicd"); err != nil {
|
if err := stopService("clicd"); err != nil {
|
||||||
fmt.Printf("停止 Web 服务失败,继续尝试替换: %v\n", err)
|
fmt.Printf("停止 Web 服务失败,继续尝试替换: %v\n", err)
|
||||||
}
|
}
|
||||||
tmpBin := "/usr/local/bin/clicd.new"
|
tmpBin := clicdNewBinaryPath
|
||||||
if err := copyFile(newBinary, tmpBin, 0755); err != nil {
|
if err := copyFileToUpgradeTemp(newBinary, 0755); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := os.Rename(tmpBin, "/usr/local/bin/clicd"); err != nil {
|
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
|
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)
|
in, err := os.Open(src)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
out.Close()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer in.Close()
|
defer in.Close()
|
||||||
|
|
||||||
out, err := os.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode)
|
if _, err := io.Copy(out, in); err != nil {
|
||||||
if err != nil {
|
out.Close()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if _, err := io.Copy(out, in); err != nil {
|
if err := out.Chmod(mode); err != nil {
|
||||||
out.Close()
|
out.Close()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := out.Close(); err != nil {
|
if err := out.Close(); err != nil {
|
||||||
return err
|
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 {
|
func sameVersion(current, latest string) bool {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
@@ -738,10 +739,14 @@ func (m *Manager) ResetSSHPassword(id int, password string) (string, error) {
|
|||||||
if err := m.EnsureSSH(id); err != nil {
|
if err := m.EnsureSSH(id); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
chpasswdInput, err := chpasswdStdin("root", password)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
client, err := ssh.Dial("tcp", net.JoinHostPort(c.IP, "22"), &ssh.ClientConfig{
|
client, err := ssh.Dial("tcp", net.JoinHostPort(c.IP, "22"), &ssh.ClientConfig{
|
||||||
User: "root",
|
User: "root",
|
||||||
Auth: []ssh.AuthMethod{ssh.Password(c.SSHPassword)},
|
Auth: []ssh.AuthMethod{ssh.Password(c.SSHPassword)},
|
||||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
HostKeyCallback: kvmHostKeyCallback(c),
|
||||||
Timeout: 8 * time.Second,
|
Timeout: 8 * time.Second,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -753,8 +758,8 @@ func (m *Manager) ResetSSHPassword(id int, password string) (string, error) {
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
defer session.Close()
|
defer session.Close()
|
||||||
cmd := fmt.Sprintf("printf 'root:%s\\n' | chpasswd", shellQuote(password))
|
session.Stdin = bytes.NewReader(chpasswdInput)
|
||||||
if output, err := session.CombinedOutput(cmd); err != nil {
|
if output, err := session.CombinedOutput("chpasswd"); err != nil {
|
||||||
return "", fmt.Errorf("failed to reset password: %v, output: %s", err, string(output))
|
return "", fmt.Errorf("failed to reset password: %v, output: %s", err, string(output))
|
||||||
}
|
}
|
||||||
c.SSHPassword = password
|
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{
|
client, err := ssh.Dial("tcp", net.JoinHostPort(c.IP, "22"), &ssh.ClientConfig{
|
||||||
User: "root",
|
User: "root",
|
||||||
Auth: []ssh.AuthMethod{ssh.Password(c.SSHPassword)},
|
Auth: []ssh.AuthMethod{ssh.Password(c.SSHPassword)},
|
||||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
HostKeyCallback: kvmHostKeyCallback(c),
|
||||||
Timeout: 8 * time.Second,
|
Timeout: 8 * time.Second,
|
||||||
})
|
})
|
||||||
if err != nil {
|
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{
|
client, err := ssh.Dial("tcp", net.JoinHostPort(c.IP, "22"), &ssh.ClientConfig{
|
||||||
User: "root",
|
User: "root",
|
||||||
Auth: []ssh.AuthMethod{ssh.Password(c.SSHPassword)},
|
Auth: []ssh.AuthMethod{ssh.Password(c.SSHPassword)},
|
||||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
HostKeyCallback: kvmHostKeyCallback(c),
|
||||||
Timeout: 8 * time.Second,
|
Timeout: 8 * time.Second,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -3377,6 +3382,46 @@ func shellQuote(value string) string {
|
|||||||
return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'"
|
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 {
|
func allocateDefaultEqualPorts(c *config.Container, count int) []int {
|
||||||
if count <= 0 {
|
if count <= 0 {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
+73
-17
@@ -402,9 +402,10 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
|
|||||||
|
|
||||||
// Set root password AFTER shiftRootfsForUnprivileged,
|
// Set root password AFTER shiftRootfsForUnprivileged,
|
||||||
// otherwise /etc/shadow ownership breaks and SSHD cannot authenticate.
|
// otherwise /etc/shadow ownership breaks and SSHD cannot authenticate.
|
||||||
setCmd := m.rootfsCommand(rootfsPath,
|
if err := m.runRootfsCommand(rootfsPath,
|
||||||
"sh", "-c", fmt.Sprintf("printf '%%s:%%s\\n' root %s | chpasswd", shellQuote(sshPassword)))
|
"sh", "-c", fmt.Sprintf("printf '%%s:%%s\\n' root %s | chpasswd", shellQuote(sshPassword))); err != nil {
|
||||||
setCmd.Run()
|
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)
|
fmt.Printf("Container %d (%s) created successfully\n", id, cfg.Name)
|
||||||
return nil
|
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"
|
content := "auto lo\niface lo inet loopback\n\nauto eth0\niface eth0 inet dhcp\n"
|
||||||
_ = os.MkdirAll(filepath.Dir(interfaces), 0755)
|
_ = os.MkdirAll(filepath.Dir(interfaces), 0755)
|
||||||
_ = os.WriteFile(interfaces, []byte(content), 0644)
|
_ = 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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -451,7 +452,7 @@ method=ignore
|
|||||||
path := filepath.Join(nmDir, "eth0.nmconnection")
|
path := filepath.Join(nmDir, "eth0.nmconnection")
|
||||||
_ = os.WriteFile(path, []byte(keyfile), 0600)
|
_ = 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")
|
networkdDir := filepath.Join(rootfsPath, "etc", "systemd", "network")
|
||||||
@@ -466,7 +467,7 @@ IPv6AcceptRA=no
|
|||||||
_ = os.WriteFile(filepath.Join(networkdDir, "10-eth0.network"), []byte(network), 0644)
|
_ = os.WriteFile(filepath.Join(networkdDir, "10-eth0.network"), []byte(network), 0644)
|
||||||
}
|
}
|
||||||
if !isRHELFamily {
|
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
|
_ = templateID
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second)
|
||||||
defer cancel()
|
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:]...)
|
cmd = exec.CommandContext(ctx, cmd.Path, cmd.Args[1:]...)
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
if ctx.Err() == context.DeadlineExceeded {
|
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 {
|
if err := m.preconfigureSSH(rootfsPath, newPassword, c.Template); err != nil {
|
||||||
return "", fmt.Errorf("failed to configure SSH: %v", err)
|
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()
|
output, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to set password: %v, output: %s", err, string(output))
|
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
|
return newPassword, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) rootfsCommand(rootfsPath string, args ...string) *exec.Cmd {
|
func (m *Manager) rootfsCommand(rootfsPath string, args ...string) (*exec.Cmd, error) {
|
||||||
marker := filepath.Join(rootfsPath, ".clicd-unprivileged-shifted")
|
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 {
|
if _, err := os.Stat(marker); err == nil {
|
||||||
uidBase, gidBase, mapErr := unprivilegedIDMap()
|
uidBase, gidBase, mapErr := unprivilegedIDMap()
|
||||||
if mapErr == nil {
|
if mapErr == nil {
|
||||||
cmdArgs := []string{
|
cmdArgs := []string{
|
||||||
"-m", fmt.Sprintf("u:0:%d:65536", uidBase),
|
"-m", fmt.Sprintf("u:0:%d:65536", uidBase),
|
||||||
"-m", fmt.Sprintf("g:0:%d:65536", gidBase),
|
"-m", fmt.Sprintf("g:0:%d:65536", gidBase),
|
||||||
"--", "chroot", rootfsPath,
|
"--", "chroot", "--", cleanRootfsPath,
|
||||||
}
|
}
|
||||||
cmdArgs = append(cmdArgs, args...)
|
cmdArgs = append(cmdArgs, args...)
|
||||||
return exec.Command("lxc-usernsexec", cmdArgs...)
|
return exec.Command("lxc-usernsexec", cmdArgs...), nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
cmdArgs := append([]string{rootfsPath}, args...)
|
cmdArgs := append([]string{"--", cleanRootfsPath}, args...)
|
||||||
return exec.Command("chroot", cmdArgs...)
|
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 {
|
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 {
|
if err := m.shiftRootfsForUnprivileged(lxcName); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
setCmd := m.rootfsCommand(rootfsPath,
|
if err := m.runRootfsCommand(rootfsPath,
|
||||||
"sh", "-c", fmt.Sprintf("printf '%%s:%%s\\n' root %s | chpasswd", shellQuote(c.SSHPassword)))
|
"sh", "-c", fmt.Sprintf("printf '%%s:%%s\\n' root %s | chpasswd", shellQuote(c.SSHPassword))); err != nil {
|
||||||
setCmd.Run()
|
fmt.Printf("Warning: failed to set root password in %s after reinstall: %v\n", lxcName, err)
|
||||||
|
}
|
||||||
|
|
||||||
// Update template and keep everything else the same
|
// Update template and keep everything else the same
|
||||||
c.Template = templateID
|
c.Template = templateID
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user