Skip to content
Draft
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions buf.lock
Original file line number Diff line number Diff line change
@@ -1,2 +1,6 @@
# Generated by buf. DO NOT EDIT.
version: v2
deps:
- name: buf.build/googleapis/googleapis
commit: 004180b77378443887d3b55cabc00384
digest: b5:e8f475fe3330f31f5fd86ac689093bcd274e19611a09db91f41d637cb9197881ce89882b94d13a58738e53c91c6e4bae7dc1feba85f590164c975a89e25115dc
195 changes: 191 additions & 4 deletions internal/docker/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"maps"
"net/http"
"os"
"os/exec"
"path/filepath"
"reflect"
"strings"
Expand Down Expand Up @@ -333,18 +334,78 @@ 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"
Copy link
Copy Markdown
Owner

@nickheyer nickheyer Feb 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am going to assume this init wrapper is being used for all custom docker images and the user-provided "init commands", right?

Starting with this entrypoint, we are making an assumption that the docker image has bash installed here at /bin/bash. There are a very large number of publicly available docker images that don't have bash installed at all. The "modern" docker image doesn't even have sh / or any command line shell binary installed - these are called "distroless" containers - usually designed for security and to mitigate operational overhead that a full-fat OS usually comes with. Check it out: here

Additionally, if we were to permit the bash assumption (which we probably shouldnt), the correct approach for the shebang would be #!/usr/bin/env bash. The reason for this is because env is far more commonly at /usr/bin/env than bash is at /bin/bash, so passing "bash" to env provides the installed location of bash if it exists. Portability is a must when supporting user provided args.

script += "set -e # Exit on first error\n"
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we assume that a user wants to exit on first error?

Catching error code in the pipe below seems generally applicable here though, as long as we make use of it.

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
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
Expand Down Expand Up @@ -456,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 == "" {
Expand Down Expand Up @@ -765,6 +875,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
}

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So purpose of this method aside, this can cause problems for a couple reasons - but there are ways around it, stylistically and functionally.

func (c *Client) imageExistsLocally(ctx context.Context, imageName string) (bool, error) {

    // Get all existing docker images on host
	images, err := c.docker..ImageList(ctx, image.ListOptions{})
	if err != nil {
		return false, err // Returning error intently to the caller
	}

    // Loop through all found images, 
	for _, img := range images {
		for _, tag := range img.RepoTags {
			if tag == target { // Check for image matching our target
				return true, nil
			}
		}
	}
	return false, nil
}

In your implementation, we assume that all errors returned by the docker image inspection call are inferring only that the image does not exist. We assert that opinion on all future callers of this method, forever masking what could be a legitimate underlying docker error.

// 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
Expand Down
26 changes: 26 additions & 0 deletions internal/rpc/services/minecraft.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package services

import (
"context"
"strings"

"connectrpc.com/connect"
storage "github.com/nickheyer/discopanel/internal/db"
Expand Down Expand Up @@ -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
}
83 changes: 81 additions & 2 deletions internal/rpc/services/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions proto/discopanel/v1/common.proto
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading