package docker import ( "context" "crypto/rand" "encoding/hex" "encoding/json" "fmt" "io" "net" "os" "os/exec" "path/filepath" "strconv" "strings" "time" ) const developmentEncryptionKeyEnv = "HOMARR_DEV_SECRET_ENCRYPTION_KEY" type Container struct { ID string Name string Image string Status string Ports string State string } func (c Container) HostPort() string { for _, mapping := range strings.Split(c.Ports, ", ") { if strings.HasSuffix(mapping, "->7575/tcp") { parts := strings.Split(mapping, "->") left := parts[0] if i := strings.LastIndex(left, ":"); i >= 0 { return left[i+1:] } return left } } return "" } func (c Container) Running() bool { return c.State == "running" } func List() ([]Container, error) { out, err := exec.Command("docker", "ps", "-a", "--filter", "name=homarr", "--format", "{{json .}}").Output() if err != nil { return nil, err } var containers []Container for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { if line == "" { continue } var raw struct { ID string Names string Image string Status string Ports string State string } if err := json.Unmarshal([]byte(line), &raw); err != nil { continue } containers = append(containers, Container{ ID: raw.ID, Name: raw.Names, Image: raw.Image, Status: raw.Status, Ports: raw.Ports, State: raw.State, }) } filtered := containers[:0] for _, c := range containers { if c.Name == "homarr" || strings.HasPrefix(c.Name, "homarr_") { filtered = append(filtered, c) } } return filtered, nil } func FindFreePort(start int) int { if start > 65535 { return 0 } used := usedDockerPorts() start = max(start, 1) for port := start; port <= 65535; port++ { if used[port] { continue } listener, err := net.Listen("tcp4", fmt.Sprintf("0.0.0.0:%d", port)) if err == nil { _ = listener.Close() return port } } return 0 } func usedDockerPorts() map[int]bool { out, err := exec.Command("docker", "ps", "--format", "{{.Ports}}").Output() if err != nil { return make(map[int]bool) } return parseUsedDockerPorts(out) } func parseUsedDockerPorts(out []byte) map[int]bool { used := make(map[int]bool) for _, line := range strings.Split(string(out), "\n") { for _, mapping := range strings.Split(line, ",") { left, _, found := strings.Cut(mapping, "->") if !found { continue } colon := strings.LastIndex(left, ":") if colon < 0 { continue } ports := strings.SplitN(strings.TrimSpace(left[colon+1:]), "-", 2) first, err := strconv.Atoi(ports[0]) if err != nil { continue } last := first if len(ports) == 2 { if parsed, err := strconv.Atoi(ports[1]); err == nil { last = parsed } } for port := first; port <= last; port++ { used[port] = true } } } return used } func IsRunning(name string) bool { containers, _ := List() for _, c := range containers { if c.Name == name && c.Running() { return true } } return false } type StartOptions struct { Name string Image string Volume string HostPort int Env []string Platform string PullAlways bool Daemon bool } func Start(opts StartOptions) error { encryptionKey, err := developmentEncryptionKey() if err != nil { return err } if opts.PullAlways { cmd := PullCommand(opts.Image, opts.Platform) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if err := cmd.Run(); err != nil { return fmt.Errorf("pull %s: %w", opts.Image, err) } } _ = exec.Command("docker", "rm", "-f", opts.Name).Run() args := []string{"run", "--rm", "--name", opts.Name, "-e", "SECRET_ENCRYPTION_KEY=" + encryptionKey, } if opts.Platform != "" { args = append(args, "--platform", opts.Platform) } if opts.Daemon { args = append(args, "-d") } else { args = append(args, "-it") } for _, env := range opts.Env { args = append(args, "-e", env) } args = append(args, "-v", opts.Volume+":/appdata", "-v", "/var/run/docker.sock:/var/run/docker.sock:ro", "-p", fmt.Sprintf("%d:7575", opts.HostPort), opts.Image, ) cmd := exec.Command("docker", args...) if opts.Daemon { out, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("%w: %s", err, cleanOutput(out)) } return nil } cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout stderr := &tailBuffer{} cmd.Stderr = io.MultiWriter(os.Stderr, stderr) if err := cmd.Run(); err != nil { return fmt.Errorf("%w: %s", err, cleanOutput(stderr.data)) } return nil } func developmentEncryptionKey() (string, error) { if key := os.Getenv(developmentEncryptionKeyEnv); key != "" { return validateDevelopmentEncryptionKey(key) } configDir, err := os.UserConfigDir() if err != nil { return "", fmt.Errorf("locate user config directory: %w", err) } return persistedDevelopmentEncryptionKey(configDir) } func persistedDevelopmentEncryptionKey(configDir string) (string, error) { dir := filepath.Join(configDir, "homarr-dev") path := filepath.Join(dir, "secret-encryption-key") if key, found, err := readDevelopmentEncryptionKey(path); found || err != nil { return key, err } if err := os.MkdirAll(dir, 0o700); err != nil { return "", fmt.Errorf("create development config directory: %w", err) } lockPath := path + ".lock" for attempts := 0; ; attempts++ { if err := os.Mkdir(lockPath, 0o700); err == nil { break } else if !os.IsExist(err) { return "", fmt.Errorf("lock development encryption key: %w", err) } if key, found, err := readDevelopmentEncryptionKey(path); found || err != nil { return key, err } if attempts == 99 { return "", fmt.Errorf("timed out waiting for development encryption key lock %q", lockPath) } time.Sleep(10 * time.Millisecond) } defer func() { _ = os.Remove(lockPath) }() if key, found, err := readDevelopmentEncryptionKey(path); found || err != nil { return key, err } keyBytes := make([]byte, 32) if _, err := rand.Read(keyBytes); err != nil { return "", fmt.Errorf("generate development encryption key: %w", err) } key := hex.EncodeToString(keyBytes) file, err := os.CreateTemp(dir, "secret-encryption-key-*.tmp") if err != nil { return "", fmt.Errorf("create development encryption key: %w", err) } tempPath := file.Name() defer func() { _ = os.Remove(tempPath) }() if _, err := file.WriteString(key + "\n"); err != nil { _ = file.Close() return "", fmt.Errorf("write development encryption key: %w", err) } if err := file.Close(); err != nil { return "", fmt.Errorf("close development encryption key: %w", err) } if err := os.Chmod(tempPath, 0o600); err != nil { return "", fmt.Errorf("secure development encryption key: %w", err) } if err := os.Rename(tempPath, path); err != nil { return "", fmt.Errorf("persist development encryption key: %w", err) } return key, nil } func readDevelopmentEncryptionKey(path string) (string, bool, error) { data, err := os.ReadFile(path) if os.IsNotExist(err) { return "", false, nil } if err != nil { return "", false, fmt.Errorf("read development encryption key: %w", err) } key, err := validateDevelopmentEncryptionKey(strings.TrimSpace(string(data))) return key, true, err } func validateDevelopmentEncryptionKey(key string) (string, error) { decoded, err := hex.DecodeString(key) if err != nil || len(decoded) != 32 { return "", fmt.Errorf("development encryption key must contain exactly 64 hexadecimal characters") } return key, nil } type tailBuffer struct { data []byte } func (b *tailBuffer) Write(p []byte) (int, error) { const limit = 32 * 1024 b.data = append(b.data, p...) if len(b.data) > limit { b.data = b.data[len(b.data)-limit:] } return len(p), nil } func cleanOutput(out []byte) string { lines := strings.Split(strings.TrimSpace(string(out)), "\n") cleaned := make([]string, 0, len(lines)) for _, line := range lines { line = strings.TrimSpace(line) if strings.HasPrefix(line, "WARNING: The requested image's platform") || isContainerID(line) { continue } if line != "" { cleaned = append(cleaned, line) } } return strings.Join(cleaned, "\n") } func isContainerID(value string) bool { if len(value) != 64 { return false } for _, r := range value { if !(r >= '0' && r <= '9') && !(r >= 'a' && r <= 'f') { return false } } return true } func PullCommand(image, platform string) *exec.Cmd { return PullCommandContext(context.Background(), image, platform) } func PullCommandContext(ctx context.Context, image, platform string) *exec.Cmd { args := []string{"pull"} if platform != "" { args = append(args, "--platform", platform) } args = append(args, image) cmd := exec.CommandContext(ctx, "docker", args...) if platform != "" { cmd.Env = append(os.Environ(), "DOCKER_DEFAULT_PLATFORM="+platform) } return cmd } func IsPortConflict(err error) bool { if err == nil { return false } message := strings.ToLower(err.Error()) return strings.Contains(message, "port is already allocated") || strings.Contains(message, "address already in use") || strings.Contains(message, "failed programming external connectivity") } func Stop(name string) error { cmd := exec.Command("docker", "stop", name) out, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out))) } return nil } func Restart(name string) error { cmd := exec.Command("docker", "restart", name) out, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out))) } return nil } func Remove(name string) error { cmd := exec.Command("docker", "rm", "-f", name) out, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out))) } return nil } func CheckImage(image string) (bool, error) { cmd := exec.Command("docker", "manifest", "inspect", image) out, err := cmd.CombinedOutput() if err == nil { return true, nil } message := strings.ToLower(string(out)) if strings.Contains(message, "manifest unknown") || strings.Contains(message, "no such manifest") { return false, nil } return false, fmt.Errorf("check image %s: %w: %s", image, err, strings.TrimSpace(string(out))) } func FollowLogs(name string) error { cmd := exec.Command("docker", "logs", "-f", "--tail", "100", name) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr return cmd.Run() }