diff --git a/buf.lock b/buf.lock index 4f98143..ae47d3f 100644 --- a/buf.lock +++ b/buf.lock @@ -1,2 +1,3 @@ # Generated by buf. DO NOT EDIT. version: v2 +deps: [] diff --git a/buf.yaml b/buf.yaml index 8508a31..3da9174 100644 --- a/buf.yaml +++ b/buf.yaml @@ -18,6 +18,4 @@ breaking: - FILE except: - EXTENSION_NO_DELETE - - FIELD_SAME_DEFAULT -deps: - - buf.build/googleapis/googleapis \ No newline at end of file + - FIELD_SAME_DEFAULT \ No newline at end of file diff --git a/internal/docker/client.go b/internal/docker/client.go index 53d14fc..2b44cff 100644 --- a/internal/docker/client.go +++ b/internal/docker/client.go @@ -16,6 +16,7 @@ import ( "time" "github.com/containerd/errdefs" + "github.com/distribution/reference" "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/image" "github.com/docker/docker/api/types/mount" @@ -333,18 +334,85 @@ func ApplyOverrides(overrides *v1.DockerOverrides, config *container.Config, hos } } +// generateInitWrapper creates a shell wrapper script that runs init commands before the original entrypoint. +// IMPORTANT: This feature requires a shellful image with /bin/sh present or else startup will fail +func (c *Client) generateInitWrapper(ctx context.Context, initCommands []string, originalEntrypoint []string) (string, error) { + if len(initCommands) == 0 { + return "", nil + } + + // Validate init commands - check for empty commands + for i, cmd := range initCommands { + if strings.TrimSpace(cmd) == "" { + return "", fmt.Errorf("init command %d is empty", i+1) + } + } + + c.log.Info("Generating init wrapper script with %d commands", len(initCommands)) + + script := "set -e\n" + script += "set -x\n\n" // Echo commands as they execute for visibility + + // Add each init command + for _, cmd := range initCommands { + script += fmt.Sprintf("%s\n", cmd) + } + + script += "\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 { + // No entrypoint - this is an error condition + // Cannot generate wrapper without knowing what to exec back to + return "", fmt.Errorf("cannot generate init wrapper: image has no entrypoint defined. init commands require an image with a defined entrypoint") + } + + 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 if server.DockerImage != "" { - imageName = "itzg/minecraft-server:" + server.DockerImage + // User provided a custom image - use it as-is + // Could be a full reference (registry.com/image:tag), a short name (my-image:latest), + // or any other valid image reference format + imageName = server.DockerImage + c.log.Debug("Using custom image: %s", imageName) } else { + // No custom image specified, determine optimal one based on version and loader 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) + // Attempt to pull the image from registry to get latest version + pullErr := c.pullImage(ctx, imageName) + if pullErr == nil { + c.log.Debug("Successfully pulled image: %s", imageName) + } else { + // Pull failed, check if image exists locally as fallback + c.log.Warn("Failed to pull image %s: %v, checking for local image", imageName, pullErr) + _, inspectErr := c.docker.ImageInspect(ctx, imageName) + if inspectErr == nil { + // Image exists locally + c.log.Info("Using existing local image as fallback: %s", imageName) + } else if !errdefs.IsNotFound(inspectErr) { + // Real error checking local image, but still return pull error + c.log.Debug("Error inspecting local image %s: %v", imageName, inspectErr) + return "", fmt.Errorf("failed to pull image: %w", pullErr) + } else { + // Image doesn't exist locally either + return "", fmt.Errorf("failed to pull image: %w", pullErr) + } } // Build environment variables @@ -456,6 +524,57 @@ 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), 0644); 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) + // Use a specific directory to avoid conflicts at container root + containerScriptPath := "/opt/discopanel/init.sh" + hostConfig.Mounts = append(hostConfig.Mounts, mount.Mount{ + Type: mount.TypeBind, + Source: scriptPath, + Target: containerScriptPath, + ReadOnly: true, + }) + + // Override entrypoint to use our wrapper + config.Entrypoint = []string{"/bin/sh", containerScriptPath} + + c.log.Info("Generated init wrapper for server %s with %d init commands", + server.ID, len(server.DockerOverrides.InitCommands)) + } + // Network configuration networkConfig := &network.NetworkingConfig{} if c.config.NetworkName != "" && hostConfig.NetworkMode == "" { @@ -765,6 +884,68 @@ func (c *Client) GetDockerImages() []DockerImageTag { return activeImages } +// ParseImageReference validates and normalizes a Docker image reference. +// Returns the normalized reference string, adding a "latest" tag if none is present. +func (c *Client) ParseImageReference(imageStr string) (string, error) { + s := strings.TrimSpace(imageStr) + if s == "" { + return "", fmt.Errorf("image name cannot be empty") + } + // Reject any whitespace anywhere (Docker refs cannot contain it) + if strings.IndexFunc(s, func(r rune) bool { + return r == ' ' || r == '\t' || r == '\n' || r == '\r' + }) != -1 { + return "", fmt.Errorf("image name contains invalid whitespace") + } + + ref, err := reference.ParseNormalizedNamed(s) + if err != nil { + return "", fmt.Errorf("invalid image reference %q: %w", s, err) + } + + // If it has a digest, don't add a tag. (image@sha256:... is already fully pinned.) + if _, ok := ref.(reference.Digested); ok { + return reference.FamiliarString(ref), nil + } + + // Ensure a tag exists (default "latest") for non-digest refs. + ref = reference.TagNameOnly(ref) + + return reference.FamiliarString(ref), nil +} + + +// ValidateImageExists validates that a Docker image reference is valid and accessible. +// Returns the normalized image name and any validation error. +func (c *Client) ValidateImageExists(ctx context.Context, imageName string) (string, error) { + // Validate format first + normalizedName, err := c.ParseImageReference(imageName) + if err != nil { + return "", err + } + + // Try to pull the image from registry + pullErr := c.pullImage(ctx, normalizedName) + if pullErr == nil { + c.log.Debug("Image validated successfully: %s", normalizedName) + return normalizedName, nil + } + + // Pull failed, check if image exists locally as fallback + c.log.Debug("Pull failed for %s: %v, checking for local image", normalizedName, pullErr) + _, inspectErr := c.docker.ImageInspect(ctx, normalizedName) + if inspectErr == nil { + c.log.Info("Image validated (local): %s", normalizedName) + return normalizedName, nil + } + if !errdefs.IsNotFound(inspectErr) { + c.log.Debug("Error inspecting local image %s: %v", normalizedName, inspectErr) + } + + // Image doesn't exist in registry or locally + return "", fmt.Errorf("image not found in registry or local images: %s", normalizedName) +} + 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..f3b9177 100644 --- a/internal/rpc/services/minecraft.go +++ b/internal/rpc/services/minecraft.go @@ -139,3 +139,21 @@ 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() + + normalizedImage, err := s.docker.ValidateImageExists(ctx, imageName) + if err != nil { + return connect.NewResponse(&v1.ValidateDockerImageResponse{ + Valid: false, + Error: err.Error(), + }), nil + } + + return connect.NewResponse(&v1.ValidateDockerImageResponse{ + Valid: true, + NormalizedImage: normalizedImage, + }), nil +} diff --git a/internal/rpc/services/server.go b/internal/rpc/services/server.go index da0d5b5..5036377 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,45 @@ 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")) + } + + // Log that init commands were configured + s.log.Info("User %s (%s) configured %d init commands for new server '%s'", + user.Username, user.ID, len(msg.DockerOverrides.InitCommands), msg.Name) + } + + // Validate custom Docker image - admin only + if msg.DockerImage != "" && !strings.HasPrefix(msg.DockerImage, "itzg/minecraft-server:") { + // This is a custom image - admin only + 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 custom docker images")) + } + + user := auth.GetUserFromContext(ctx) + if user == nil || user.Role != storage.RoleAdmin { + return nil, connect.NewError(connect.CodePermissionDenied, + fmt.Errorf("only administrators can use custom docker images")) + } + + // Audit log: Custom image configured during creation + s.log.Info("User %s (%s) configured custom docker image '%s' for new server '%s'", + user.Username, user.ID, msg.DockerImage, msg.Name) + } + // Handle proxy configuration proxyHostname := msg.ProxyHostname proxyListenerID := msg.ProxyListenerId @@ -464,7 +504,7 @@ func (s *ServerService) CreateServer(ctx context.Context, req *connect.Request[v // Determine Docker image if not specified dockerImage := msg.DockerImage if dockerImage == "" { - dockerImage = docker.GetOptimalDockerTag(msg.McVersion, modLoader, false) + dockerImage = "itzg/minecraft-server:" + docker.GetOptimalDockerTag(msg.McVersion, modLoader, false) } // Validate additional ports @@ -791,6 +831,27 @@ func (s *ServerService) UpdateServer(ctx context.Context, req *connect.Request[v needsRecreation = true } if msg.DockerImage != "" && msg.DockerImage != originalDockerImage { + // Validate custom Docker image - admin only + if !strings.HasPrefix(msg.DockerImage, "itzg/minecraft-server:") { + // This is a custom image - admin only + 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 custom docker images")) + } + + user := auth.GetUserFromContext(ctx) + if user == nil || user.Role != storage.RoleAdmin { + return nil, connect.NewError(connect.CodePermissionDenied, + fmt.Errorf("only administrators can use custom docker images")) + } + + // Audit log: Custom image modified + s.log.Info("User %s (%s) changed docker image for server '%s' (ID: %s)", + user.Username, user.ID, server.Name, server.ID) + s.log.Info(" Previous: %s, New: %s", originalDockerImage, msg.DockerImage) + } + server.DockerImage = msg.DockerImage needsRecreation = true } @@ -854,10 +915,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("User %s (%s) modified init commands for server '%s' (ID: %s)", + user.Username, user.ID, server.Name, server.ID) + s.log.Info(" 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(" 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/internal/rpc/services/user.go b/internal/rpc/services/user.go index 9edacdd..33074e3 100644 --- a/internal/rpc/services/user.go +++ b/internal/rpc/services/user.go @@ -149,4 +149,4 @@ func (s *UserService) DeleteUser(ctx context.Context, req *connect.Request[v1.De return connect.NewResponse(&v1.DeleteUserResponse{ Message: "User deleted successfully", }), nil -} \ No newline at end of file +} 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/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..e214453 100644 --- a/web/discopanel/src/lib/components/docker-overrides-editor.svelte +++ b/web/discopanel/src/lib/components/docker-overrides-editor.svelte @@ -5,19 +5,21 @@ 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 { create } from '@bufbuild/protobuf'; + import type { DockerImage } from '$lib/proto/discopanel/v1/minecraft_pb'; + import { create, toJson } from '@bufbuild/protobuf'; import { Badge } from '$lib/components/ui/badge'; interface Props { overrides?: DockerOverrides; disabled?: boolean; onchange?: (overrides: DockerOverrides | undefined) => void; + isAdmin?: boolean; } - let { overrides = $bindable(), disabled = false, onchange }: Props = $props(); + let { overrides = $bindable(), disabled = false, onchange, isAdmin = false }: Props = $props(); let showAdvanced = $state(false); let jsonMode = $state(false); @@ -29,23 +31,31 @@ 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.initCommands && overrides.initCommands.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); + if (overrides) { + const overridesJson = toJson(DockerOverridesSchema, overrides); + jsonText = JSON.stringify(overridesJson, null, 2); + } else { + jsonText = '{}'; + } } }); @@ -53,11 +63,29 @@ if (jsonMode) { // Parse JSON and update overrides try { - const parsed = jsonText.trim() ? JSON.parse(jsonText) : {}; - overrides = Object.keys(parsed).length > 0 ? parsed : undefined; + const trimmed = jsonText.trim(); + if (!trimmed || trimmed === '{}') { + // If JSON is empty or just empty object, reset to empty message + overrides = create(DockerOverridesSchema, {}); + jsonError = ''; + jsonMode = false; + onchange?.(overrides); + return; + } + + const parsed = JSON.parse(trimmed); + + // Update overrides - convert plain object back to protobuf Message + if (typeof parsed === 'object' && Object.keys(parsed).length > 0) { + overrides = create(DockerOverridesSchema, parsed); + onchange?.(overrides); + } else { + overrides = create(DockerOverridesSchema, {}); + onchange?.(overrides); + } jsonError = ''; jsonMode = false; - onchange?.(overrides); + } catch (e) { jsonError = `Invalid JSON: ${e instanceof Error ? e.message : 'Unknown error'}`; } @@ -92,6 +120,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]; @@ -125,7 +154,7 @@ if (hasValues) { overrides = create(DockerOverridesSchema, updates); } else { - overrides = undefined; + overrides = create(DockerOverridesSchema, {}); } onchange?.(overrides); @@ -251,19 +280,18 @@ placeholder={"{}"} class="font-mono text-xs min-h-[200px] {jsonError ? 'border-destructive' : ''}" /> - {#if jsonError} -
Advanced Feature: Admin Only
+
+ Requires a shellful image with /bin/sh.
+ Distroless, scratch, and minimal images are not supported.
+ Commands run with container privileges before the server starts.
+
+ Examples: Install packages, clone git repos, modify configs +
++ Commands execute in order. The container will fail to start if any command returns a non-zero exit code. + Check container logs for execution details. +
+Set up a new Minecraft server instance with your preferred configuration