From b11c1087b43a7af2f9e529d1e4fc65e7c9b40d0f Mon Sep 17 00:00:00 2001 From: Leon Hubrich Date: Thu, 28 Aug 2025 18:02:55 +0200 Subject: [PATCH 1/2] Add: Unix Sockets --- docs/configuration.md | 23 ++++++++++++++++++- internal/glance/config.go | 13 +++++++++++ internal/glance/glance.go | 48 +++++++++++++++++++++++++++++++-------- 3 files changed, 74 insertions(+), 10 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index c9dd4733..340f8bb5 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -276,20 +276,30 @@ server: When set to `true`, Glance will use the `X-Forwarded-For` header to determine the original IP address of the request, so make sure that your reverse proxy is correctly configured to send that header. ## Server -Server configuration is done through a top level `server` property. Example: +Server configuration is done through a top level `server` property. You can configure the server to listen on either TCP or Unix socket. +TCP example: ```yaml server: + host: localhost port: 8080 assets-path: /home/user/glance-assets ``` +Unix socket example: +```yaml +server: + socket-path: /tmp/glance.sock + assets-path: /home/user/glance-assets +``` + ### Properties | Name | Type | Required | Default | | ---- | ---- | -------- | ------- | | host | string | no | | | port | number | no | 8080 | +| socket-path | string | no | | | proxied | boolean | no | false | | base-url | string | no | | | assets-path | string | no | | @@ -300,6 +310,17 @@ The address which the server will listen on. Setting it to `localhost` means tha #### `port` A number between 1 and 65,535, so long as that port isn't already used by anything else. +#### `socket-path` +Path to a Unix socket file to listen on instead of TCP host:port. When specified, the server will listen on a Unix socket rather than a TCP port. Cannot be used together with `host`. The socket file will be created if it doesn't exist, and any existing file at the path will be removed before creating the socket. + +Example: +```yaml +server: + socket-path: /tmp/glance.sock +``` + +This is useful for running behind reverse proxies that support Unix sockets, or in containerized environments where you want to share the socket via a volume mount. + #### `proxied` Set to `true` if you're using a reverse proxy in front of Glance. This will make Glance use the `X-Forwarded-*` headers to determine the original request details. diff --git a/internal/glance/config.go b/internal/glance/config.go index d4d6af0e..fad9ca54 100644 --- a/internal/glance/config.go +++ b/internal/glance/config.go @@ -31,6 +31,7 @@ type config struct { Server struct { Host string `yaml:"host"` Port uint16 `yaml:"port"` + SocketPath string `yaml:"socket-path"` Proxied bool `yaml:"proxied"` AssetsPath string `yaml:"assets-path"` BaseURL string `yaml:"base-url"` @@ -483,6 +484,18 @@ func isConfigStateValid(config *config) error { } } + // Validate server listening configuration + hasSocketPath := config.Server.SocketPath != "" + hasExplicitHostPort := config.Server.Host != "" + + if hasSocketPath && hasExplicitHostPort { + return fmt.Errorf("cannot specify both socket-path and host when using socket-path") + } + + if !hasSocketPath && !hasExplicitHostPort && config.Server.Port == 0 { + return fmt.Errorf("must specify either socket-path or host:port for server") + } + for i := range config.Pages { page := &config.Pages[i] diff --git a/internal/glance/glance.go b/internal/glance/glance.go index 28771fa5..67eefb84 100644 --- a/internal/glance/glance.go +++ b/internal/glance/glance.go @@ -6,7 +6,9 @@ import ( "encoding/base64" "fmt" "log" + "net" "net/http" + "os" "path/filepath" "slices" "strconv" @@ -489,19 +491,47 @@ func (a *application) server() (func() error, func() error) { } server := http.Server{ - Addr: fmt.Sprintf("%s:%d", a.Config.Server.Host, a.Config.Server.Port), Handler: mux, } start := func() error { - log.Printf("Starting server on %s:%d (base-url: \"%s\", assets-path: \"%s\")\n", - a.Config.Server.Host, - a.Config.Server.Port, - a.Config.Server.BaseURL, - absAssetsPath, - ) - - if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + var listener net.Listener + var err error + + if a.Config.Server.SocketPath != "" { + // Unix socket mode + // Remove existing socket file if it exists + if err := os.RemoveAll(a.Config.Server.SocketPath); err != nil { + return fmt.Errorf("failed to remove existing socket file: %w", err) + } + + listener, err = net.Listen("unix", a.Config.Server.SocketPath) + if err != nil { + return fmt.Errorf("failed to listen on unix socket: %w", err) + } + + log.Printf("Starting server on unix socket %s (base-url: \"%s\", assets-path: \"%s\")\n", + a.Config.Server.SocketPath, + a.Config.Server.BaseURL, + absAssetsPath, + ) + } else { + // TCP mode + addr := fmt.Sprintf("%s:%d", a.Config.Server.Host, a.Config.Server.Port) + listener, err = net.Listen("tcp", addr) + if err != nil { + return fmt.Errorf("failed to listen on tcp address: %w", err) + } + + log.Printf("Starting server on %s:%d (base-url: \"%s\", assets-path: \"%s\")\n", + a.Config.Server.Host, + a.Config.Server.Port, + a.Config.Server.BaseURL, + absAssetsPath, + ) + } + + if err := server.Serve(listener); err != nil && err != http.ErrServerClosed { return err } From 87b6d08ed5f3b68bdbf674905f00e51ce7244731 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 29 Aug 2025 09:56:35 +0000 Subject: [PATCH 2/2] Add Socket Mode (Chmod) --- .gitignore | 2 +- docs/configuration.md | 18 +++ internal/glance/config.go | 14 ++ internal/glance/config_socket_test.go | 210 ++++++++++++++++++++++++++ internal/glance/glance.go | 12 ++ 5 files changed, 255 insertions(+), 1 deletion(-) create mode 100644 internal/glance/config_socket_test.go diff --git a/.gitignore b/.gitignore index 2cd84fc0..7a63426f 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,4 @@ /build /playground /.idea -/glance*.yml +/glance*.yml \ No newline at end of file diff --git a/docs/configuration.md b/docs/configuration.md index 340f8bb5..e571d133 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -290,6 +290,7 @@ Unix socket example: ```yaml server: socket-path: /tmp/glance.sock + socket-mode: "0666" assets-path: /home/user/glance-assets ``` @@ -300,6 +301,7 @@ server: | host | string | no | | | port | number | no | 8080 | | socket-path | string | no | | +| socket-mode | string | no | | | proxied | boolean | no | false | | base-url | string | no | | | assets-path | string | no | | @@ -321,6 +323,22 @@ server: This is useful for running behind reverse proxies that support Unix sockets, or in containerized environments where you want to share the socket via a volume mount. +#### `socket-mode` +File permissions to set on the Unix socket file, specified as octal permissions (e.g., "0666" or "666"). Only valid when `socket-path` is also specified. If not specified, the socket will use the default permissions set by the system. + +Example: +```yaml +server: + socket-path: /tmp/glance.sock + socket-mode: "0666" +``` + +Common values: +- `"0600"` - Owner read/write only +- `"0660"` - Owner and group read/write +- `"0666"` - All users read/write (most permissive) +- `"0664"` - Owner and group read/write, others read only + #### `proxied` Set to `true` if you're using a reverse proxy in front of Glance. This will make Glance use the `X-Forwarded-*` headers to determine the original request details. diff --git a/internal/glance/config.go b/internal/glance/config.go index fad9ca54..1202b7af 100644 --- a/internal/glance/config.go +++ b/internal/glance/config.go @@ -11,6 +11,7 @@ import ( "os" "path/filepath" "regexp" + "strconv" "strings" "sync" "time" @@ -32,6 +33,7 @@ type config struct { Host string `yaml:"host"` Port uint16 `yaml:"port"` SocketPath string `yaml:"socket-path"` + SocketMode string `yaml:"socket-mode"` Proxied bool `yaml:"proxied"` AssetsPath string `yaml:"assets-path"` BaseURL string `yaml:"base-url"` @@ -496,6 +498,18 @@ func isConfigStateValid(config *config) error { return fmt.Errorf("must specify either socket-path or host:port for server") } + // Validate socket-mode parameter + if config.Server.SocketMode != "" { + if !hasSocketPath { + return fmt.Errorf("socket-mode can only be specified when using socket-path") + } + + // Parse and validate the socket mode as octal permissions + if _, err := strconv.ParseUint(config.Server.SocketMode, 8, 32); err != nil { + return fmt.Errorf("invalid socket-mode '%s': must be valid octal permissions (e.g., '0666', '666')", config.Server.SocketMode) + } + } + for i := range config.Pages { page := &config.Pages[i] diff --git a/internal/glance/config_socket_test.go b/internal/glance/config_socket_test.go new file mode 100644 index 00000000..6cce440a --- /dev/null +++ b/internal/glance/config_socket_test.go @@ -0,0 +1,210 @@ +package glance + +import ( + "io/fs" + "os" + "path/filepath" + "testing" + "time" +) + +func TestSocketModeValidation(t *testing.T) { + tests := []struct { + name string + config config + expectError bool + errorMsg string + }{ + { + name: "valid socket mode with socket path", + config: func() config { + c := config{Pages: []page{{Title: "Test", Columns: []struct { + Size string `yaml:"size"` + Widgets widgets `yaml:"widgets"` + }{{Size: "full"}}}}} + c.Server.SocketPath = "/tmp/test.sock" + c.Server.SocketMode = "0666" + return c + }(), + expectError: false, + }, + { + name: "socket mode without socket path should fail", + config: func() config { + c := config{Pages: []page{{Title: "Test"}}} + c.Server.Host = "localhost" + c.Server.Port = 8080 + c.Server.SocketMode = "0666" + return c + }(), + expectError: true, + errorMsg: "socket-mode can only be specified when using socket-path", + }, + { + name: "invalid socket mode should fail", + config: func() config { + c := config{Pages: []page{{Title: "Test"}}} + c.Server.SocketPath = "/tmp/test.sock" + c.Server.SocketMode = "999" + return c + }(), + expectError: true, + errorMsg: "invalid socket-mode '999': must be valid octal permissions (e.g., '0666', '666')", + }, + { + name: "non-numeric socket mode should fail", + config: func() config { + c := config{Pages: []page{{Title: "Test"}}} + c.Server.SocketPath = "/tmp/test.sock" + c.Server.SocketMode = "rwxr--r--" + return c + }(), + expectError: true, + errorMsg: "invalid socket-mode 'rwxr--r--': must be valid octal permissions (e.g., '0666', '666')", + }, + { + name: "valid three-digit socket mode", + config: func() config { + c := config{Pages: []page{{Title: "Test", Columns: []struct { + Size string `yaml:"size"` + Widgets widgets `yaml:"widgets"` + }{{Size: "full"}}}}} + c.Server.SocketPath = "/tmp/test.sock" + c.Server.SocketMode = "666" + return c + }(), + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := isConfigStateValid(&tt.config) + if tt.expectError { + if err == nil { + t.Errorf("expected error but got none") + } else if tt.errorMsg != "" && err.Error() != tt.errorMsg { + t.Errorf("expected error message '%s' but got '%s'", tt.errorMsg, err.Error()) + } + } else { + if err != nil { + t.Errorf("expected no error but got: %v", err) + } + } + }) + } +} + +func TestSocketCreationWithMode(t *testing.T) { + // Create a temporary directory for test sockets + tempDir, err := os.MkdirTemp("", "glance_socket_test") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tempDir) + + socketPath := filepath.Join(tempDir, "test.sock") + + // Create a test application with socket configuration + app := &application{ + CreatedAt: time.Now(), + Config: config{}, + } + app.Config.Server.SocketPath = socketPath + app.Config.Server.SocketMode = "0644" + + // Test socket creation + start, stop := app.server() + + // Start the server in a goroutine + done := make(chan error, 1) + go func() { + done <- start() + }() + + // Give the server a moment to start + time.Sleep(100 * time.Millisecond) + + // Check if socket was created + if _, err := os.Stat(socketPath); os.IsNotExist(err) { + t.Fatalf("Socket file was not created") + } + + // Check socket permissions + info, err := os.Stat(socketPath) + if err != nil { + t.Fatalf("Failed to stat socket file: %v", err) + } + + expectedMode := fs.FileMode(0644) + actualMode := info.Mode() & fs.ModePerm + if actualMode != expectedMode { + t.Errorf("Expected socket permissions %o, but got %o", expectedMode, actualMode) + } + + // Stop the server + if err := stop(); err != nil { + t.Errorf("Failed to stop server: %v", err) + } + + // Wait for server to stop + select { + case err := <-done: + if err != nil && err.Error() != "http: Server closed" { + t.Errorf("Server stopped with unexpected error: %v", err) + } + case <-time.After(5 * time.Second): + t.Error("Server did not stop within timeout") + } +} + +func TestSocketCreationWithoutMode(t *testing.T) { + // Create a temporary directory for test sockets + tempDir, err := os.MkdirTemp("", "glance_socket_test") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tempDir) + + socketPath := filepath.Join(tempDir, "test.sock") + + // Create a test application with socket configuration but no mode + app := &application{ + CreatedAt: time.Now(), + Config: config{}, + } + app.Config.Server.SocketPath = socketPath + // SocketMode is empty, should use default permissions + + // Test socket creation + start, stop := app.server() + + // Start the server in a goroutine + done := make(chan error, 1) + go func() { + done <- start() + }() + + // Give the server a moment to start + time.Sleep(100 * time.Millisecond) + + // Check if socket was created + if _, err := os.Stat(socketPath); os.IsNotExist(err) { + t.Fatalf("Socket file was not created") + } + + // Stop the server + if err := stop(); err != nil { + t.Errorf("Failed to stop server: %v", err) + } + + // Wait for server to stop + select { + case err := <-done: + if err != nil && err.Error() != "http: Server closed" { + t.Errorf("Server stopped with unexpected error: %v", err) + } + case <-time.After(5 * time.Second): + t.Error("Server did not stop within timeout") + } +} \ No newline at end of file diff --git a/internal/glance/glance.go b/internal/glance/glance.go index 67eefb84..db25afbe 100644 --- a/internal/glance/glance.go +++ b/internal/glance/glance.go @@ -510,6 +510,18 @@ func (a *application) server() (func() error, func() error) { return fmt.Errorf("failed to listen on unix socket: %w", err) } + // Set socket file permissions if socket-mode is specified + if a.Config.Server.SocketMode != "" { + mode, err := strconv.ParseUint(a.Config.Server.SocketMode, 8, 32) + if err != nil { + return fmt.Errorf("failed to parse socket-mode: %w", err) + } + + if err := os.Chmod(a.Config.Server.SocketPath, os.FileMode(mode)); err != nil { + return fmt.Errorf("failed to set socket permissions: %w", err) + } + } + log.Printf("Starting server on unix socket %s (base-url: \"%s\", assets-path: \"%s\")\n", a.Config.Server.SocketPath, a.Config.Server.BaseURL,