Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
/build
/playground
/.idea
/glance*.yml
/glance*.yml
41 changes: 40 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -276,20 +276,32 @@ 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
socket-mode: "0666"
assets-path: /home/user/glance-assets
```

### Properties

| Name | Type | Required | Default |
| ---- | ---- | -------- | ------- |
| 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 | |
Expand All @@ -300,6 +312,33 @@ 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.

#### `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.

Expand Down
27 changes: 27 additions & 0 deletions internal/glance/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"time"
Expand All @@ -31,6 +32,8 @@ type config struct {
Server 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"`
Expand Down Expand Up @@ -483,6 +486,30 @@ 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")
}

// 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]

Expand Down
210 changes: 210 additions & 0 deletions internal/glance/config_socket_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading