Skip to content
Merged
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
8 changes: 4 additions & 4 deletions .github/workflows/push.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ jobs:
fail-fast: false
matrix:
os: [ubuntu-22.04]
go: ["1.25.11", "1.26.4"]
go: ["1.25.12", "1.26.5"]
goos: [linux]
goarch: [amd64, arm64]

Expand Down Expand Up @@ -64,14 +64,14 @@ jobs:

- name: Upload Release Artifact
uses: actions/upload-artifact@v7
if: ${{ (github.ref == 'refs/heads/main' || github.event_name == 'pull_request') && matrix.go == '1.25.11' }}
if: ${{ (github.ref == 'refs/heads/main' || github.event_name == 'pull_request') && matrix.go == '1.25.12' }}
with:
name: wings_linux_${{ matrix.goarch }}
path: dist/wings

- name: Upload Debug Artifact
uses: actions/upload-artifact@v7
if: ${{ (github.ref == 'refs/heads/main' || github.event_name == 'pull_request') && matrix.go == '1.25.11' }}
if: ${{ (github.ref == 'refs/heads/main' || github.event_name == 'pull_request') && matrix.go == '1.25.12' }}
with:
name: wings_linux_${{ matrix.goarch }}_debug
path: dist/wings_debug
Expand All @@ -82,7 +82,7 @@ jobs:
strategy:
fail-fast: false
matrix:
go: ["1.25.11", "1.26.4"]
go: ["1.25.12", "1.26.5"]

steps:
- name: Setup Go
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version: "1.25.11"
go-version: "1.25.12"

- name: Build release binaries
env:
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Stage 1 (Build)
FROM golang:1.25.11-alpine AS builder
FROM golang:1.25.12-alpine AS builder

ARG VERSION
RUN apk add --update --no-cache git make
Expand Down
5 changes: 5 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,11 @@ type Backups struct {
// Defaults to "best_speed" (level 1)
CompressionLevel string `default:"best_speed" yaml:"compression_level"`


// RestoreHostAllowlist allows backup restore downloads to connect to otherwise blocked
// private/internal destinations. Entries may be hostnames, IP addresses, or CIDR ranges.
RestoreHostAllowlist []string `yaml:"restore_host_allowlist"`

// RemoveBackupsOnServerDelete deletes backups associated with a server when the server is deleted
RemoveBackupsOnServerDelete bool `default:"true" yaml:"remove_backups_on_server_delete"`
}
Expand Down
89 changes: 89 additions & 0 deletions config/config_docker.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@ package config

import (
"encoding/base64"
"net/url"
"sort"
"strings"

"github.com/distribution/reference"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/registry"
"github.com/goccy/go-json"
Expand Down Expand Up @@ -108,6 +111,92 @@ func (c DockerConfiguration) ContainerLogConfig() container.LogConfig {
Config: c.LogConfig.Config,
}
}
// RegistryCredentialsForImage returns registry credentials for an image only
// when the configured registry and image reference share the same registry
// identity.
func (c DockerConfiguration) RegistryCredentialsForImage(img string) (string, *RegistryConfiguration) {
named, err := reference.ParseNormalizedNamed(img)
if err != nil {
return "", nil
}

imageDomain := strings.ToLower(reference.Domain(named))
imagePath := reference.Path(named)
var matchedRegistry string
var matchedCredentials RegistryConfiguration
matchedScore := -1

for registry, cfg := range c.Registries {
domain, path, ok := parseDockerRegistryReference(registry)
if !ok || domain != imageDomain || !registryPathMatchesImage(path, imagePath) {
continue
}

score := len(domain) + len(path)
if score > matchedScore {
matchedRegistry = registry
matchedCredentials = cfg
matchedScore = score
}
}

if matchedScore == -1 {
return "", nil
}

return matchedRegistry, &matchedCredentials
}

func parseDockerRegistryReference(registry string) (string, string, bool) {
registry = strings.TrimSpace(registry)
if registry == "" {
return "", "", false
}

if u, err := url.Parse(registry); err == nil && u.Host != "" {
p := strings.Trim(u.Path, "/")
if p == "" || p == "v1" || p == "v2" {
registry = u.Host
} else {
registry = u.Host + "/" + p
}
}

registry = strings.Trim(registry, "/")
if registry == "" {
return "", "", false
}

hasPath := strings.Contains(registry, "/")
ref := registry
if !hasPath {
ref += "/wings"
}

named, err := reference.ParseNormalizedNamed(ref)
if err != nil {
return "", "", false
}

path := ""
if hasPath {
path = reference.Path(named)
}
domain := strings.ToLower(reference.Domain(named))
if domain == "docker.io" && (path == "v1" || path == "v2") {
path = ""
}

return domain, path, true
}

func registryPathMatchesImage(registryPath string, imagePath string) bool {
if registryPath == "" {
return true
}

return imagePath == registryPath || strings.HasPrefix(imagePath, registryPath+"/")
}

// RegistryConfiguration defines the authentication credentials for a given
// Docker registry.
Expand Down
99 changes: 99 additions & 0 deletions config/config_docker_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package config

import "testing"

func TestDockerRegistryCredentialsForImage(t *testing.T) {
cfg := DockerConfiguration{
Registries: map[string]RegistryConfiguration{
"registry.example.com": {
Username: "registry",
Password: "secret",
},
"registry.example.com/team": {
Username: "team",
Password: "secret",
},
"registry.example.com:5000": {
Username: "port",
Password: "secret",
},
"https://index.docker.io/v1/": {
Username: "docker",
Password: "secret",
},
},
}

tests := []struct {
name string
image string
username string
}{
{
name: "registry domain",
image: "registry.example.com/project/image:latest",
username: "registry",
},
{
name: "registry with port",
image: "registry.example.com:5000/project/image:latest",
username: "port",
},
{
name: "registry path",
image: "registry.example.com/team/image:latest",
username: "team",
},
{
name: "registry prefix is not domain",
image: "registry.example.com.evil/project/image:latest",
},
{
name: "registry path prefix falls back to domain",
image: "registry.example.com/team-evil/image:latest",
username: "registry",
},
{
name: "legacy docker hub registry",
image: "docker.io/library/busybox:latest",
username: "docker",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, registry := cfg.RegistryCredentialsForImage(tt.image)
if tt.username == "" {
if registry != nil {
t.Fatalf("expected no registry credentials, got username %q", registry.Username)
}

return
}

if registry == nil {
t.Fatalf("expected registry credentials for %q", tt.image)
}

if registry.Username != tt.username {
t.Fatalf("expected username %q, got %q", tt.username, registry.Username)
}
})
}
}

func TestDockerRegistryPathCredentialsDoNotMatchSiblingPath(t *testing.T) {
cfg := DockerConfiguration{
Registries: map[string]RegistryConfiguration{
"registry.example.com/team": {
Username: "team",
Password: "secret",
},
},
}

_, registry := cfg.RegistryCredentialsForImage("registry.example.com/team-evil/image:latest")
if registry != nil {
t.Fatalf("expected no registry credentials, got username %q", registry.Username)
}
}
10 changes: 2 additions & 8 deletions environment/docker/container.go
Original file line number Diff line number Diff line change
Expand Up @@ -390,15 +390,9 @@ func (e *Environment) ensureImageExists(image string) error {
defer cancel()

// Get a registry auth configuration from the config.
var registryAuth *config.RegistryConfiguration
for registry, c := range config.Get().Docker.Registries {
if !strings.HasPrefix(image, registry) {
continue
}

registry, registryAuth := config.Get().Docker.RegistryCredentialsForImage(image)
if registryAuth != nil {
log.WithField("registry", registry).Debug("using authentication for registry")
registryAuth = &c
break
}

// Get the ImagePullOptions.
Expand Down
2 changes: 1 addition & 1 deletion internal/ufs/fs_darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,5 +59,5 @@ func (fs *UnixFS) Chtimesat(dirfd int, name string, atime, mtime time.Time) erro
unix.NsecToTimespec(atime.UnixNano()),
unix.NsecToTimespec(mtime.UnixNano()),
}
return ensurePathError(unix.UtimesNanoAt(dirfd, name, utimes[0:], 0), "chtimes", name)
return ensurePathError(unix.UtimesNanoAt(dirfd, name, utimes[0:], AT_SYMLINK_NOFOLLOW), "chtimes", name)
}
2 changes: 1 addition & 1 deletion internal/ufs/fs_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,5 +70,5 @@ func (fs *UnixFS) Chtimesat(dirfd int, name string, atime, mtime time.Time) erro
set(1, mtime)

// This does support `AT_SYMLINK_NOFOLLOW` as well if needed.
return ensurePathError(unix.UtimesNanoAt(dirfd, name, utimes[0:], 0), "chtimes", name)
return ensurePathError(unix.UtimesNanoAt(dirfd, name, utimes[0:], AT_SYMLINK_NOFOLLOW), "chtimes", name)
}
43 changes: 32 additions & 11 deletions internal/ufs/fs_quota.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
package ufs

import (
"math"
"sync/atomic"
)

Expand Down Expand Up @@ -67,14 +68,33 @@ func (fs *Quota) SetUsage(newUsage int64) int64 {

// Add adds `i` to the tracked usage total.
func (fs *Quota) Add(i int64) int64 {
usage := fs.Usage()
for {
usage := fs.Usage()
var next int64

switch {
case i > 0:
if usage > math.MaxInt64-i {
next = math.MaxInt64
} else {
next = usage + i
}
case i < 0:
if i == math.MinInt64 {
next = 0
} else if usage <= -i {
next = 0
} else {
next = usage + i
}
default:
return usage
}

// If adding `i` to the usage will put us below 0, cap it. (`i` can be negative)
if usage+i < 0 {
fs.usage.Store(0)
return 0
if fs.usage.CompareAndSwap(usage, next) {
return next
}
}
return fs.usage.Add(i)
}

// CanFit checks if the given size can fit in the filesystem without exceeding
Expand All @@ -98,14 +118,15 @@ func (fs *Quota) CanFit(size int64) bool {
return true
}

// If the current usage + the requested size are under the limit of the
// filesystem, allow it.
if usage+size <= limit {
if size <= 0 {
return true
}

// Welp, the size would exceed the limit of the filesystem, deny it.
return false
if usage >= limit {
return false
}

return size <= limit-usage
}

// Remove removes the named file or (empty) directory.
Expand Down
Loading
Loading