From 79d3f748ed0ae8351e35a78de1008bfd7b91babe Mon Sep 17 00:00:00 2001 From: Wolfhound Date: Tue, 10 Feb 2026 03:56:30 -0500 Subject: [PATCH 1/8] feat: custom docker image support --- buf.lock | 4 + internal/docker/client.go | 104 +++++++++++- internal/rpc/services/minecraft.go | 26 +++ proto/discopanel/v1/minecraft.proto | 14 ++ web/discopanel/package-lock.json | 53 ++++--- .../components/docker-overrides-editor.svelte | 148 ++++++++++++++---- web/discopanel/src/lib/utils.ts | 37 +++++ .../src/routes/servers/new/+page.svelte | 141 +++++++++++++---- 8 files changed, 443 insertions(+), 84 deletions(-) diff --git a/buf.lock b/buf.lock index 4f98143..5df8acd 100644 --- a/buf.lock +++ b/buf.lock @@ -1,2 +1,6 @@ # Generated by buf. DO NOT EDIT. version: v2 +deps: + - name: buf.build/googleapis/googleapis + commit: 004180b77378443887d3b55cabc00384 + digest: b5:e8f475fe3330f31f5fd86ac689093bcd274e19611a09db91f41d637cb9197881ce89882b94d13a58738e53c91c6e4bae7dc1feba85f590164c975a89e25115dc diff --git a/internal/docker/client.go b/internal/docker/client.go index 53d14fc..0c5aa70 100644 --- a/internal/docker/client.go +++ b/internal/docker/client.go @@ -9,6 +9,7 @@ import ( "maps" "net/http" "os" + "os/exec" "path/filepath" "reflect" "strings" @@ -336,15 +337,33 @@ func ApplyOverrides(overrides *v1.DockerOverrides, config *container.Config, hos func (c *Client) CreateContainer(ctx context.Context, server *models.Server, serverConfig *models.ServerConfig) (string, error) { // Use server's DockerImage if specified, otherwise determine based on version and loader var imageName string + var isLocalImage bool if server.DockerImage != "" { - imageName = "itzg/minecraft-server:" + server.DockerImage + // Check if DockerImage is a full image reference (contains "/") or a local image + if strings.Contains(server.DockerImage, "/") { + // It's a full image reference (e.g., "my-registry.com/image:tag"), use as-is + imageName = server.DockerImage + c.log.Debug("Using full image reference: %s", imageName) + } else if c.imageExistsLocally(ctx, server.DockerImage) { + // It's a local image (e.g., "minecraft-with-git:latest"), use as-is + imageName = server.DockerImage + isLocalImage = true + c.log.Info("Using local image: %s", imageName) + } else { + // It's just a tag (e.g., "java21"), prepend the default itzg image + imageName = "itzg/minecraft-server:" + server.DockerImage + c.log.Debug("Using itzg image with tag: %s", imageName) + } } else { imageName = getDockerImage(server.ModLoader, server.MCVersion) + c.log.Debug("Using optimal docker tag: %s", imageName) } - // Try pulling latest - if err := c.pullImage(ctx, imageName); err != nil { - return "", fmt.Errorf("failed to pull image: %w", err) + // Only pull if it's not a local image + if !isLocalImage { + if err := c.pullImage(ctx, imageName); err != nil { + return "", fmt.Errorf("failed to pull image: %w", err) + } } // Build environment variables @@ -765,6 +784,83 @@ func (c *Client) GetDockerImages() []DockerImageTag { return activeImages } +// parseImageReference splits an image reference into repository and tag +// Returns normalized image name with tag (defaults to "latest" if not specified) +func parseImageReference(imageStr string) (string, error) { + imageStr = strings.TrimSpace(imageStr) + if imageStr == "" { + return "", fmt.Errorf("image name cannot be empty") + } + + // Check for invalid characters + if strings.ContainsAny(imageStr, " \t\n") { + return "", fmt.Errorf("image name contains invalid whitespace") + } + + // If no tag specified, add :latest + if !strings.Contains(imageStr, ":") { + return imageStr + ":latest", nil + } + + return imageStr, nil +} + +// imageExistsLocally checks if a Docker image exists in the local Docker daemon +func (c *Client) imageExistsLocally(ctx context.Context, imageName string) bool { + // Use the Docker API client for more reliable local image detection + _, err := c.docker.ImageInspect(ctx, imageName) + exists := err == nil + c.log.Debug("imageExistsLocally check for '%s': exists=%v, err=%v", imageName, exists, err) + return exists +} + +// ValidateImageExists checks if a Docker image exists locally or on accessible registries +// First checks for local images using docker image inspect, then falls back to docker manifest inspect for remote images +func (c *Client) ValidateImageExists(ctx context.Context, imageName string) error { + // Parse and normalize the image reference + normalizedImage, err := parseImageReference(imageName) + if err != nil { + return err + } + + // First try to check if it's a local image using docker image inspect + cmd := exec.CommandContext(ctx, "docker", "image", "inspect", normalizedImage) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err = cmd.Run() + if err == nil { + // Image exists locally + return nil + } + + // Not found locally, try remote registries using docker manifest inspect + cmd = exec.CommandContext(ctx, "docker", "manifest", "inspect", normalizedImage) + stdout.Reset() + stderr.Reset() + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err = cmd.Run() + if err != nil { + // More descriptive error messages based on error output + errStr := stderr.String() + " " + stdout.String() + if strings.Contains(errStr, "no such manifest") || strings.Contains(errStr, "not found") { + return fmt.Errorf("image '%s' not found locally or on Docker Hub or accessible registries", normalizedImage) + } + if strings.Contains(errStr, "unauthorized") || strings.Contains(errStr, "forbidden") { + return fmt.Errorf("access denied to image '%s' - may require authentication", normalizedImage) + } + if strings.Contains(errStr, "connection refused") || strings.Contains(errStr, "network") { + return fmt.Errorf("cannot reach Docker daemon or registry: %w", err) + } + return fmt.Errorf("failed to validate image '%s': %w", normalizedImage, err) + } + + return nil +} + func getDockerImage(loader models.ModLoader, mcVersion string) string { _ = loader // itzg/minecraft-server supports all mod loaders through environment variables diff --git a/internal/rpc/services/minecraft.go b/internal/rpc/services/minecraft.go index ce9abf3..e0ca429 100644 --- a/internal/rpc/services/minecraft.go +++ b/internal/rpc/services/minecraft.go @@ -2,6 +2,7 @@ package services import ( "context" + "strings" "connectrpc.com/connect" storage "github.com/nickheyer/discopanel/internal/db" @@ -139,3 +140,28 @@ func (s *MinecraftService) GetDockerImages(ctx context.Context, req *connect.Req Images: protoImages, }), nil } + +// ValidateDockerImage validates a custom Docker image +func (s *MinecraftService) ValidateDockerImage(ctx context.Context, req *connect.Request[v1.ValidateDockerImageRequest]) (*connect.Response[v1.ValidateDockerImageResponse], error) { + imageName := req.Msg.GetImage() + + // Validate image exists + err := s.docker.ValidateImageExists(ctx, imageName) + if err != nil { + return connect.NewResponse(&v1.ValidateDockerImageResponse{ + Valid: false, + Error: err.Error(), + }), nil + } + + // Parse the image reference to get normalized name + normalizedImage := imageName + if !strings.Contains(imageName, ":") { + normalizedImage = imageName + ":latest" + } + + return connect.NewResponse(&v1.ValidateDockerImageResponse{ + Valid: true, + NormalizedImage: normalizedImage, + }), nil +} diff --git a/proto/discopanel/v1/minecraft.proto b/proto/discopanel/v1/minecraft.proto index 09da5f2..e4516a1 100644 --- a/proto/discopanel/v1/minecraft.proto +++ b/proto/discopanel/v1/minecraft.proto @@ -12,6 +12,8 @@ service MinecraftService { rpc GetModLoaders(GetModLoadersRequest) returns (GetModLoadersResponse); // List available Docker images rpc GetDockerImages(GetDockerImagesRequest) returns (GetDockerImagesResponse); + // Validate a custom Docker image + rpc ValidateDockerImage(ValidateDockerImageRequest) returns (ValidateDockerImageResponse); } // Minecraft version metadata @@ -120,3 +122,15 @@ message SLPPlayerSample { string name = 1; string id = 2; // UUID } + +// Request to validate a custom Docker image +message ValidateDockerImageRequest { + string image = 1; // e.g., "itzg/minecraft-server:java21" or "myrepo/custom:latest" +} + +// Response with validation result for a Docker image +message ValidateDockerImageResponse { + bool valid = 1; // True if image exists and is accessible + string error = 2; // Error message if validation fails + string normalized_image = 3; // Normalized image name (with tag added if needed) +} diff --git a/web/discopanel/package-lock.json b/web/discopanel/package-lock.json index f3c0e0d..df79592 100644 --- a/web/discopanel/package-lock.json +++ b/web/discopanel/package-lock.json @@ -237,8 +237,7 @@ "version": "2.10.2", "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.10.2.tgz", "integrity": "sha512-uFsRXwIGyu+r6AMdz+XijIIZJYpoWeYzILt5yZ2d3mCjQrWUTVpVD9WL/jZAbvp+Ed04rOhrsk7FiTcEDseB5A==", - "license": "(Apache-2.0 AND BSD-3-Clause)", - "peer": true + "license": "(Apache-2.0 AND BSD-3-Clause)" }, "node_modules/@bufbuild/protoc-gen-es": { "version": "2.10.2", @@ -296,7 +295,6 @@ "resolved": "https://registry.npmjs.org/@connectrpc/connect/-/connect-2.1.1.tgz", "integrity": "sha512-JzhkaTvM73m2K1URT6tv53k2RwngSmCXLZJgK580qNQOXRzZRR/BCMfZw3h+90JpnG6XksP5bYT+cz0rpUzUWQ==", "license": "Apache-2.0", - "peer": true, "peerDependencies": { "@bufbuild/protobuf": "^2.7.0" } @@ -344,6 +342,7 @@ "os": [ "aix" ], + "peer": true, "engines": { "node": ">=18" } @@ -361,6 +360,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -378,6 +378,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -395,6 +396,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -412,6 +414,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } @@ -429,6 +432,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } @@ -446,6 +450,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -463,6 +468,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -480,6 +486,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -497,6 +504,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -514,6 +522,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -531,6 +540,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -548,6 +558,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -565,6 +576,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -582,6 +594,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -599,6 +612,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -616,6 +630,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -633,6 +648,7 @@ "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -650,6 +666,7 @@ "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -667,6 +684,7 @@ "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -684,6 +702,7 @@ "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -701,6 +720,7 @@ "os": [ "openharmony" ], + "peer": true, "engines": { "node": ">=18" } @@ -718,6 +738,7 @@ "os": [ "sunos" ], + "peer": true, "engines": { "node": ">=18" } @@ -735,6 +756,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -752,6 +774,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -769,6 +792,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -1109,7 +1133,6 @@ "integrity": "sha512-oxDR/NTEJ1k+UFVQElaNIk65E/Z83HK1z1WI3lQyhTtnNg4R5oVXaPzK3jcpKG8UHKDVuDQHzn+wsxSz8RP3aw==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@swc/helpers": "^0.5.0" } @@ -1606,7 +1629,6 @@ "integrity": "sha512-Vp3zX/qlwerQmHMP6x0Ry1oY7eKKRcOWGc2P59srOp4zcqyn+etJyQpELgOi4+ZSUgteX8Y387NuwruLgGXLUQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", @@ -1646,7 +1668,6 @@ "integrity": "sha512-YZs/OSKOQAQCnJvM/P+F1URotNnYNeU3P2s4oIpzm1uFaqUEqRxUB0g5ejMjEb5Gjb9/PiBI5Ktrq4rUUF8UVQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", "debug": "^4.4.1", @@ -2046,7 +2067,6 @@ "integrity": "sha512-gqkrWUsS8hcm0r44yn7/xZeV1ERva/nLgrLxFRUGb7aoNMIJfZJ3AC261zDQuOAKC7MiXai1WCpYc48jAHoShQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -2139,7 +2159,6 @@ "integrity": "sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.49.0", "@typescript-eslint/types": "8.49.0", @@ -2402,7 +2421,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2635,7 +2653,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@types/validator": "^13.15.3", "libphonenumber-js": "^1.11.1", @@ -3164,8 +3181,7 @@ "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/embla-carousel-reactive-utils": { "version": "8.6.0", @@ -3304,7 +3320,6 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4710,7 +4725,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -4738,7 +4752,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -4872,7 +4885,6 @@ "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -4889,7 +4901,6 @@ "integrity": "sha512-pn1ra/0mPObzqoIQn/vUTR3ZZI6UuZ0sHqMK5x2jMLGrs53h0sXhkVuDcrlssHwIMk7FYrMjHBPoUSyyEEDlBQ==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "prettier": "^3.0.0", "svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0" @@ -5287,7 +5298,6 @@ "integrity": "sha512-GiWXq6akkEN3zVDMQ1BVlRolmks5JkEdzD/67mvXOz6drRfuddT5JwsGZjMGSnsTRv/PjAXX8fqBcOr2g2qc/Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", @@ -5448,7 +5458,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "devalue": "^5.3.2", "memoize-weak": "^1.0.2", @@ -5577,8 +5586,7 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/tapable": { "version": "2.3.0", @@ -5726,7 +5734,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5871,7 +5878,6 @@ "integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -6530,7 +6536,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/web/discopanel/src/lib/components/docker-overrides-editor.svelte b/web/discopanel/src/lib/components/docker-overrides-editor.svelte index 823ed61..507b3f2 100644 --- a/web/discopanel/src/lib/components/docker-overrides-editor.svelte +++ b/web/discopanel/src/lib/components/docker-overrides-editor.svelte @@ -5,9 +5,10 @@ import { Textarea } from '$lib/components/ui/textarea'; import { Card, CardContent } from '$lib/components/ui/card'; import { Switch } from '$lib/components/ui/switch'; - import { Plus, AlertCircle, Code, ChevronDown, ChevronRight, X } from '@lucide/svelte'; + import { Plus, AlertCircle, Code, ChevronDown, ChevronRight, X, Loader2 } from '@lucide/svelte'; import type { DockerOverrides, VolumeMount } from '$lib/proto/discopanel/v1/common_pb'; import { DockerOverridesSchema, VolumeMountSchema } from '$lib/proto/discopanel/v1/common_pb'; + import type { DockerImage } from '$lib/proto/discopanel/v1/minecraft_pb'; import { create } from '@bufbuild/protobuf'; import { Badge } from '$lib/components/ui/badge'; @@ -15,9 +16,17 @@ overrides?: DockerOverrides; disabled?: boolean; onchange?: (overrides: DockerOverrides | undefined) => void; + dockerImages?: DockerImage[]; + customImageValue?: string; + onCustomImageWarning?: (warning: string | null) => void; + presetDockerImage?: string; + dockerImageValid?: null | true | false; + dockerImageError?: string; + validatingDockerImage?: boolean; + onCustomImageChange?: (value: string) => void; } - let { overrides = $bindable(), disabled = false, onchange }: Props = $props(); + let { overrides = $bindable(), disabled = false, onchange, customImageValue = '', onCustomImageWarning, presetDockerImage = $bindable(), dockerImageValid = null, dockerImageError = '', validatingDockerImage = false, onCustomImageChange }: Props = $props(); let showAdvanced = $state(false); let jsonMode = $state(false); @@ -25,27 +34,52 @@ let jsonError = $state(''); let envVarCounter = $state(0); // Counter for unique env var keys + // Warn when both custom and preset images are specified + $effect(() => { + if (customImageValue && presetDockerImage) { + onCustomImageWarning?.('Both custom image and preset image are specified. The custom image will be used.'); + } else { + onCustomImageWarning?.(null); + } + }); + // Count active overrides for badge let activeCount = $derived(() => { if (!overrides) return 0; let count = 0; - if (overrides.environment && Object.keys(overrides.environment).length > 0) count++; - if (overrides.volumes && overrides.volumes.length > 0) count++; - if (overrides.cpuLimit) count++; - if (overrides.memoryLimit) count++; - if (overrides.networkMode) count++; - if (overrides.privileged) count++; - if (overrides.user) count++; - if (overrides.capAdd && overrides.capAdd.length > 0) count++; - if (overrides.capDrop && overrides.capDrop.length > 0) count++; - if (overrides.devices && overrides.devices.length > 0) count++; + if (overrides) { + if (overrides.environment && Object.keys(overrides.environment).length > 0) count++; + if (overrides.volumes && overrides.volumes.length > 0) count++; + if (overrides.cpuLimit) count++; + if (overrides.memoryLimit) count++; + if (overrides.networkMode) count++; + if (overrides.privileged) count++; + if (overrides.user) count++; + if (overrides.capAdd && overrides.capAdd.length > 0) count++; + if (overrides.capDrop && overrides.capDrop.length > 0) count++; + if (overrides.devices && overrides.devices.length > 0) count++; + } return count; }); // Initialize JSON text when switching modes $effect(() => { if (jsonMode) { - jsonText = JSON.stringify(overrides || {}, null, 2); + // Combine customImage with overrides for JSON representation + const jsonData: Record = {}; + + // Add custom image if present + if (customImageValue) { + jsonData.customImage = customImageValue; + } + + // Add all override properties + if (overrides) { + // Spread the protobuf object properties into jsonData + Object.assign(jsonData, overrides); + } + + jsonText = JSON.stringify(jsonData, null, 2); } }); @@ -54,11 +88,23 @@ // Parse JSON and update overrides try { const parsed = jsonText.trim() ? JSON.parse(jsonText) : {}; + + // Extract customImage if present + if ('customImage' in parsed) { + const newCustomImage = parsed.customImage || ''; + // Trigger validation and update via callback + onCustomImageChange?.(newCustomImage); + // Remove customImage from the parsed object so it's not in overrides + delete parsed.customImage; + } + + // Update overrides with remaining properties overrides = Object.keys(parsed).length > 0 ? parsed : undefined; jsonError = ''; jsonMode = false; onchange?.(overrides); - } catch (e) { + + } catch (e) { jsonError = `Invalid JSON: ${e instanceof Error ? e.message : 'Unknown error'}`; } } else { @@ -251,19 +297,18 @@ placeholder={"{}"} class="font-mono text-xs min-h-[200px] {jsonError ? 'border-destructive' : ''}" /> - {#if jsonError} -
- - {jsonError} -
- {/if} -
-
+ {/if} +
+ @@ -272,6 +317,55 @@ {:else} + +
+ +
+ { + onCustomImageChange?.(e.currentTarget.value); + }} + class={dockerImageValid === false ? 'border-destructive' : dockerImageValid === true ? 'border-green-600' : ''} + {disabled} + /> + {#if validatingDockerImage} +
+ +
+ {:else if dockerImageValid === true} +
+ +
+ {/if} +
+ {#if dockerImageError} +
+ + {dockerImageError} +
+ {:else if customImageValue === ''} +

+ Leave empty to use a preset image or auto-select +

+ {:else if dockerImageValid === true} +

+ Image is available and ready to use +

+ {:else if dockerImageValid === false} +

+ Please check the image name and try again +

+ {:else} +

+ Checking image availability... +

+ {/if} +
+
diff --git a/web/discopanel/src/lib/utils.ts b/web/discopanel/src/lib/utils.ts index 4e661de..596ad3f 100644 --- a/web/discopanel/src/lib/utils.ts +++ b/web/discopanel/src/lib/utils.ts @@ -59,4 +59,41 @@ export function enumToString(map: any, val: unknown): string { return parts.slice(2).join('_').toLowerCase(); } return enumKey.toLowerCase(); +} + +// Validate Docker image reference format +export function isValidImageReferenceFormat(image: string): boolean { + if (!image || image.trim() === '') { + return true; // Empty is valid for auto-select + } + + // Check for invalid whitespace + if (/\s/.test(image)) { + return false; + } + + // Basic format check - should contain at least a namespace/repo + // Valid formats: repo:tag, registry.io/repo:tag, registry.io/repo, repo + const hasValidFormat = /^[a-zA-Z0-9\-._/]+(?::[a-zA-Z0-9\-._]+)?$/.test(image); + return hasValidFormat; +} + +// Debounce helper for async validation +export function debounce( + func: (...args: TArgs) => Promise, + wait: number +): (...args: TArgs) => Promise { + let timeout: ReturnType | null = null; + + return (...args: TArgs) => { + return new Promise((resolve) => { + if (timeout) { + clearTimeout(timeout); + } + + timeout = setTimeout(() => { + resolve(func(...args)); + }, wait); + }); + }; } \ No newline at end of file diff --git a/web/discopanel/src/routes/servers/new/+page.svelte b/web/discopanel/src/routes/servers/new/+page.svelte index 747f7a3..c960050 100644 --- a/web/discopanel/src/routes/servers/new/+page.svelte +++ b/web/discopanel/src/routes/servers/new/+page.svelte @@ -11,7 +11,7 @@ import { Separator } from '$lib/components/ui/separator'; import { rpcClient } from '$lib/api/rpc-client'; import { toast } from 'svelte-sonner'; - import { ArrowLeft, Loader2, Package, Settings, HardDrive } from '@lucide/svelte'; + import { ArrowLeft, Loader2, Package, Settings, HardDrive, AlertCircle } from '@lucide/svelte'; import { create } from '@bufbuild/protobuf'; import type { CreateServerRequest } from '$lib/proto/discopanel/v1/server_pb'; import { CreateServerRequestSchema } from '$lib/proto/discopanel/v1/server_pb'; @@ -22,7 +22,7 @@ import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '$lib/components/ui/dialog'; import AdditionalPortsEditor from '$lib/components/additional-ports-editor.svelte'; import DockerOverridesEditor from '$lib/components/docker-overrides-editor.svelte'; - import { getUniqueDockerImages, getDockerImageDisplayName } from '$lib/utils'; + import { getUniqueDockerImages, getDockerImageDisplayName, isValidImageReferenceFormat, debounce } from '$lib/utils'; let loading = $state(false); let loadingVersions = $state(true); @@ -35,6 +35,11 @@ let proxyListeners = $state([]); let usedPorts = $state>({}); let portError = $state(''); + let dockerImageError = $state(''); + let validatingDockerImage = $state(false); + let dockerImageValid = $state(null); // null = not validated, true = valid, false = invalid + let selectedPresetDockerImage = $state(''); + let presetDockerImageWarning = $state(null); let useProxyMode = $state(false); // Track connection mode separately // Modpack selection @@ -230,9 +235,47 @@ } } + async function validateDockerImage(image: string) { + dockerImageError = ''; + + // Allow empty for auto-select + if (!image || image.trim() === '') { + dockerImageValid = true; + return; + } + + // Basic format check + if (!isValidImageReferenceFormat(image)) { + dockerImageError = 'Invalid image format. Example: itzg/minecraft-server:java21'; + dockerImageValid = false; + return; + } + + // Async validation with backend + validatingDockerImage = true; + try { + const result = await rpcClient.minecraft.validateDockerImage({ image }); + if (!result.valid) { + dockerImageError = result.error || 'Image not found'; + dockerImageValid = false; + } else { + dockerImageValid = true; + dockerImageError = ''; + } + } catch (error) { + dockerImageError = `Failed to validate image: ${error instanceof Error ? error.message : 'Unknown error'}`; + dockerImageValid = false; + } finally { + validatingDockerImage = false; + } + } + + // Debounced validation + const debouncedValidateDockerImage = debounce(validateDockerImage, 700); + async function handleSubmit(e: Event) { e.preventDefault(); - + if (!formData.name.trim()) { toast.error('Server name is required'); return; @@ -244,6 +287,21 @@ return; } + // Determine which Docker image to use (custom takes precedence over preset) + let finalDockerImage = formData.dockerImage || selectedPresetDockerImage || ''; + + // Validate Docker image if provided + if (finalDockerImage) { + // If we haven't validated yet or the validation failed, validate now + if (dockerImageValid !== true) { + await validateDockerImage(finalDockerImage); + if (dockerImageValid === false) { + toast.error('Invalid Docker image'); + return; + } + } + } + loading = true; try { // Add modpack ID and version to the request if selected @@ -255,6 +313,7 @@ const createRequest = { ...formData, + dockerImage: finalDockerImage, modpackId: selectedModpack?.id || '', modpackVersionId: versionToSend || '', // When using proxy with hostname, set port to 0 to indicate proxy usage @@ -274,18 +333,18 @@ -
+
-
+
-

Create New Server

+

Create New Server

Set up a new Minecraft server instance with your preferred configuration

@@ -293,7 +352,7 @@
- +
@@ -336,7 +395,7 @@
{#if selectedModpack} - +
{#if selectedModpack.logoUrl} @@ -507,7 +566,7 @@ - +
@@ -740,27 +799,28 @@ -
- - -

- Leave as auto-select unless you have specific requirements -

-
- +
+ + +

+ Choose a preset Java version for quick selection +

+
+ +

Lifecycle Management

@@ -832,10 +892,33 @@
+ {#if presetDockerImageWarning} +
+
+ +

{presetDockerImageWarning}

+
+
+ {/if} formData.dockerOverrides = overrides} + dockerImages={dockerImages} + customImageValue={formData.dockerImage} + onCustomImageWarning={(warning) => presetDockerImageWarning = warning} + dockerImageValid={dockerImageValid} + dockerImageError={dockerImageError} + validatingDockerImage={validatingDockerImage} + onCustomImageChange={(value) => { + formData.dockerImage = value; + dockerImageValid = null; + dockerImageError = ''; + if (value) { + debouncedValidateDockerImage(value); + } + }} />
From 5760618867106c8c7270f8df4ae9b9def607916e Mon Sep 17 00:00:00 2001 From: Wolfhound Date: Tue, 10 Feb 2026 05:13:38 -0500 Subject: [PATCH 2/8] feat: runtime container commands --- internal/docker/client.go | 91 ++++++++++++++++ internal/rpc/services/server.go | 83 ++++++++++++++- proto/discopanel/v1/common.proto | 1 + .../components/docker-overrides-editor.svelte | 100 +++++++++++++++++- .../src/lib/components/server-settings.svelte | 2 + .../src/routes/servers/new/+page.svelte | 2 + 6 files changed, 276 insertions(+), 3 deletions(-) diff --git a/internal/docker/client.go b/internal/docker/client.go index 0c5aa70..0889009 100644 --- a/internal/docker/client.go +++ b/internal/docker/client.go @@ -334,6 +334,48 @@ func ApplyOverrides(overrides *v1.DockerOverrides, config *container.Config, hos } } +// generateInitWrapper creates a bash wrapper script that runs init commands before the original entrypoint +func (c *Client) generateInitWrapper(ctx context.Context, initCommands []string, originalEntrypoint []string) (string, error) { + if len(initCommands) == 0 { + return "", nil + } + + c.log.Info("Generating init wrapper script with %d commands", len(initCommands)) + + // Create wrapper script content + script := "#!/bin/bash\n" + script += "set -e # Exit on first error\n" + script += "set -o pipefail # Catch errors in pipes\n\n" + script += "echo '[DiscoPanel] Starting init commands...'\n\n" + + // Add each init command with logging + for i, cmd := range initCommands { + script += fmt.Sprintf("echo '[DiscoPanel] Init command %d/%d: Running...'\n", i+1, len(initCommands)) + script += fmt.Sprintf("%s\n", cmd) + script += fmt.Sprintf("echo '[DiscoPanel] Init command %d/%d: SUCCESS'\n\n", i+1, len(initCommands)) + } + + script += "echo '[DiscoPanel] All init commands completed successfully'\n" + script += "echo '[DiscoPanel] Starting original entrypoint...'\n\n" + + // Exec original entrypoint (replaces shell process) + if len(originalEntrypoint) > 0 { + // Properly quote and escape arguments + entrypointCmd := "exec" + for _, part := range originalEntrypoint { + // Escape single quotes in the argument + escaped := strings.ReplaceAll(part, "'", "'\"'\"'") + entrypointCmd += fmt.Sprintf(" '%s'", escaped) + } + script += entrypointCmd + "\n" + } else { + script += "# No original entrypoint specified\n" + script += "exec /bin/bash\n" + } + + return script, nil +} + func (c *Client) CreateContainer(ctx context.Context, server *models.Server, serverConfig *models.ServerConfig) (string, error) { // Use server's DockerImage if specified, otherwise determine based on version and loader var imageName string @@ -475,6 +517,55 @@ func (c *Client) CreateContainer(ctx context.Context, server *models.Server, ser // Apply docker overrides ApplyOverrides(server.DockerOverrides, config, hostConfig) + // Handle init commands wrapper - if init_commands are present + if server.DockerOverrides != nil && len(server.DockerOverrides.InitCommands) > 0 { + c.log.Info("Server %s has init commands, generating wrapper script", server.ID) + + // Determine original entrypoint + originalEntrypoint := config.Entrypoint + if len(originalEntrypoint) == 0 { + // If no entrypoint specified, Docker will use image default + // Try to get it from the image inspection + imageInspect, err := c.docker.ImageInspect(ctx, imageName) + if err == nil && len(imageInspect.Config.Entrypoint) > 0 { + originalEntrypoint = imageInspect.Config.Entrypoint + c.log.Debug("Retrieved image entrypoint: %v", originalEntrypoint) + } else { + // Fallback for itzg/minecraft-server (known default) + originalEntrypoint = []string{"/start"} + c.log.Debug("Using fallback entrypoint for itzg/minecraft-server: /start") + } + } + + // Generate wrapper script + scriptContent, err := c.generateInitWrapper(ctx, server.DockerOverrides.InitCommands, originalEntrypoint) + if err != nil { + return "", fmt.Errorf("failed to generate init wrapper: %w", err) + } + + // Create script file in server's data directory + scriptPath := filepath.Join(server.DataPath, ".discopanel-init.sh") + if err := os.WriteFile(scriptPath, []byte(scriptContent), 0755); err != nil { + return "", fmt.Errorf("failed to write init wrapper script: %w", err) + } + + c.log.Info("Wrote init wrapper script to %s", scriptPath) + + // Mount the script into the container (read-only) + hostConfig.Mounts = append(hostConfig.Mounts, mount.Mount{ + Type: mount.TypeBind, + Source: scriptPath, + Target: "/discopanel-init.sh", + ReadOnly: true, + }) + + // Override entrypoint to use our wrapper + config.Entrypoint = []string{"/bin/bash", "/discopanel-init.sh"} + + c.log.Info("[AUDIT] Generated init wrapper for server %s with %d commands", + server.ID, len(server.DockerOverrides.InitCommands)) + } + // Network configuration networkConfig := &network.NetworkingConfig{} if c.config.NetworkName != "" && hostConfig.NetworkMode == "" { diff --git a/internal/rpc/services/server.go b/internal/rpc/services/server.go index da0d5b5..68f361d 100644 --- a/internal/rpc/services/server.go +++ b/internal/rpc/services/server.go @@ -14,6 +14,7 @@ import ( "connectrpc.com/connect" "github.com/google/uuid" + "github.com/nickheyer/discopanel/internal/auth" "github.com/nickheyer/discopanel/internal/config" storage "github.com/nickheyer/discopanel/internal/db" "github.com/nickheyer/discopanel/internal/docker" @@ -384,6 +385,34 @@ func (s *ServerService) CreateServer(ctx context.Context, req *connect.Request[v return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("name and MC version are required")) } + // Validate init commands - admin only, requires auth enabled + if msg.DockerOverrides != nil && len(msg.DockerOverrides.InitCommands) > 0 { + authConfig, _, err := s.store.GetAuthConfig(ctx) + if err == nil && !authConfig.Enabled { + return nil, connect.NewError(connect.CodeInvalidArgument, + fmt.Errorf("authentication must be enabled to use init commands")) + } + + user := auth.GetUserFromContext(ctx) + if user == nil || user.Role != storage.RoleAdmin { + return nil, connect.NewError(connect.CodePermissionDenied, + fmt.Errorf("only administrators can configure init commands")) + } + + // Audit log: Init commands configured during creation + s.log.Info("[AUDIT] User %s (%s) configured %d init commands for new server '%s'", + user.Username, user.ID, len(msg.DockerOverrides.InitCommands), msg.Name) + + // Log each command (truncated for security) + for i, cmd := range msg.DockerOverrides.InitCommands { + cmdPreview := cmd + if len(cmdPreview) > 100 { + cmdPreview = cmdPreview[:100] + "..." + } + s.log.Info("[AUDIT] Init command %d: %s", i+1, cmdPreview) + } + } + // Handle proxy configuration proxyHostname := msg.ProxyHostname proxyListenerID := msg.ProxyListenerId @@ -854,10 +883,60 @@ func (s *ServerService) UpdateServer(ctx context.Context, req *connect.Request[v needsRecreation = true } - // Handle docker overrides update + // Handle docker overrides update with init commands validation if msg.DockerOverrides != nil { + // Check if init commands changed + oldInitCommands := []string{} + if server.DockerOverrides != nil { + oldInitCommands = server.DockerOverrides.InitCommands + } + newInitCommands := msg.DockerOverrides.InitCommands + + // Compare init commands + commandsChanged := len(oldInitCommands) != len(newInitCommands) + if !commandsChanged { + for i := range oldInitCommands { + if oldInitCommands[i] != newInitCommands[i] { + commandsChanged = true + break + } + } + } + + // Validate init commands if changed - admin only, requires auth enabled + if commandsChanged && len(newInitCommands) > 0 { + authConfig, _, err := s.store.GetAuthConfig(ctx) + if err == nil && !authConfig.Enabled { + return nil, connect.NewError(connect.CodeInvalidArgument, + fmt.Errorf("authentication must be enabled to use init commands")) + } + + user := auth.GetUserFromContext(ctx) + if user == nil || user.Role != storage.RoleAdmin { + return nil, connect.NewError(connect.CodePermissionDenied, + fmt.Errorf("only administrators can configure init commands")) + } + + // Audit log: Init commands modified + s.log.Info("[AUDIT] User %s (%s) modified init commands for server '%s' (ID: %s)", + user.Username, user.ID, server.Name, server.ID) + s.log.Info("[AUDIT] Previous: %d commands, New: %d commands", + len(oldInitCommands), len(newInitCommands)) + + // Log new commands + for i, cmd := range newInitCommands { + cmdPreview := cmd + if len(cmdPreview) > 100 { + cmdPreview = cmdPreview[:100] + "..." + } + s.log.Info("[AUDIT] New init command %d: %s", i+1, cmdPreview) + } + } + server.DockerOverrides = msg.DockerOverrides - needsRecreation = true + if commandsChanged || len(msg.DockerOverrides.InitCommands) > 0 { + needsRecreation = true + } } // Handle modpack version update diff --git a/proto/discopanel/v1/common.proto b/proto/discopanel/v1/common.proto index 0aa2d98..2d17c85 100644 --- a/proto/discopanel/v1/common.proto +++ b/proto/discopanel/v1/common.proto @@ -147,6 +147,7 @@ message DockerOverrides { string working_dir = 17; // Working directory inside container repeated string entrypoint = 18; // Override default entrypoint repeated string command = 19; // Override default command + repeated string init_commands = 20; // Bash commands to run before entrypoint (ADMIN ONLY) } // TCP proxy listener endpoint diff --git a/web/discopanel/src/lib/components/docker-overrides-editor.svelte b/web/discopanel/src/lib/components/docker-overrides-editor.svelte index 507b3f2..4e665dd 100644 --- a/web/discopanel/src/lib/components/docker-overrides-editor.svelte +++ b/web/discopanel/src/lib/components/docker-overrides-editor.svelte @@ -24,9 +24,10 @@ dockerImageError?: string; validatingDockerImage?: boolean; onCustomImageChange?: (value: string) => void; + isAdmin?: boolean; } - let { overrides = $bindable(), disabled = false, onchange, customImageValue = '', onCustomImageWarning, presetDockerImage = $bindable(), dockerImageValid = null, dockerImageError = '', validatingDockerImage = false, onCustomImageChange }: Props = $props(); + let { overrides = $bindable(), disabled = false, onchange, customImageValue = '', onCustomImageWarning, presetDockerImage = $bindable(), dockerImageValid = null, dockerImageError = '', validatingDockerImage = false, onCustomImageChange, isAdmin = false }: Props = $props(); let showAdvanced = $state(false); let jsonMode = $state(false); @@ -50,6 +51,7 @@ if (overrides) { if (overrides.environment && Object.keys(overrides.environment).length > 0) count++; if (overrides.volumes && overrides.volumes.length > 0) count++; + if (overrides.initCommands && overrides.initCommands.length > 0) count++; if (overrides.cpuLimit) count++; if (overrides.memoryLimit) count++; if (overrides.networkMode) count++; @@ -138,6 +140,7 @@ // Copy existing values if (overrides.environment && Object.keys(overrides.environment).length > 0) updates.environment = { ...overrides.environment }; if (overrides.volumes && overrides.volumes.length > 0) updates.volumes = [...overrides.volumes]; + if (overrides.initCommands && overrides.initCommands.length > 0) updates.initCommands = [...overrides.initCommands]; if (overrides.capAdd && overrides.capAdd.length > 0) updates.capAdd = [...overrides.capAdd]; if (overrides.capDrop && overrides.capDrop.length > 0) updates.capDrop = [...overrides.capDrop]; if (overrides.devices && overrides.devices.length > 0) updates.devices = [...overrides.devices]; @@ -500,6 +503,101 @@ {/if}
+ + {#if isAdmin} +
+
+ + +
+ + +
+ +
+

Security Warning: Admin Access Required

+

+ Init commands run with container privileges before the Minecraft server starts. + Only administrators can configure these commands. Commands run as bash scripts + and can modify the container environment. +

+

+ Examples: Install packages, clone git repos, modify configs +

+
+
+ + {#if overrides?.initCommands && overrides.initCommands.length > 0} +
+
+ {#each overrides.initCommands as command, i} +
+ {i + 1}. +