detached 56bea54

fix: harden image pulls and container runtime

commit
56bea5463be37c48b4561ec8d679d0f9c5b2592c
author
brice · · 2026-04-25 10:12 UTC
parent
07c8b0c
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
14 files · +402 -75
M docs/config/deploy.md +12 -0
@@ -98,6 +98,18 @@ container shutdown.
9898
9999 Default: `"30s"`
100100
101+## Container security profile
102+
103+Runtime hardening is configured under `[containers]`:
104+
105+```toml
106+[containers]
107+security_profile = "compat" # "compat" or "strict"
108+```
109+
110+- `compat` is the default and preserves existing image compatibility while keeping `no-new-privileges` and capability drop/add defaults.
111+- `strict` enables a read-only root filesystem, drops all capabilities, and only adds `NET_BIND_SERVICE`. Images that write outside mounted volumes or need extra Linux capabilities may require changes before using this profile.
112+
101113 ## Notes
102114
103115 - Changes to `deploy.pull_policy` require a restart to take effect.
@@ -98,6 +98,18 @@ container shutdown.
98 98
99 Default: `"30s"`99 Default: `"30s"`
100 100
101+## Container security profile
102+
103+Runtime hardening is configured under `[containers]`:
104+
105+```toml
106+[containers]
107+security_profile = "compat" # "compat" or "strict"
108+```
109+
110+- `compat` is the default and preserves existing image compatibility while keeping `no-new-privileges` and capability drop/add defaults.
111+- `strict` enables a read-only root filesystem, drops all capabilities, and only adds `NET_BIND_SERVICE`. Images that write outside mounted volumes or need extra Linux capabilities may require changes before using this profile.
112+
101 ## Notes113 ## Notes
102 114
103 - Changes to `deploy.pull_policy` require a restart to take effect.115 - Changes to `deploy.pull_policy` require a restart to take effect.
M docs/config/images.md +6 -0
@@ -16,6 +16,10 @@ The CLI `gordon images prune` uses the same defaults as the scheduled job (keep
1616 ## Configuration
1717
1818 ```toml
19+[images]
20+allowed_registries = []
21+require_digest = false
22+
1923 [images.prune]
2024 enabled = false
2125 schedule = "daily"
@@ -26,6 +30,8 @@ keep_last = 3
2630
2731 | Key | Type | Default | Description |
2832 |-----|------|---------|-------------|
33+| `images.allowed_registries` | array | `[]` | Allowlist for explicit external image registries. Empty means no explicit external registries are accepted. Gordon always allows its configured registry and rejects localhost/private/link-local registries. Include ports when needed, e.g. `"registry.example.com:5000"`. |
34+| `images.require_digest` | bool | `false` | Require allowlisted external image references to use a valid `@sha256:<64 hex chars>` digest. Gordon registry images are exempt. |
2935 | `images.prune.enabled` | bool | `false` | Enables scheduled image cleanup |
3036 | `images.prune.schedule` | string | `"daily"` | Schedule preset: `hourly`, `daily`, `weekly`, `monthly` |
3137 | `images.prune.keep_last` | int | `3` | Number of newest non-`latest` tags kept per repository during registry cleanup (`latest` is always kept when present) |
@@ -16,6 +16,10 @@ The CLI `gordon images prune` uses the same defaults as the scheduled job (keep
16 ## Configuration16 ## Configuration
17 17
18 ```toml18 ```toml
19+[images]
20+allowed_registries = []
21+require_digest = false
22+
19 [images.prune]23 [images.prune]
20 enabled = false24 enabled = false
21 schedule = "daily"25 schedule = "daily"
@@ -26,6 +30,8 @@ keep_last = 3
26 30
27 | Key | Type | Default | Description |31 | Key | Type | Default | Description |
28 |-----|------|---------|-------------|32 |-----|------|---------|-------------|
33+| `images.allowed_registries` | array | `[]` | Allowlist for explicit external image registries. Empty means no explicit external registries are accepted. Gordon always allows its configured registry and rejects localhost/private/link-local registries. Include ports when needed, e.g. `"registry.example.com:5000"`. |
34+| `images.require_digest` | bool | `false` | Require allowlisted external image references to use a valid `@sha256:<64 hex chars>` digest. Gordon registry images are exempt. |
29 | `images.prune.enabled` | bool | `false` | Enables scheduled image cleanup |35 | `images.prune.enabled` | bool | `false` | Enables scheduled image cleanup |
30 | `images.prune.schedule` | string | `"daily"` | Schedule preset: `hourly`, `daily`, `weekly`, `monthly` |36 | `images.prune.schedule` | string | `"daily"` | Schedule preset: `hourly`, `daily`, `weekly`, `monthly` |
31 | `images.prune.keep_last` | int | `3` | Number of newest non-`latest` tags kept per repository during registry cleanup (`latest` is always kept when present) |37 | `images.prune.keep_last` | int | `3` | Number of newest non-`latest` tags kept per repository during registry cleanup (`latest` is always kept when present) |
M docs/config/network-isolation.md +2 -0
@@ -8,6 +8,7 @@ Isolate applications in separate Docker networks for enhanced security.
88 [network_isolation]
99 enabled = true
1010 network_prefix = "gordon"
11+internal = false
1112 ```
1213
1314 ## Migration Note
@@ -26,6 +27,7 @@ enabled = false
2627 |--------|------|---------|-------------|
2728 | `enabled` | bool | `true` | Enable per-app network isolation (changed from `false`) |
2829 | `network_prefix` | string | `"gordon"` | Prefix for created networks |
30+| `internal` | bool | `false` | Create isolated Docker networks with Docker's `Internal` flag, blocking direct external egress from containers on those networks. Default remains `false` for compatibility. |
2931
3032 ## How It Works
3133
@@ -8,6 +8,7 @@ Isolate applications in separate Docker networks for enhanced security.
8 [network_isolation]8 [network_isolation]
9 enabled = true9 enabled = true
10 network_prefix = "gordon"10 network_prefix = "gordon"
11+internal = false
11 ```12 ```
12 13
13 ## Migration Note14 ## Migration Note
@@ -26,6 +27,7 @@ enabled = false
26 |--------|------|---------|-------------|27 |--------|------|---------|-------------|
27 | `enabled` | bool | `true` | Enable per-app network isolation (changed from `false`) |28 | `enabled` | bool | `true` | Enable per-app network isolation (changed from `false`) |
28 | `network_prefix` | string | `"gordon"` | Prefix for created networks |29 | `network_prefix` | string | `"gordon"` | Prefix for created networks |
30+| `internal` | bool | `false` | Create isolated Docker networks with Docker's `Internal` flag, blocking direct external egress from containers on those networks. Default remains `false` for compatibility. |
29 31
30 ## How It Works32 ## How It Works
31 33
M docs/config/reference.md +15 -0
@@ -93,6 +93,12 @@ drain_mode = "auto" # "auto", "inflight", "delay"
9393 drain_timeout = "30s" # Max wait for in-flight request drain
9494 drain_delay = "2s" # Wait after cache invalidation before old stop
9595
96+# =============================================================================
97+# CONTAINERS
98+# =============================================================================
99+[containers]
100+security_profile = "compat" # "compat" or "strict"
101+
96102 # =============================================================================
97103 # AUTO-ROUTE
98104 # =============================================================================
@@ -105,6 +111,7 @@ enabled = false # Create routes from image labels a
105111 [network_isolation]
106112 enabled = true # Enable per-app Docker networks
107113 network_prefix = "gordon" # Prefix for created networks
114+internal = false # Create Docker internal networks (blocks direct egress)
108115
109116 # =============================================================================
110117 # VOLUMES
@@ -157,6 +164,10 @@ monthly = 0 # Keep N monthly backups per DB
157164 # =============================================================================
158165 # IMAGES
159166 # =============================================================================
167+[images]
168+allowed_registries = []
169+require_digest = false
170+
160171 [images.prune]
161172 enabled = false # Enable scheduled image cleanup
162173 schedule = "daily" # "hourly", "daily", "weekly", "monthly"
@@ -215,9 +226,11 @@ keep_last = 3 # Keep N newest tags per repository
215226 | `deploy.drain_mode` | `"auto"` | Drain strategy (`auto`, `inflight`, `delay`) |
216227 | `deploy.drain_timeout` | `"30s"` | Max wait for in-flight request drain before old stop |
217228 | `deploy.drain_delay` | `"2s"` | Delay before stopping previous container after cache invalidation |
229+| `containers.security_profile` | `"compat"` | Runtime hardening profile: `compat` preserves existing behavior, `strict` enables read-only rootfs and narrower capabilities |
218230 | `auto_route.enabled` | `false` | Auto-route disabled |
219231 | `network_isolation.enabled` | `true` | Network isolation enabled |
220232 | `network_isolation.network_prefix` | `"gordon"` | Network prefix |
233+| `network_isolation.internal` | `false` | Create Docker internal networks without direct external egress |
221234 | `volumes.auto_create` | `true` | Auto-create volumes |
222235 | `volumes.prefix` | `"gordon"` | Volume prefix |
223236 | `volumes.preserve` | `true` | Keep volumes |
@@ -228,6 +241,8 @@ keep_last = 3 # Keep N newest tags per repository
228241 | `backups.retention.daily` | `0` | Keep no daily backups by default (recommend `7`) |
229242 | `backups.retention.weekly` | `0` | Keep no weekly backups by default |
230243 | `backups.retention.monthly` | `0` | Keep no monthly backups by default |
244+| `images.allowed_registries` | `[]` | Explicit external registry allowlist; empty rejects explicit external registries; dangerous local/private registries are always rejected |
245+| `images.require_digest` | `false` | Require digest-pinned references for allowlisted external registries |
231246 | `images.prune.enabled` | `false` | Scheduled image cleanup disabled |
232247 | `images.prune.schedule` | `"daily"` | Cleanup schedule preset |
233248 | `images.prune.keep_last` | `3` | Number of recent tags kept per repository |
@@ -93,6 +93,12 @@ drain_mode = "auto" # "auto", "inflight", "delay"
93 drain_timeout = "30s" # Max wait for in-flight request drain93 drain_timeout = "30s" # Max wait for in-flight request drain
94 drain_delay = "2s" # Wait after cache invalidation before old stop94 drain_delay = "2s" # Wait after cache invalidation before old stop
95 95
96+# =============================================================================
97+# CONTAINERS
98+# =============================================================================
99+[containers]
100+security_profile = "compat" # "compat" or "strict"
101+
96 # =============================================================================102 # =============================================================================
97 # AUTO-ROUTE103 # AUTO-ROUTE
98 # =============================================================================104 # =============================================================================
@@ -105,6 +111,7 @@ enabled = false # Create routes from image labels a
105 [network_isolation]111 [network_isolation]
106 enabled = true # Enable per-app Docker networks112 enabled = true # Enable per-app Docker networks
107 network_prefix = "gordon" # Prefix for created networks113 network_prefix = "gordon" # Prefix for created networks
114+internal = false # Create Docker internal networks (blocks direct egress)
108 115
109 # =============================================================================116 # =============================================================================
110 # VOLUMES117 # VOLUMES
@@ -157,6 +164,10 @@ monthly = 0 # Keep N monthly backups per DB
157 # =============================================================================164 # =============================================================================
158 # IMAGES165 # IMAGES
159 # =============================================================================166 # =============================================================================
167+[images]
168+allowed_registries = []
169+require_digest = false
170+
160 [images.prune]171 [images.prune]
161 enabled = false # Enable scheduled image cleanup172 enabled = false # Enable scheduled image cleanup
162 schedule = "daily" # "hourly", "daily", "weekly", "monthly"173 schedule = "daily" # "hourly", "daily", "weekly", "monthly"
@@ -215,9 +226,11 @@ keep_last = 3 # Keep N newest tags per repository
215 | `deploy.drain_mode` | `"auto"` | Drain strategy (`auto`, `inflight`, `delay`) |226 | `deploy.drain_mode` | `"auto"` | Drain strategy (`auto`, `inflight`, `delay`) |
216 | `deploy.drain_timeout` | `"30s"` | Max wait for in-flight request drain before old stop |227 | `deploy.drain_timeout` | `"30s"` | Max wait for in-flight request drain before old stop |
217 | `deploy.drain_delay` | `"2s"` | Delay before stopping previous container after cache invalidation |228 | `deploy.drain_delay` | `"2s"` | Delay before stopping previous container after cache invalidation |
229+| `containers.security_profile` | `"compat"` | Runtime hardening profile: `compat` preserves existing behavior, `strict` enables read-only rootfs and narrower capabilities |
218 | `auto_route.enabled` | `false` | Auto-route disabled |230 | `auto_route.enabled` | `false` | Auto-route disabled |
219 | `network_isolation.enabled` | `true` | Network isolation enabled |231 | `network_isolation.enabled` | `true` | Network isolation enabled |
220 | `network_isolation.network_prefix` | `"gordon"` | Network prefix |232 | `network_isolation.network_prefix` | `"gordon"` | Network prefix |
233+| `network_isolation.internal` | `false` | Create Docker internal networks without direct external egress |
221 | `volumes.auto_create` | `true` | Auto-create volumes |234 | `volumes.auto_create` | `true` | Auto-create volumes |
222 | `volumes.prefix` | `"gordon"` | Volume prefix |235 | `volumes.prefix` | `"gordon"` | Volume prefix |
223 | `volumes.preserve` | `true` | Keep volumes |236 | `volumes.preserve` | `true` | Keep volumes |
@@ -228,6 +241,8 @@ keep_last = 3 # Keep N newest tags per repository
228 | `backups.retention.daily` | `0` | Keep no daily backups by default (recommend `7`) |241 | `backups.retention.daily` | `0` | Keep no daily backups by default (recommend `7`) |
229 | `backups.retention.weekly` | `0` | Keep no weekly backups by default |242 | `backups.retention.weekly` | `0` | Keep no weekly backups by default |
230 | `backups.retention.monthly` | `0` | Keep no monthly backups by default |243 | `backups.retention.monthly` | `0` | Keep no monthly backups by default |
244+| `images.allowed_registries` | `[]` | Explicit external registry allowlist; empty rejects explicit external registries; dangerous local/private registries are always rejected |
245+| `images.require_digest` | `false` | Require digest-pinned references for allowlisted external registries |
231 | `images.prune.enabled` | `false` | Scheduled image cleanup disabled |246 | `images.prune.enabled` | `false` | Scheduled image cleanup disabled |
232 | `images.prune.schedule` | `"daily"` | Cleanup schedule preset |247 | `images.prune.schedule` | `"daily"` | Cleanup schedule preset |
233 | `images.prune.keep_last` | `3` | Number of recent tags kept per repository |248 | `images.prune.keep_last` | `3` | Number of recent tags kept per repository |
M internal/adapters/out/docker/runtime.go +29 -29
@@ -161,6 +161,7 @@ func (r *Runtime) CreateContainer(ctx context.Context, config *domain.ContainerC
161161 WorkingDir: config.WorkingDir,
162162 Cmd: config.Cmd,
163163 Labels: config.Labels,
164+ User: config.User,
164165 }
165166
166167 resources := container.Resources{
@@ -173,20 +174,24 @@ func (r *Runtime) CreateContainer(ctx context.Context, config *domain.ContainerC
173174 if config.PidsLimit > 0 {
174175 resources.PidsLimit = &config.PidsLimit
175176 }
177+ capDrop := strslice.StrSlice{"ALL"}
178+ capAdd := strslice.StrSlice{"CHOWN", "DAC_OVERRIDE", "FOWNER", "SETUID", "SETGID", "NET_BIND_SERVICE"}
179+ if config.CapDrop != nil {
180+ capDrop = strslice.StrSlice(config.CapDrop)
181+ }
182+ if config.CapAdd != nil {
183+ capAdd = strslice.StrSlice(config.CapAdd)
184+ }
176185 hostConfig := &container.HostConfig{
177- PortBindings: portBindings,
178- AutoRemove: config.AutoRemove,
179- Binds: binds,
180- NetworkMode: container.NetworkMode(config.NetworkMode),
181- Resources: resources,
182- SecurityOpt: []string{"no-new-privileges:true"},
183- CapDrop: strslice.StrSlice{"ALL"},
184- // Re-add the minimal set of capabilities that standard images need.
185- // CHOWN/FOWNER/DAC_OVERRIDE: postgres, mysql etc. chown/chmod data dirs
186- // owned by their service user during entrypoint init.
187- // SETUID/SETGID: gosu/su-exec to drop privileges after init.
188- // NET_BIND_SERVICE: nginx etc. binding to ports < 1024.
189- CapAdd: strslice.StrSlice{"CHOWN", "DAC_OVERRIDE", "FOWNER", "SETUID", "SETGID", "NET_BIND_SERVICE"},
186+ PortBindings: portBindings,
187+ AutoRemove: config.AutoRemove,
188+ Binds: binds,
189+ NetworkMode: container.NetworkMode(config.NetworkMode),
190+ Resources: resources,
191+ SecurityOpt: []string{"no-new-privileges:true"},
192+ CapDrop: capDrop,
193+ CapAdd: capAdd,
194+ ReadonlyRootfs: config.ReadOnlyRootFS,
190195 }
191196
192197 // Create network configuration for container
@@ -1251,7 +1256,7 @@ func (r *Runtime) GetImageID(ctx context.Context, imageRef string) (string, erro
12511256 }
12521257
12531258 // CreateNetwork creates a new Docker network.
1254-func (r *Runtime) CreateNetwork(ctx context.Context, name string, options map[string]string) error {
1259+func (r *Runtime) CreateNetwork(ctx context.Context, name string, config domain.NetworkConfig) error {
12551260 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
12561261 zerowrap.FieldLayer: "adapter",
12571262 zerowrap.FieldAdapter: "docker",
@@ -1260,24 +1265,19 @@ func (r *Runtime) CreateNetwork(ctx context.Context, name string, options map[st
12601265 })
12611266 log := zerowrap.FromCtx(ctx)
12621267
1263- // Set default driver to bridge if not specified
1264- driver := "bridge"
1265- if driverOption, exists := options["driver"]; exists {
1266- driver = driverOption
1268+ driver := config.Driver
1269+ if driver == "" {
1270+ driver = "bridge"
12671271 }
1268-
1269- createOptions := network.CreateOptions{
1270- Driver: driver,
1271- Labels: map[string]string{
1272- domain.LabelManaged: "true",
1273- },
1272+ labels := map[string]string{domain.LabelManaged: "true"}
1273+ for key, value := range config.Labels {
1274+ labels[key] = value
12741275 }
12751276
1276- // Add any additional options to labels
1277- for key, value := range options {
1278- if key != "driver" {
1279- createOptions.Labels["gordon."+key] = value
1280- }
1277+ createOptions := network.CreateOptions{
1278+ Driver: driver,
1279+ Internal: config.Internal,
1280+ Labels: labels,
12811281 }
12821282
12831283 _, err := r.client.NetworkCreate(ctx, name, createOptions)
@@ -161,6 +161,7 @@ func (r *Runtime) CreateContainer(ctx context.Context, config *domain.ContainerC
161 WorkingDir: config.WorkingDir,161 WorkingDir: config.WorkingDir,
162 Cmd: config.Cmd,162 Cmd: config.Cmd,
163 Labels: config.Labels,163 Labels: config.Labels,
164+ User: config.User,
164 }165 }
165 166
166 resources := container.Resources{167 resources := container.Resources{
@@ -173,20 +174,24 @@ func (r *Runtime) CreateContainer(ctx context.Context, config *domain.ContainerC
173 if config.PidsLimit > 0 {174 if config.PidsLimit > 0 {
174 resources.PidsLimit = &config.PidsLimit175 resources.PidsLimit = &config.PidsLimit
175 }176 }
177+ capDrop := strslice.StrSlice{"ALL"}
178+ capAdd := strslice.StrSlice{"CHOWN", "DAC_OVERRIDE", "FOWNER", "SETUID", "SETGID", "NET_BIND_SERVICE"}
179+ if config.CapDrop != nil {
180+ capDrop = strslice.StrSlice(config.CapDrop)
181+ }
182+ if config.CapAdd != nil {
183+ capAdd = strslice.StrSlice(config.CapAdd)
184+ }
176 hostConfig := &container.HostConfig{185 hostConfig := &container.HostConfig{
177- PortBindings: portBindings,186+ PortBindings: portBindings,
178- AutoRemove: config.AutoRemove,187+ AutoRemove: config.AutoRemove,
179- Binds: binds,188+ Binds: binds,
180- NetworkMode: container.NetworkMode(config.NetworkMode),189+ NetworkMode: container.NetworkMode(config.NetworkMode),
181- Resources: resources,190+ Resources: resources,
182- SecurityOpt: []string{"no-new-privileges:true"},191+ SecurityOpt: []string{"no-new-privileges:true"},
183- CapDrop: strslice.StrSlice{"ALL"},192+ CapDrop: capDrop,
184- // Re-add the minimal set of capabilities that standard images need.193+ CapAdd: capAdd,
185- // CHOWN/FOWNER/DAC_OVERRIDE: postgres, mysql etc. chown/chmod data dirs194+ ReadonlyRootfs: config.ReadOnlyRootFS,
186- // owned by their service user during entrypoint init.
187- // SETUID/SETGID: gosu/su-exec to drop privileges after init.
188- // NET_BIND_SERVICE: nginx etc. binding to ports < 1024.
189- CapAdd: strslice.StrSlice{"CHOWN", "DAC_OVERRIDE", "FOWNER", "SETUID", "SETGID", "NET_BIND_SERVICE"},
190 }195 }
191 196
192 // Create network configuration for container197 // Create network configuration for container
@@ -1251,7 +1256,7 @@ func (r *Runtime) GetImageID(ctx context.Context, imageRef string) (string, erro
1251 }1256 }
1252 1257
1253 // CreateNetwork creates a new Docker network.1258 // CreateNetwork creates a new Docker network.
1254-func (r *Runtime) CreateNetwork(ctx context.Context, name string, options map[string]string) error {1259+func (r *Runtime) CreateNetwork(ctx context.Context, name string, config domain.NetworkConfig) error {
1255 ctx = zerowrap.CtxWithFields(ctx, map[string]any{1260 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1256 zerowrap.FieldLayer: "adapter",1261 zerowrap.FieldLayer: "adapter",
1257 zerowrap.FieldAdapter: "docker",1262 zerowrap.FieldAdapter: "docker",
@@ -1260,24 +1265,19 @@ func (r *Runtime) CreateNetwork(ctx context.Context, name string, options map[st
1260 })1265 })
1261 log := zerowrap.FromCtx(ctx)1266 log := zerowrap.FromCtx(ctx)
1262 1267
1263- // Set default driver to bridge if not specified1268+ driver := config.Driver
1264- driver := "bridge"1269+ if driver == "" {
1265- if driverOption, exists := options["driver"]; exists {1270+ driver = "bridge"
1266- driver = driverOption
1267 }1271 }
1268-1272+ labels := map[string]string{domain.LabelManaged: "true"}
1269- createOptions := network.CreateOptions{1273+ for key, value := range config.Labels {
1270- Driver: driver,1274+ labels[key] = value
1271- Labels: map[string]string{
1272- domain.LabelManaged: "true",
1273- },
1274 }1275 }
1275 1276
1276- // Add any additional options to labels1277+ createOptions := network.CreateOptions{
1277- for key, value := range options {1278+ Driver: driver,
1278- if key != "driver" {1279+ Internal: config.Internal,
1279- createOptions.Labels["gordon."+key] = value1280+ Labels: labels,
1280- }
1281 }1281 }
1282 1282
1283 _, err := r.client.NetworkCreate(ctx, name, createOptions)1283 _, err := r.client.NetworkCreate(ctx, name, createOptions)
M internal/app/run.go +15 -4
@@ -172,7 +172,9 @@ type Config struct {
172172 } `mapstructure:"backups"`
173173
174174 Images struct {
175- Prune struct {
175+ AllowedRegistries []string `mapstructure:"allowed_registries"`
176+ RequireDigest bool `mapstructure:"require_digest"`
177+ Prune struct {
176178 Enabled bool `mapstructure:"enabled"`
177179 Schedule string `mapstructure:"schedule"`
178180 KeepLast int `mapstructure:"keep_last"`
@@ -180,9 +182,10 @@ type Config struct {
180182 } `mapstructure:"images"`
181183
182184 Containers struct {
183- MemoryLimit string `mapstructure:"memory_limit"` // e.g., "512MB", "1GB"
184- CPULimit float64 `mapstructure:"cpu_limit"` // CPU cores, e.g., 1.0 = 1 core
185- PidsLimit int64 `mapstructure:"pids_limit"` // e.g., 512
185+ MemoryLimit string `mapstructure:"memory_limit"` // e.g., "512MB", "1GB"
186+ CPULimit float64 `mapstructure:"cpu_limit"` // CPU cores, e.g., 1.0 = 1 core
187+ PidsLimit int64 `mapstructure:"pids_limit"` // e.g., 512
188+ SecurityProfile string `mapstructure:"security_profile"` // compat or strict
186189 } `mapstructure:"containers"`
187190
188191 Telemetry telemetry.Config `mapstructure:"telemetry"`
@@ -1612,7 +1615,11 @@ func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc
16121615 NetworkIsolation: v.GetBool("network_isolation.enabled"),
16131616 NetworkPrefix: v.GetString("network_isolation.network_prefix"),
16141617 NetworkGroups: attachmentConfig.NetworkGroups,
1618+ NetworkInternal: v.GetBool("network_isolation.internal"),
16151619 Attachments: attachmentConfig.Attachments,
1620+ AllowedRegistries: cfg.Images.AllowedRegistries,
1621+ RequireImageDigest: cfg.Images.RequireDigest,
1622+ SecurityProfile: cfg.Containers.SecurityProfile,
16161623 ReadinessDelay: v.GetDuration("deploy.readiness_delay"),
16171624 ReadinessMode: v.GetString("deploy.readiness_mode"),
16181625 HealthTimeout: v.GetDuration("deploy.health_timeout"),
@@ -2959,6 +2966,7 @@ func loadConfig(v *viper.Viper, configPath string) error {
29592966 v.SetDefault("auto_route.enabled", false)
29602967 v.SetDefault("network_isolation.enabled", true)
29612968 v.SetDefault("network_isolation.network_prefix", "gordon")
2969+ v.SetDefault("network_isolation.internal", false)
29622970 v.SetDefault("volumes.auto_create", true)
29632971 v.SetDefault("volumes.prefix", "gordon")
29642972 v.SetDefault("volumes.preserve", true)
@@ -2970,9 +2978,12 @@ func loadConfig(v *viper.Viper, configPath string) error {
29702978 v.SetDefault("backups.retention.daily", 0)
29712979 v.SetDefault("backups.retention.weekly", 0)
29722980 v.SetDefault("backups.retention.monthly", 0)
2981+ v.SetDefault("images.allowed_registries", []string{})
2982+ v.SetDefault("images.require_digest", false)
29732983 v.SetDefault("images.prune.enabled", false)
29742984 v.SetDefault("images.prune.schedule", string(domain.ScheduleDaily))
29752985 v.SetDefault("images.prune.keep_last", domain.DefaultImagePruneKeepLast)
2986+ v.SetDefault("containers.security_profile", "compat")
29762987 v.SetDefault("telemetry.enabled", false)
29772988 v.SetDefault("telemetry.endpoint", "")
29782989 v.SetDefault("telemetry.auth_token", "")
@@ -172,7 +172,9 @@ type Config struct {
172 } `mapstructure:"backups"`172 } `mapstructure:"backups"`
173 173
174 Images struct {174 Images struct {
175- Prune struct {175+ AllowedRegistries []string `mapstructure:"allowed_registries"`
176+ RequireDigest bool `mapstructure:"require_digest"`
177+ Prune struct {
176 Enabled bool `mapstructure:"enabled"`178 Enabled bool `mapstructure:"enabled"`
177 Schedule string `mapstructure:"schedule"`179 Schedule string `mapstructure:"schedule"`
178 KeepLast int `mapstructure:"keep_last"`180 KeepLast int `mapstructure:"keep_last"`
@@ -180,9 +182,10 @@ type Config struct {
180 } `mapstructure:"images"`182 } `mapstructure:"images"`
181 183
182 Containers struct {184 Containers struct {
183- MemoryLimit string `mapstructure:"memory_limit"` // e.g., "512MB", "1GB"185+ MemoryLimit string `mapstructure:"memory_limit"` // e.g., "512MB", "1GB"
184- CPULimit float64 `mapstructure:"cpu_limit"` // CPU cores, e.g., 1.0 = 1 core186+ CPULimit float64 `mapstructure:"cpu_limit"` // CPU cores, e.g., 1.0 = 1 core
185- PidsLimit int64 `mapstructure:"pids_limit"` // e.g., 512187+ PidsLimit int64 `mapstructure:"pids_limit"` // e.g., 512
188+ SecurityProfile string `mapstructure:"security_profile"` // compat or strict
186 } `mapstructure:"containers"`189 } `mapstructure:"containers"`
187 190
188 Telemetry telemetry.Config `mapstructure:"telemetry"`191 Telemetry telemetry.Config `mapstructure:"telemetry"`
@@ -1612,7 +1615,11 @@ func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc
1612 NetworkIsolation: v.GetBool("network_isolation.enabled"),1615 NetworkIsolation: v.GetBool("network_isolation.enabled"),
1613 NetworkPrefix: v.GetString("network_isolation.network_prefix"),1616 NetworkPrefix: v.GetString("network_isolation.network_prefix"),
1614 NetworkGroups: attachmentConfig.NetworkGroups,1617 NetworkGroups: attachmentConfig.NetworkGroups,
1618+ NetworkInternal: v.GetBool("network_isolation.internal"),
1615 Attachments: attachmentConfig.Attachments,1619 Attachments: attachmentConfig.Attachments,
1620+ AllowedRegistries: cfg.Images.AllowedRegistries,
1621+ RequireImageDigest: cfg.Images.RequireDigest,
1622+ SecurityProfile: cfg.Containers.SecurityProfile,
1616 ReadinessDelay: v.GetDuration("deploy.readiness_delay"),1623 ReadinessDelay: v.GetDuration("deploy.readiness_delay"),
1617 ReadinessMode: v.GetString("deploy.readiness_mode"),1624 ReadinessMode: v.GetString("deploy.readiness_mode"),
1618 HealthTimeout: v.GetDuration("deploy.health_timeout"),1625 HealthTimeout: v.GetDuration("deploy.health_timeout"),
@@ -2959,6 +2966,7 @@ func loadConfig(v *viper.Viper, configPath string) error {
2959 v.SetDefault("auto_route.enabled", false)2966 v.SetDefault("auto_route.enabled", false)
2960 v.SetDefault("network_isolation.enabled", true)2967 v.SetDefault("network_isolation.enabled", true)
2961 v.SetDefault("network_isolation.network_prefix", "gordon")2968 v.SetDefault("network_isolation.network_prefix", "gordon")
2969+ v.SetDefault("network_isolation.internal", false)
2962 v.SetDefault("volumes.auto_create", true)2970 v.SetDefault("volumes.auto_create", true)
2963 v.SetDefault("volumes.prefix", "gordon")2971 v.SetDefault("volumes.prefix", "gordon")
2964 v.SetDefault("volumes.preserve", true)2972 v.SetDefault("volumes.preserve", true)
@@ -2970,9 +2978,12 @@ func loadConfig(v *viper.Viper, configPath string) error {
2970 v.SetDefault("backups.retention.daily", 0)2978 v.SetDefault("backups.retention.daily", 0)
2971 v.SetDefault("backups.retention.weekly", 0)2979 v.SetDefault("backups.retention.weekly", 0)
2972 v.SetDefault("backups.retention.monthly", 0)2980 v.SetDefault("backups.retention.monthly", 0)
2981+ v.SetDefault("images.allowed_registries", []string{})
2982+ v.SetDefault("images.require_digest", false)
2973 v.SetDefault("images.prune.enabled", false)2983 v.SetDefault("images.prune.enabled", false)
2974 v.SetDefault("images.prune.schedule", string(domain.ScheduleDaily))2984 v.SetDefault("images.prune.schedule", string(domain.ScheduleDaily))
2975 v.SetDefault("images.prune.keep_last", domain.DefaultImagePruneKeepLast)2985 v.SetDefault("images.prune.keep_last", domain.DefaultImagePruneKeepLast)
2986+ v.SetDefault("containers.security_profile", "compat")
2976 v.SetDefault("telemetry.enabled", false)2987 v.SetDefault("telemetry.enabled", false)
2977 v.SetDefault("telemetry.endpoint", "")2988 v.SetDefault("telemetry.endpoint", "")
2978 v.SetDefault("telemetry.auth_token", "")2989 v.SetDefault("telemetry.auth_token", "")
M internal/boundaries/out/mocks/mock_container_runtime.go +11 -11
@@ -246,16 +246,16 @@ func (_c *MockContainerRuntime_CreateContainer_Call) RunAndReturn(run func(ctx c
246246 }
247247
248248 // CreateNetwork provides a mock function for the type MockContainerRuntime
249-func (_mock *MockContainerRuntime) CreateNetwork(ctx context.Context, name string, options map[string]string) error {
250- ret := _mock.Called(ctx, name, options)
249+func (_mock *MockContainerRuntime) CreateNetwork(ctx context.Context, name string, config domain.NetworkConfig) error {
250+ ret := _mock.Called(ctx, name, config)
251251
252252 if len(ret) == 0 {
253253 panic("no return value specified for CreateNetwork")
254254 }
255255
256256 var r0 error
257- if returnFunc, ok := ret.Get(0).(func(context.Context, string, map[string]string) error); ok {
258- r0 = returnFunc(ctx, name, options)
257+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, domain.NetworkConfig) error); ok {
258+ r0 = returnFunc(ctx, name, config)
259259 } else {
260260 r0 = ret.Error(0)
261261 }
@@ -270,12 +270,12 @@ type MockContainerRuntime_CreateNetwork_Call struct {
270270 // CreateNetwork is a helper method to define mock.On call
271271 // - ctx context.Context
272272 // - name string
273-// - options map[string]string
274-func (_e *MockContainerRuntime_Expecter) CreateNetwork(ctx interface{}, name interface{}, options interface{}) *MockContainerRuntime_CreateNetwork_Call {
275- return &MockContainerRuntime_CreateNetwork_Call{Call: _e.mock.On("CreateNetwork", ctx, name, options)}
273+// - config domain.NetworkConfig
274+func (_e *MockContainerRuntime_Expecter) CreateNetwork(ctx interface{}, name interface{}, config interface{}) *MockContainerRuntime_CreateNetwork_Call {
275+ return &MockContainerRuntime_CreateNetwork_Call{Call: _e.mock.On("CreateNetwork", ctx, name, config)}
276276 }
277277
278-func (_c *MockContainerRuntime_CreateNetwork_Call) Run(run func(ctx context.Context, name string, options map[string]string)) *MockContainerRuntime_CreateNetwork_Call {
278+func (_c *MockContainerRuntime_CreateNetwork_Call) Run(run func(ctx context.Context, name string, config domain.NetworkConfig)) *MockContainerRuntime_CreateNetwork_Call {
279279 _c.Call.Run(func(args mock.Arguments) {
280280 var arg0 context.Context
281281 if args[0] != nil {
@@ -285,9 +285,9 @@ func (_c *MockContainerRuntime_CreateNetwork_Call) Run(run func(ctx context.Cont
285285 if args[1] != nil {
286286 arg1 = args[1].(string)
287287 }
288- var arg2 map[string]string
288+ var arg2 domain.NetworkConfig
289289 if args[2] != nil {
290- arg2 = args[2].(map[string]string)
290+ arg2 = args[2].(domain.NetworkConfig)
291291 }
292292 run(
293293 arg0,
@@ -303,7 +303,7 @@ func (_c *MockContainerRuntime_CreateNetwork_Call) Return(err error) *MockContai
303303 return _c
304304 }
305305
306-func (_c *MockContainerRuntime_CreateNetwork_Call) RunAndReturn(run func(ctx context.Context, name string, options map[string]string) error) *MockContainerRuntime_CreateNetwork_Call {
306+func (_c *MockContainerRuntime_CreateNetwork_Call) RunAndReturn(run func(ctx context.Context, name string, config domain.NetworkConfig) error) *MockContainerRuntime_CreateNetwork_Call {
307307 _c.Call.Return(run)
308308 return _c
309309 }
@@ -246,16 +246,16 @@ func (_c *MockContainerRuntime_CreateContainer_Call) RunAndReturn(run func(ctx c
246 }246 }
247 247
248 // CreateNetwork provides a mock function for the type MockContainerRuntime248 // CreateNetwork provides a mock function for the type MockContainerRuntime
249-func (_mock *MockContainerRuntime) CreateNetwork(ctx context.Context, name string, options map[string]string) error {249+func (_mock *MockContainerRuntime) CreateNetwork(ctx context.Context, name string, config domain.NetworkConfig) error {
250- ret := _mock.Called(ctx, name, options)250+ ret := _mock.Called(ctx, name, config)
251 251
252 if len(ret) == 0 {252 if len(ret) == 0 {
253 panic("no return value specified for CreateNetwork")253 panic("no return value specified for CreateNetwork")
254 }254 }
255 255
256 var r0 error256 var r0 error
257- if returnFunc, ok := ret.Get(0).(func(context.Context, string, map[string]string) error); ok {257+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, domain.NetworkConfig) error); ok {
258- r0 = returnFunc(ctx, name, options)258+ r0 = returnFunc(ctx, name, config)
259 } else {259 } else {
260 r0 = ret.Error(0)260 r0 = ret.Error(0)
261 }261 }
@@ -270,12 +270,12 @@ type MockContainerRuntime_CreateNetwork_Call struct {
270 // CreateNetwork is a helper method to define mock.On call270 // CreateNetwork is a helper method to define mock.On call
271 // - ctx context.Context271 // - ctx context.Context
272 // - name string272 // - name string
273-// - options map[string]string273+// - config domain.NetworkConfig
274-func (_e *MockContainerRuntime_Expecter) CreateNetwork(ctx interface{}, name interface{}, options interface{}) *MockContainerRuntime_CreateNetwork_Call {274+func (_e *MockContainerRuntime_Expecter) CreateNetwork(ctx interface{}, name interface{}, config interface{}) *MockContainerRuntime_CreateNetwork_Call {
275- return &MockContainerRuntime_CreateNetwork_Call{Call: _e.mock.On("CreateNetwork", ctx, name, options)}275+ return &MockContainerRuntime_CreateNetwork_Call{Call: _e.mock.On("CreateNetwork", ctx, name, config)}
276 }276 }
277 277
278-func (_c *MockContainerRuntime_CreateNetwork_Call) Run(run func(ctx context.Context, name string, options map[string]string)) *MockContainerRuntime_CreateNetwork_Call {278+func (_c *MockContainerRuntime_CreateNetwork_Call) Run(run func(ctx context.Context, name string, config domain.NetworkConfig)) *MockContainerRuntime_CreateNetwork_Call {
279 _c.Call.Run(func(args mock.Arguments) {279 _c.Call.Run(func(args mock.Arguments) {
280 var arg0 context.Context280 var arg0 context.Context
281 if args[0] != nil {281 if args[0] != nil {
@@ -285,9 +285,9 @@ func (_c *MockContainerRuntime_CreateNetwork_Call) Run(run func(ctx context.Cont
285 if args[1] != nil {285 if args[1] != nil {
286 arg1 = args[1].(string)286 arg1 = args[1].(string)
287 }287 }
288- var arg2 map[string]string288+ var arg2 domain.NetworkConfig
289 if args[2] != nil {289 if args[2] != nil {
290- arg2 = args[2].(map[string]string)290+ arg2 = args[2].(domain.NetworkConfig)
291 }291 }
292 run(292 run(
293 arg0,293 arg0,
@@ -303,7 +303,7 @@ func (_c *MockContainerRuntime_CreateNetwork_Call) Return(err error) *MockContai
303 return _c303 return _c
304 }304 }
305 305
306-func (_c *MockContainerRuntime_CreateNetwork_Call) RunAndReturn(run func(ctx context.Context, name string, options map[string]string) error) *MockContainerRuntime_CreateNetwork_Call {306+func (_c *MockContainerRuntime_CreateNetwork_Call) RunAndReturn(run func(ctx context.Context, name string, config domain.NetworkConfig) error) *MockContainerRuntime_CreateNetwork_Call {
307 _c.Call.Return(run)307 _c.Call.Return(run)
308 return _c308 return _c
309 }309 }
M internal/boundaries/out/runtime.go +1 -1
@@ -70,7 +70,7 @@ type ContainerRuntime interface {
7070 CopyFromContainer(ctx context.Context, containerID, srcPath string) (io.ReadCloser, error)
7171
7272 // Network management
73- CreateNetwork(ctx context.Context, name string, options map[string]string) error
73+ CreateNetwork(ctx context.Context, name string, config domain.NetworkConfig) error
7474 RemoveNetwork(ctx context.Context, name string) error
7575 ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error)
7676 NetworkExists(ctx context.Context, name string) (bool, error)
@@ -70,7 +70,7 @@ type ContainerRuntime interface {
70 CopyFromContainer(ctx context.Context, containerID, srcPath string) (io.ReadCloser, error)70 CopyFromContainer(ctx context.Context, containerID, srcPath string) (io.ReadCloser, error)
71 71
72 // Network management72 // Network management
73- CreateNetwork(ctx context.Context, name string, options map[string]string) error73+ CreateNetwork(ctx context.Context, name string, config domain.NetworkConfig) error
74 RemoveNetwork(ctx context.Context, name string) error74 RemoveNetwork(ctx context.Context, name string) error
75 ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error)75 ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error)
76 NetworkExists(ctx context.Context, name string) (bool, error)76 NetworkExists(ctx context.Context, name string) (bool, error)
M internal/domain/container.go +11 -0
@@ -26,6 +26,13 @@ type NetworkInfo struct {
2626 Labels map[string]string
2727 }
2828
29+// NetworkConfig holds options for creating a container network.
30+type NetworkConfig struct {
31+ Driver string
32+ Internal bool
33+ Labels map[string]string
34+}
35+
2936 // Attachment represents an attached service container.
3037 type Attachment struct {
3138 Name string
@@ -64,6 +71,10 @@ type ContainerConfig struct {
6471 MemoryLimit int64 // Memory limit in bytes (0 = no limit)
6572 NanoCPUs int64 // CPU quota in nanoseconds (1e9 = 1 core, 0 = no limit)
6673 PidsLimit int64 // Max number of PIDs (0 = no limit)
74+ ReadOnlyRootFS bool // Mount container root filesystem read-only
75+ User string // User to run as
76+ CapDrop []string // Linux capabilities to drop; nil uses runtime compat defaults
77+ CapAdd []string // Linux capabilities to add; nil uses runtime compat defaults
6778 }
6879
6980 // ContainerStatus represents the current state of a container.
@@ -26,6 +26,13 @@ type NetworkInfo struct {
26 Labels map[string]string26 Labels map[string]string
27 }27 }
28 28
29+// NetworkConfig holds options for creating a container network.
30+type NetworkConfig struct {
31+ Driver string
32+ Internal bool
33+ Labels map[string]string
34+}
35+
29 // Attachment represents an attached service container.36 // Attachment represents an attached service container.
30 type Attachment struct {37 type Attachment struct {
31 Name string38 Name string
@@ -64,6 +71,10 @@ type ContainerConfig struct {
64 MemoryLimit int64 // Memory limit in bytes (0 = no limit)71 MemoryLimit int64 // Memory limit in bytes (0 = no limit)
65 NanoCPUs int64 // CPU quota in nanoseconds (1e9 = 1 core, 0 = no limit)72 NanoCPUs int64 // CPU quota in nanoseconds (1e9 = 1 core, 0 = no limit)
66 PidsLimit int64 // Max number of PIDs (0 = no limit)73 PidsLimit int64 // Max number of PIDs (0 = no limit)
74+ ReadOnlyRootFS bool // Mount container root filesystem read-only
75+ User string // User to run as
76+ CapDrop []string // Linux capabilities to drop; nil uses runtime compat defaults
77+ CapAdd []string // Linux capabilities to add; nil uses runtime compat defaults
67 }78 }
68 79
69 // ContainerStatus represents the current state of a container.80 // ContainerStatus represents the current state of a container.
M internal/testutils/mocks/runtime_gen.go +2 -2
@@ -72,9 +72,9 @@ func (mr *MockRuntimeMockRecorder) CreateContainer(ctx, config any) *gomock.Call
7272 }
7373
7474 // CreateNetwork mocks base method.
75-func (m *MockRuntime) CreateNetwork(ctx context.Context, name string, options map[string]string) error {
75+func (m *MockRuntime) CreateNetwork(ctx context.Context, name string, config runtime.NetworkConfig) error {
7676 m.ctrl.T.Helper()
77- ret := m.ctrl.Call(m, "CreateNetwork", ctx, name, options)
77+ ret := m.ctrl.Call(m, "CreateNetwork", ctx, name, config)
7878 ret0, _ := ret[0].(error)
7979 return ret0
8080 }
@@ -72,9 +72,9 @@ func (mr *MockRuntimeMockRecorder) CreateContainer(ctx, config any) *gomock.Call
72 }72 }
73 73
74 // CreateNetwork mocks base method.74 // CreateNetwork mocks base method.
75-func (m *MockRuntime) CreateNetwork(ctx context.Context, name string, options map[string]string) error {75+func (m *MockRuntime) CreateNetwork(ctx context.Context, name string, config runtime.NetworkConfig) error {
76 m.ctrl.T.Helper()76 m.ctrl.T.Helper()
77- ret := m.ctrl.Call(m, "CreateNetwork", ctx, name, options)77+ ret := m.ctrl.Call(m, "CreateNetwork", ctx, name, config)
78 ret0, _ := ret[0].(error)78 ret0, _ := ret[0].(error)
79 return ret079 return ret0
80 }80 }
M internal/usecase/backup/integration_test.go +4 -1
@@ -47,7 +47,7 @@ func runPostgresBackupFlow(t *testing.T, ctx context.Context, runtime *docker.Ru
4747 require.NoError(t, runtime.PullImage(pullCtx, image))
4848 netCtx, cancelNet := context.WithTimeout(ctx, 2*time.Minute)
4949 defer cancelNet()
50- require.NoError(t, runtime.CreateNetwork(netCtx, networkName, map[string]string{"driver": "bridge"}))
50+ require.NoError(t, runtime.CreateNetwork(netCtx, networkName, domain.NetworkConfig{Driver: "bridge"}))
5151 t.Cleanup(func() {
5252 cleanupCtx, cancelCleanup := context.WithTimeout(context.Background(), 20*time.Second)
5353 defer cancelCleanup()
@@ -239,6 +239,9 @@ func (s *integrationContainerService) SyncContainers(context.Context) error {
239239 return nil
240240 }
241241
242+func (s *integrationContainerService) UpdateAttachments(map[string][]string) {
243+}
244+
242245 func (s *integrationContainerService) AutoStart(context.Context, []domain.Route) error {
243246 return nil
244247 }
@@ -47,7 +47,7 @@ func runPostgresBackupFlow(t *testing.T, ctx context.Context, runtime *docker.Ru
47 require.NoError(t, runtime.PullImage(pullCtx, image))47 require.NoError(t, runtime.PullImage(pullCtx, image))
48 netCtx, cancelNet := context.WithTimeout(ctx, 2*time.Minute)48 netCtx, cancelNet := context.WithTimeout(ctx, 2*time.Minute)
49 defer cancelNet()49 defer cancelNet()
50- require.NoError(t, runtime.CreateNetwork(netCtx, networkName, map[string]string{"driver": "bridge"}))50+ require.NoError(t, runtime.CreateNetwork(netCtx, networkName, domain.NetworkConfig{Driver: "bridge"}))
51 t.Cleanup(func() {51 t.Cleanup(func() {
52 cleanupCtx, cancelCleanup := context.WithTimeout(context.Background(), 20*time.Second)52 cleanupCtx, cancelCleanup := context.WithTimeout(context.Background(), 20*time.Second)
53 defer cancelCleanup()53 defer cancelCleanup()
@@ -239,6 +239,9 @@ func (s *integrationContainerService) SyncContainers(context.Context) error {
239 return nil239 return nil
240 }240 }
241 241
242+func (s *integrationContainerService) UpdateAttachments(map[string][]string) {
243+}
244+
242 func (s *integrationContainerService) AutoStart(context.Context, []domain.Route) error {245 func (s *integrationContainerService) AutoStart(context.Context, []domain.Route) error {
243 return nil246 return nil
244 }247 }
M internal/usecase/container/service.go +179 -25
@@ -5,10 +5,12 @@ import (
55 "bufio"
66 "context"
77 "encoding/binary"
8+ "encoding/hex"
89 "errors"
910 "fmt"
1011 "io"
1112 "maps"
13+ "net"
1214 "slices"
1315 "strconv"
1416 "strings"
@@ -43,7 +45,11 @@ type Config struct {
4345 NetworkIsolation bool
4446 NetworkPrefix string
4547 NetworkGroups map[string][]string
48+ NetworkInternal bool
4649 Attachments map[string][]string
50+ AllowedRegistries []string
51+ RequireImageDigest bool
52+ SecurityProfile string
4753 ReadinessDelay time.Duration // Delay after container starts before considering it ready
4854 ReadinessMode string // Readiness strategy: auto, docker-health, delay
4955 HealthTimeout time.Duration // Max wait for health-based readiness
@@ -268,7 +274,7 @@ func (s *Service) buildContainerConfig(in containerConfigInput) *domain.Containe
268274 }
269275 }
270276
271- return &domain.ContainerConfig{
277+ containerConfig := &domain.ContainerConfig{
272278 Image: in.ImageRef,
273279 Name: containerName,
274280 Ports: ports,
@@ -282,6 +288,8 @@ func (s *Service) buildContainerConfig(in containerConfigInput) *domain.Containe
282288 NanoCPUs: cfg.DefaultNanoCPUs,
283289 PidsLimit: cfg.DefaultPidsLimit,
284290 }
291+ applySecurityProfile(containerConfig, cfg)
292+ return containerConfig
285293 }
286294
287295 // Deploy creates and starts a container for the given route.
@@ -561,7 +569,10 @@ func (s *Service) prepareDeployResources(ctx context.Context, route domain.Route
561569 log.WrapErr(err, "failed to cleanup orphaned containers")
562570 }
563571
564- imageRef := s.buildImageRef(route.Image)
572+ imageRef, err := s.buildValidatedImageRef(route.Image)
573+ if err != nil {
574+ return nil, err
575+ }
565576 actualImageRef, err := s.ensureImage(ctx, imageRef)
566577 if err != nil {
567578 return nil, err
@@ -1668,6 +1679,14 @@ func (s *Service) startLogCollection(ctx context.Context, containerID, domainNam
16681679 }
16691680 }
16701681
1682+func (s *Service) buildValidatedImageRef(image string) (string, error) {
1683+ imageRef := s.buildImageRef(image)
1684+ if err := s.validateImageRef(imageRef); err != nil {
1685+ return "", err
1686+ }
1687+ return imageRef, nil
1688+}
1689+
16711690 func (s *Service) buildImageRef(image string) string {
16721691 s.mu.RLock()
16731692 cfg := s.config
@@ -1743,6 +1762,108 @@ func hasExplicitRegistry(image string) bool {
17431762 return false
17441763 }
17451764
1765+func (s *Service) validateImageRef(imageRef string) error {
1766+ s.mu.RLock()
1767+ cfg := s.config
1768+ s.mu.RUnlock()
1769+
1770+ if !hasExplicitRegistry(imageRef) {
1771+ if cfg.RequireImageDigest && !isValidSha256DigestRef(imageRef) {
1772+ return fmt.Errorf("image %q rejected: digest reference required", imageRef)
1773+ }
1774+ return nil
1775+ }
1776+
1777+ registry := imageRegistry(imageRef)
1778+ if registry == "" {
1779+ return nil
1780+ }
1781+
1782+ if sameRegistry(registry, cfg.RegistryDomain) {
1783+ return nil
1784+ }
1785+ if isDangerousRegistryHost(imageRegistryHost(registry)) {
1786+ return fmt.Errorf("image %q rejected: registry %q is not allowed", imageRef, registry)
1787+ }
1788+ if len(cfg.AllowedRegistries) == 0 {
1789+ return fmt.Errorf("image %q rejected: registry %q is not in images.allowed_registries", imageRef, registry)
1790+ }
1791+ for _, allowed := range cfg.AllowedRegistries {
1792+ if sameRegistry(registry, allowed) {
1793+ if cfg.RequireImageDigest && !isValidSha256DigestRef(imageRef) {
1794+ return fmt.Errorf("image %q rejected: digest reference required", imageRef)
1795+ }
1796+ return nil
1797+ }
1798+ }
1799+ return fmt.Errorf("image %q rejected: registry %q is not in images.allowed_registries", imageRef, registry)
1800+}
1801+
1802+func imageRegistry(imageRef string) string {
1803+ slashIdx := strings.Index(imageRef, "/")
1804+ if slashIdx == -1 {
1805+ return ""
1806+ }
1807+ return normalizeRegistry(imageRef[:slashIdx])
1808+}
1809+
1810+func normalizeRegistry(registry string) string {
1811+ registry = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(registry), "."))
1812+ if strings.HasPrefix(registry, "[") {
1813+ if end := strings.Index(registry, "]"); end != -1 {
1814+ host := strings.Trim(registry[:end+1], "[]")
1815+ port := registry[end+1:]
1816+ return host + port
1817+ }
1818+ }
1819+ return registry
1820+}
1821+
1822+func imageRegistryHost(registry string) string {
1823+ if ip := net.ParseIP(registry); ip != nil {
1824+ return registry
1825+ }
1826+ if h, _, err := net.SplitHostPort(registry); err == nil {
1827+ return strings.Trim(h, "[]")
1828+ }
1829+ if strings.HasPrefix(registry, "[") && strings.Contains(registry, "]") {
1830+ return strings.TrimPrefix(strings.Split(registry, "]")[0], "[")
1831+ }
1832+ if colon := strings.LastIndex(registry, ":"); colon != -1 && isNumeric(registry[colon+1:]) {
1833+ return registry[:colon]
1834+ }
1835+ return registry
1836+}
1837+
1838+func sameRegistry(a, b string) bool {
1839+ return normalizeRegistry(a) == normalizeRegistry(b)
1840+}
1841+
1842+func isDangerousRegistryHost(host string) bool {
1843+ host = strings.ToLower(strings.TrimSuffix(host, "."))
1844+ if host == "localhost" || host == "metadata.google.internal" {
1845+ return true
1846+ }
1847+ ips, err := net.LookupIP(host)
1848+ if err != nil {
1849+ return false
1850+ }
1851+ for _, ip := range ips {
1852+ if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsUnspecified() {
1853+ return true
1854+ }
1855+ }
1856+ return false
1857+}
1858+
1859+func applySecurityProfile(config *domain.ContainerConfig, cfg Config) {
1860+ if strings.EqualFold(cfg.SecurityProfile, "strict") {
1861+ config.ReadOnlyRootFS = true
1862+ config.CapDrop = []string{"ALL"}
1863+ config.CapAdd = []string{"NET_BIND_SERVICE"}
1864+ }
1865+}
1866+
17461867 // isNumeric checks if a string contains only digits.
17471868 func isNumeric(s string) bool {
17481869 for _, c := range s {
@@ -1767,7 +1888,16 @@ func normalizePullPolicy(policy string) string {
17671888 }
17681889
17691890 func isDigestRef(imageRef string) bool {
1770- return strings.Contains(imageRef, "@sha256:")
1891+ return isValidSha256DigestRef(imageRef)
1892+}
1893+
1894+func isValidSha256DigestRef(imageRef string) bool {
1895+ _, digest, ok := strings.Cut(imageRef, "@sha256:")
1896+ if !ok || len(digest) != 64 {
1897+ return false
1898+ }
1899+ _, err := hex.DecodeString(digest)
1900+ return err == nil
17711901 }
17721902
17731903 func (s *Service) pullRefForDeploy(ctx context.Context, imageRef string) (string, bool) {
@@ -2101,7 +2231,10 @@ func (s *Service) createNetworkIfNeeded(ctx context.Context, networkName string)
21012231 }
21022232
21032233 if !exists {
2104- if err := s.runtime.CreateNetwork(ctx, networkName, map[string]string{"driver": "bridge"}); err != nil {
2234+ s.mu.RLock()
2235+ internal := s.config.NetworkInternal
2236+ s.mu.RUnlock()
2237+ if err := s.runtime.CreateNetwork(ctx, networkName, domain.NetworkConfig{Driver: "bridge", Internal: internal}); err != nil {
21052238 return log.WrapErr(err, "failed to create network")
21062239 }
21072240 log.Info().Msg("created network for app isolation")
@@ -2415,32 +2548,18 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic
24152548 log.Info().Str(zerowrap.FieldService, serviceImage).Msg("deploying attached service")
24162549
24172550 // Ensure image (canonical ref is returned; internal pulls may use the local registry).
2418- imageRef := s.buildImageRef(serviceImage)
2419- actualImageRef, err := s.ensureImage(ctx, imageRef)
2551+ imageRef, err := s.buildValidatedImageRef(serviceImage)
24202552 if err != nil {
24212553 return err
24222554 }
2423-
2424- // Get image metadata
2425- exposedPorts, err := s.runtime.GetImageExposedPorts(ctx, actualImageRef)
2426- if err != nil {
2427- log.WrapErr(err, "failed to get exposed ports for attachment, using defaults")
2428- exposedPorts = []int{}
2429- }
2430-
2431- // Setup volumes (attachments need persistent data)
2432- volumes, err := s.setupVolumes(ctx, containerName, actualImageRef)
2555+ actualImageRef, err := s.ensureImage(ctx, imageRef)
24332556 if err != nil {
2434- log.WrapErr(err, "failed to setup volumes for attachment")
2435- volumes = make(map[string]string)
2557+ return err
24362558 }
24372559
2438- // Load environment (attachment-specific env file)
2439- envVars, err := s.loadEnvironment(ctx, nil, containerName, actualImageRef)
2440- if err != nil {
2441- log.WrapErr(err, "failed to load environment for attachment")
2442- envVars = []string{}
2443- }
2560+ exposedPorts := s.attachmentExposedPorts(ctx, actualImageRef)
2561+ volumes := s.attachmentVolumes(ctx, containerName, actualImageRef)
2562+ envVars := s.attachmentEnv(ctx, containerName, actualImageRef)
24442563 envHash := hashEnvironment(envVars)
24452564
24462565 s.mu.RLock()
@@ -2468,6 +2587,7 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic
24682587 NanoCPUs: cfg.DefaultNanoCPUs,
24692588 PidsLimit: cfg.DefaultPidsLimit,
24702589 }
2590+ applySecurityProfile(config, cfg)
24712591
24722592 container, err := s.runtime.CreateContainer(ctx, config)
24732593 if err != nil {
@@ -2502,8 +2622,42 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic
25022622 return nil
25032623 }
25042624
2625+func (s *Service) attachmentExposedPorts(ctx context.Context, imageRef string) []int {
2626+ log := zerowrap.FromCtx(ctx)
2627+ exposedPorts, err := s.runtime.GetImageExposedPorts(ctx, imageRef)
2628+ if err != nil {
2629+ log.WrapErr(err, "failed to get exposed ports for attachment, using defaults")
2630+ return []int{}
2631+ }
2632+ return exposedPorts
2633+}
2634+
2635+func (s *Service) attachmentVolumes(ctx context.Context, containerName, imageRef string) map[string]string {
2636+ log := zerowrap.FromCtx(ctx)
2637+ volumes, err := s.setupVolumes(ctx, containerName, imageRef)
2638+ if err != nil {
2639+ log.WrapErr(err, "failed to setup volumes for attachment")
2640+ return make(map[string]string)
2641+ }
2642+ return volumes
2643+}
2644+
2645+func (s *Service) attachmentEnv(ctx context.Context, containerName, imageRef string) []string {
2646+ log := zerowrap.FromCtx(ctx)
2647+ envVars, err := s.loadEnvironment(ctx, nil, containerName, imageRef)
2648+ if err != nil {
2649+ log.WrapErr(err, "failed to load environment for attachment")
2650+ return []string{}
2651+ }
2652+ return envVars
2653+}
2654+
25052655 func (s *Service) attachmentEnvDrifted(ctx context.Context, existing *domain.Container, containerName, serviceImage string) (bool, error) {
2506- currentEnv, err := s.loadEnvironment(ctx, nil, containerName, s.buildImageRef(serviceImage))
2656+ imageRef, err := s.buildValidatedImageRef(serviceImage)
2657+ if err != nil {
2658+ return false, err
2659+ }
2660+ currentEnv, err := s.loadEnvironment(ctx, nil, containerName, imageRef)
25072661 if err != nil {
25082662 return false, err
25092663 }
@@ -5,10 +5,12 @@ import (
5 "bufio"5 "bufio"
6 "context"6 "context"
7 "encoding/binary"7 "encoding/binary"
8+ "encoding/hex"
8 "errors"9 "errors"
9 "fmt"10 "fmt"
10 "io"11 "io"
11 "maps"12 "maps"
13+ "net"
12 "slices"14 "slices"
13 "strconv"15 "strconv"
14 "strings"16 "strings"
@@ -43,7 +45,11 @@ type Config struct {
43 NetworkIsolation bool45 NetworkIsolation bool
44 NetworkPrefix string46 NetworkPrefix string
45 NetworkGroups map[string][]string47 NetworkGroups map[string][]string
48+ NetworkInternal bool
46 Attachments map[string][]string49 Attachments map[string][]string
50+ AllowedRegistries []string
51+ RequireImageDigest bool
52+ SecurityProfile string
47 ReadinessDelay time.Duration // Delay after container starts before considering it ready53 ReadinessDelay time.Duration // Delay after container starts before considering it ready
48 ReadinessMode string // Readiness strategy: auto, docker-health, delay54 ReadinessMode string // Readiness strategy: auto, docker-health, delay
49 HealthTimeout time.Duration // Max wait for health-based readiness55 HealthTimeout time.Duration // Max wait for health-based readiness
@@ -268,7 +274,7 @@ func (s *Service) buildContainerConfig(in containerConfigInput) *domain.Containe
268 }274 }
269 }275 }
270 276
271- return &domain.ContainerConfig{277+ containerConfig := &domain.ContainerConfig{
272 Image: in.ImageRef,278 Image: in.ImageRef,
273 Name: containerName,279 Name: containerName,
274 Ports: ports,280 Ports: ports,
@@ -282,6 +288,8 @@ func (s *Service) buildContainerConfig(in containerConfigInput) *domain.Containe
282 NanoCPUs: cfg.DefaultNanoCPUs,288 NanoCPUs: cfg.DefaultNanoCPUs,
283 PidsLimit: cfg.DefaultPidsLimit,289 PidsLimit: cfg.DefaultPidsLimit,
284 }290 }
291+ applySecurityProfile(containerConfig, cfg)
292+ return containerConfig
285 }293 }
286 294
287 // Deploy creates and starts a container for the given route.295 // Deploy creates and starts a container for the given route.
@@ -561,7 +569,10 @@ func (s *Service) prepareDeployResources(ctx context.Context, route domain.Route
561 log.WrapErr(err, "failed to cleanup orphaned containers")569 log.WrapErr(err, "failed to cleanup orphaned containers")
562 }570 }
563 571
564- imageRef := s.buildImageRef(route.Image)572+ imageRef, err := s.buildValidatedImageRef(route.Image)
573+ if err != nil {
574+ return nil, err
575+ }
565 actualImageRef, err := s.ensureImage(ctx, imageRef)576 actualImageRef, err := s.ensureImage(ctx, imageRef)
566 if err != nil {577 if err != nil {
567 return nil, err578 return nil, err
@@ -1668,6 +1679,14 @@ func (s *Service) startLogCollection(ctx context.Context, containerID, domainNam
1668 }1679 }
1669 }1680 }
1670 1681
1682+func (s *Service) buildValidatedImageRef(image string) (string, error) {
1683+ imageRef := s.buildImageRef(image)
1684+ if err := s.validateImageRef(imageRef); err != nil {
1685+ return "", err
1686+ }
1687+ return imageRef, nil
1688+}
1689+
1671 func (s *Service) buildImageRef(image string) string {1690 func (s *Service) buildImageRef(image string) string {
1672 s.mu.RLock()1691 s.mu.RLock()
1673 cfg := s.config1692 cfg := s.config
@@ -1743,6 +1762,108 @@ func hasExplicitRegistry(image string) bool {
1743 return false1762 return false
1744 }1763 }
1745 1764
1765+func (s *Service) validateImageRef(imageRef string) error {
1766+ s.mu.RLock()
1767+ cfg := s.config
1768+ s.mu.RUnlock()
1769+
1770+ if !hasExplicitRegistry(imageRef) {
1771+ if cfg.RequireImageDigest && !isValidSha256DigestRef(imageRef) {
1772+ return fmt.Errorf("image %q rejected: digest reference required", imageRef)
1773+ }
1774+ return nil
1775+ }
1776+
1777+ registry := imageRegistry(imageRef)
1778+ if registry == "" {
1779+ return nil
1780+ }
1781+
1782+ if sameRegistry(registry, cfg.RegistryDomain) {
1783+ return nil
1784+ }
1785+ if isDangerousRegistryHost(imageRegistryHost(registry)) {
1786+ return fmt.Errorf("image %q rejected: registry %q is not allowed", imageRef, registry)
1787+ }
1788+ if len(cfg.AllowedRegistries) == 0 {
1789+ return fmt.Errorf("image %q rejected: registry %q is not in images.allowed_registries", imageRef, registry)
1790+ }
1791+ for _, allowed := range cfg.AllowedRegistries {
1792+ if sameRegistry(registry, allowed) {
1793+ if cfg.RequireImageDigest && !isValidSha256DigestRef(imageRef) {
1794+ return fmt.Errorf("image %q rejected: digest reference required", imageRef)
1795+ }
1796+ return nil
1797+ }
1798+ }
1799+ return fmt.Errorf("image %q rejected: registry %q is not in images.allowed_registries", imageRef, registry)
1800+}
1801+
1802+func imageRegistry(imageRef string) string {
1803+ slashIdx := strings.Index(imageRef, "/")
1804+ if slashIdx == -1 {
1805+ return ""
1806+ }
1807+ return normalizeRegistry(imageRef[:slashIdx])
1808+}
1809+
1810+func normalizeRegistry(registry string) string {
1811+ registry = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(registry), "."))
1812+ if strings.HasPrefix(registry, "[") {
1813+ if end := strings.Index(registry, "]"); end != -1 {
1814+ host := strings.Trim(registry[:end+1], "[]")
1815+ port := registry[end+1:]
1816+ return host + port
1817+ }
1818+ }
1819+ return registry
1820+}
1821+
1822+func imageRegistryHost(registry string) string {
1823+ if ip := net.ParseIP(registry); ip != nil {
1824+ return registry
1825+ }
1826+ if h, _, err := net.SplitHostPort(registry); err == nil {
1827+ return strings.Trim(h, "[]")
1828+ }
1829+ if strings.HasPrefix(registry, "[") && strings.Contains(registry, "]") {
1830+ return strings.TrimPrefix(strings.Split(registry, "]")[0], "[")
1831+ }
1832+ if colon := strings.LastIndex(registry, ":"); colon != -1 && isNumeric(registry[colon+1:]) {
1833+ return registry[:colon]
1834+ }
1835+ return registry
1836+}
1837+
1838+func sameRegistry(a, b string) bool {
1839+ return normalizeRegistry(a) == normalizeRegistry(b)
1840+}
1841+
1842+func isDangerousRegistryHost(host string) bool {
1843+ host = strings.ToLower(strings.TrimSuffix(host, "."))
1844+ if host == "localhost" || host == "metadata.google.internal" {
1845+ return true
1846+ }
1847+ ips, err := net.LookupIP(host)
1848+ if err != nil {
1849+ return false
1850+ }
1851+ for _, ip := range ips {
1852+ if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsUnspecified() {
1853+ return true
1854+ }
1855+ }
1856+ return false
1857+}
1858+
1859+func applySecurityProfile(config *domain.ContainerConfig, cfg Config) {
1860+ if strings.EqualFold(cfg.SecurityProfile, "strict") {
1861+ config.ReadOnlyRootFS = true
1862+ config.CapDrop = []string{"ALL"}
1863+ config.CapAdd = []string{"NET_BIND_SERVICE"}
1864+ }
1865+}
1866+
1746 // isNumeric checks if a string contains only digits.1867 // isNumeric checks if a string contains only digits.
1747 func isNumeric(s string) bool {1868 func isNumeric(s string) bool {
1748 for _, c := range s {1869 for _, c := range s {
@@ -1767,7 +1888,16 @@ func normalizePullPolicy(policy string) string {
1767 }1888 }
1768 1889
1769 func isDigestRef(imageRef string) bool {1890 func isDigestRef(imageRef string) bool {
1770- return strings.Contains(imageRef, "@sha256:")1891+ return isValidSha256DigestRef(imageRef)
1892+}
1893+
1894+func isValidSha256DigestRef(imageRef string) bool {
1895+ _, digest, ok := strings.Cut(imageRef, "@sha256:")
1896+ if !ok || len(digest) != 64 {
1897+ return false
1898+ }
1899+ _, err := hex.DecodeString(digest)
1900+ return err == nil
1771 }1901 }
1772 1902
1773 func (s *Service) pullRefForDeploy(ctx context.Context, imageRef string) (string, bool) {1903 func (s *Service) pullRefForDeploy(ctx context.Context, imageRef string) (string, bool) {
@@ -2101,7 +2231,10 @@ func (s *Service) createNetworkIfNeeded(ctx context.Context, networkName string)
2101 }2231 }
2102 2232
2103 if !exists {2233 if !exists {
2104- if err := s.runtime.CreateNetwork(ctx, networkName, map[string]string{"driver": "bridge"}); err != nil {2234+ s.mu.RLock()
2235+ internal := s.config.NetworkInternal
2236+ s.mu.RUnlock()
2237+ if err := s.runtime.CreateNetwork(ctx, networkName, domain.NetworkConfig{Driver: "bridge", Internal: internal}); err != nil {
2105 return log.WrapErr(err, "failed to create network")2238 return log.WrapErr(err, "failed to create network")
2106 }2239 }
2107 log.Info().Msg("created network for app isolation")2240 log.Info().Msg("created network for app isolation")
@@ -2415,32 +2548,18 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic
2415 log.Info().Str(zerowrap.FieldService, serviceImage).Msg("deploying attached service")2548 log.Info().Str(zerowrap.FieldService, serviceImage).Msg("deploying attached service")
2416 2549
2417 // Ensure image (canonical ref is returned; internal pulls may use the local registry).2550 // Ensure image (canonical ref is returned; internal pulls may use the local registry).
2418- imageRef := s.buildImageRef(serviceImage)2551+ imageRef, err := s.buildValidatedImageRef(serviceImage)
2419- actualImageRef, err := s.ensureImage(ctx, imageRef)
2420 if err != nil {2552 if err != nil {
2421 return err2553 return err
2422 }2554 }
2423-2555+ actualImageRef, err := s.ensureImage(ctx, imageRef)
2424- // Get image metadata
2425- exposedPorts, err := s.runtime.GetImageExposedPorts(ctx, actualImageRef)
2426- if err != nil {
2427- log.WrapErr(err, "failed to get exposed ports for attachment, using defaults")
2428- exposedPorts = []int{}
2429- }
2430-
2431- // Setup volumes (attachments need persistent data)
2432- volumes, err := s.setupVolumes(ctx, containerName, actualImageRef)
2433 if err != nil {2556 if err != nil {
2434- log.WrapErr(err, "failed to setup volumes for attachment")2557+ return err
2435- volumes = make(map[string]string)
2436 }2558 }
2437 2559
2438- // Load environment (attachment-specific env file)2560+ exposedPorts := s.attachmentExposedPorts(ctx, actualImageRef)
2439- envVars, err := s.loadEnvironment(ctx, nil, containerName, actualImageRef)2561+ volumes := s.attachmentVolumes(ctx, containerName, actualImageRef)
2440- if err != nil {2562+ envVars := s.attachmentEnv(ctx, containerName, actualImageRef)
2441- log.WrapErr(err, "failed to load environment for attachment")
2442- envVars = []string{}
2443- }
2444 envHash := hashEnvironment(envVars)2563 envHash := hashEnvironment(envVars)
2445 2564
2446 s.mu.RLock()2565 s.mu.RLock()
@@ -2468,6 +2587,7 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic
2468 NanoCPUs: cfg.DefaultNanoCPUs,2587 NanoCPUs: cfg.DefaultNanoCPUs,
2469 PidsLimit: cfg.DefaultPidsLimit,2588 PidsLimit: cfg.DefaultPidsLimit,
2470 }2589 }
2590+ applySecurityProfile(config, cfg)
2471 2591
2472 container, err := s.runtime.CreateContainer(ctx, config)2592 container, err := s.runtime.CreateContainer(ctx, config)
2473 if err != nil {2593 if err != nil {
@@ -2502,8 +2622,42 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic
2502 return nil2622 return nil
2503 }2623 }
2504 2624
2625+func (s *Service) attachmentExposedPorts(ctx context.Context, imageRef string) []int {
2626+ log := zerowrap.FromCtx(ctx)
2627+ exposedPorts, err := s.runtime.GetImageExposedPorts(ctx, imageRef)
2628+ if err != nil {
2629+ log.WrapErr(err, "failed to get exposed ports for attachment, using defaults")
2630+ return []int{}
2631+ }
2632+ return exposedPorts
2633+}
2634+
2635+func (s *Service) attachmentVolumes(ctx context.Context, containerName, imageRef string) map[string]string {
2636+ log := zerowrap.FromCtx(ctx)
2637+ volumes, err := s.setupVolumes(ctx, containerName, imageRef)
2638+ if err != nil {
2639+ log.WrapErr(err, "failed to setup volumes for attachment")
2640+ return make(map[string]string)
2641+ }
2642+ return volumes
2643+}
2644+
2645+func (s *Service) attachmentEnv(ctx context.Context, containerName, imageRef string) []string {
2646+ log := zerowrap.FromCtx(ctx)
2647+ envVars, err := s.loadEnvironment(ctx, nil, containerName, imageRef)
2648+ if err != nil {
2649+ log.WrapErr(err, "failed to load environment for attachment")
2650+ return []string{}
2651+ }
2652+ return envVars
2653+}
2654+
2505 func (s *Service) attachmentEnvDrifted(ctx context.Context, existing *domain.Container, containerName, serviceImage string) (bool, error) {2655 func (s *Service) attachmentEnvDrifted(ctx context.Context, existing *domain.Container, containerName, serviceImage string) (bool, error) {
2506- currentEnv, err := s.loadEnvironment(ctx, nil, containerName, s.buildImageRef(serviceImage))2656+ imageRef, err := s.buildValidatedImageRef(serviceImage)
2657+ if err != nil {
2658+ return false, err
2659+ }
2660+ currentEnv, err := s.loadEnvironment(ctx, nil, containerName, imageRef)
2507 if err != nil {2661 if err != nil {
2508 return false, err2662 return false, err
2509 }2663 }
M internal/usecase/container/service_test.go +107 -1
@@ -989,7 +989,7 @@ func TestService_Deploy_WithNetworkIsolation(t *testing.T) {
989989
990990 // Network isolation - should check and create network
991991 runtime.EXPECT().NetworkExists(mock.Anything, "gordon-test-example-com").Return(false, nil)
992- runtime.EXPECT().CreateNetwork(mock.Anything, "gordon-test-example-com", map[string]string{"driver": "bridge"}).Return(nil)
992+ runtime.EXPECT().CreateNetwork(mock.Anything, "gordon-test-example-com", domain.NetworkConfig{Driver: "bridge"}).Return(nil)
993993
994994 container := &domain.Container{ID: "container-123", Status: "created"}
995995 runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
@@ -4168,3 +4168,109 @@ func TestDeployAttachedService_SetsAliasOnContainerConfig(t *testing.T) {
41684168 require.Len(t, attachIDs, 1)
41694169 assert.Equal(t, "pg-container-1", attachIDs[0])
41704170 }
4171+
4172+func TestService_BuildValidatedImageRef_RegistryPolicy(t *testing.T) {
4173+ svc := NewService(nil, nil, nil, nil, Config{
4174+ RegistryAuthEnabled: true,
4175+ RegistryDomain: "registry.example.com",
4176+ AllowedRegistries: []string{"docker.io"},
4177+ }, nil)
4178+
4179+ ref, err := svc.buildValidatedImageRef("myapp:latest")
4180+ require.NoError(t, err)
4181+ assert.Equal(t, "registry.example.com/myapp:latest", ref)
4182+
4183+ ref, err = svc.buildValidatedImageRef("registry.example.com/myapp:latest")
4184+ require.NoError(t, err)
4185+ assert.Equal(t, "registry.example.com/myapp:latest", ref)
4186+
4187+ ref, err = svc.buildValidatedImageRef("docker.io/library/nginx:latest")
4188+ require.NoError(t, err)
4189+ assert.Equal(t, "docker.io/library/nginx:latest", ref)
4190+
4191+ for _, image := range []string{
4192+ "ghcr.io/acme/app:latest",
4193+ "localhost:5000/app:latest",
4194+ "127.0.0.1:5000/app:latest",
4195+ "10.0.0.2:5000/app:latest",
4196+ "169.254.169.254/app:latest",
4197+ } {
4198+ t.Run(image, func(t *testing.T) {
4199+ _, err := svc.buildValidatedImageRef(image)
4200+ require.Error(t, err)
4201+ })
4202+ }
4203+}
4204+
4205+func TestService_BuildValidatedImageRef_RejectsExternalRegistryWhenAllowlistEmpty(t *testing.T) {
4206+ svc := NewService(nil, nil, nil, nil, Config{}, nil)
4207+
4208+ _, err := svc.buildValidatedImageRef("ghcr.io/acme/app:latest")
4209+ require.Error(t, err)
4210+ assert.Contains(t, err.Error(), "images.allowed_registries")
4211+}
4212+
4213+func TestService_BuildValidatedImageRef_RequireDigestOnlyForExternalAllowedRegistries(t *testing.T) {
4214+ svc := NewService(nil, nil, nil, nil, Config{
4215+ RegistryAuthEnabled: true,
4216+ RegistryDomain: "registry.example.com",
4217+ RequireImageDigest: true,
4218+ AllowedRegistries: []string{"docker.io"},
4219+ }, nil)
4220+
4221+ _, err := svc.buildValidatedImageRef("docker.io/library/nginx:latest")
4222+ require.Error(t, err)
4223+
4224+ _, err = svc.buildValidatedImageRef("docker.io/library/nginx@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
4225+ require.NoError(t, err)
4226+
4227+ _, err = svc.buildValidatedImageRef("docker.io/library/nginx@sha256:not-a-digest")
4228+ require.Error(t, err)
4229+
4230+ ref, err := svc.buildValidatedImageRef("myapp:latest")
4231+ require.NoError(t, err)
4232+ assert.Equal(t, "registry.example.com/myapp:latest", ref)
4233+
4234+ _, err = svc.buildValidatedImageRef("registry.example.com/myapp:latest")
4235+ require.NoError(t, err)
4236+}
4237+
4238+func TestService_BuildValidatedImageRef_AllowedRegistryMatchesPort(t *testing.T) {
4239+ svc := NewService(nil, nil, nil, nil, Config{AllowedRegistries: []string{"registry.example.com:5000"}}, nil)
4240+
4241+ _, err := svc.buildValidatedImageRef("registry.example.com:5000/app:latest")
4242+ require.NoError(t, err)
4243+
4244+ _, err = svc.buildValidatedImageRef("registry.example.com:5001/app:latest")
4245+ require.Error(t, err)
4246+}
4247+
4248+func TestService_CreateNetworkIfNeeded_UsesInternalOptionWhenConfigured(t *testing.T) {
4249+ runtime := mocks.NewMockContainerRuntime(t)
4250+ svc := NewService(runtime, nil, nil, nil, Config{NetworkInternal: true}, nil)
4251+
4252+ runtime.EXPECT().NetworkExists(mock.Anything, "gordon-app").Return(false, nil)
4253+ runtime.EXPECT().CreateNetwork(mock.Anything, "gordon-app", domain.NetworkConfig{Driver: "bridge", Internal: true}).Return(nil)
4254+
4255+ require.NoError(t, svc.createNetworkIfNeeded(testContext(), "gordon-app"))
4256+}
4257+
4258+func TestService_BuildContainerConfig_StrictSecurityProfile(t *testing.T) {
4259+ svc := NewService(nil, nil, nil, nil, Config{SecurityProfile: "strict"}, nil)
4260+
4261+ cfg := svc.buildContainerConfig(containerConfigInput{Domain: "app.example.com", Image: "app:latest", ImageRef: "app:latest"})
4262+
4263+ assert.True(t, cfg.ReadOnlyRootFS)
4264+ assert.Equal(t, []string{"ALL"}, cfg.CapDrop)
4265+ assert.Equal(t, []string{"NET_BIND_SERVICE"}, cfg.CapAdd)
4266+}
4267+
4268+func TestService_BuildContainerConfig_CompatSecurityProfileDefault(t *testing.T) {
4269+ svc := NewService(nil, nil, nil, nil, Config{}, nil)
4270+
4271+ cfg := svc.buildContainerConfig(containerConfigInput{Domain: "app.example.com", Image: "app:latest", ImageRef: "app:latest"})
4272+
4273+ assert.False(t, cfg.ReadOnlyRootFS)
4274+ assert.Nil(t, cfg.CapDrop)
4275+ assert.Nil(t, cfg.CapAdd)
4276+}
@@ -989,7 +989,7 @@ func TestService_Deploy_WithNetworkIsolation(t *testing.T) {
989 989
990 // Network isolation - should check and create network990 // Network isolation - should check and create network
991 runtime.EXPECT().NetworkExists(mock.Anything, "gordon-test-example-com").Return(false, nil)991 runtime.EXPECT().NetworkExists(mock.Anything, "gordon-test-example-com").Return(false, nil)
992- runtime.EXPECT().CreateNetwork(mock.Anything, "gordon-test-example-com", map[string]string{"driver": "bridge"}).Return(nil)992+ runtime.EXPECT().CreateNetwork(mock.Anything, "gordon-test-example-com", domain.NetworkConfig{Driver: "bridge"}).Return(nil)
993 993
994 container := &domain.Container{ID: "container-123", Status: "created"}994 container := &domain.Container{ID: "container-123", Status: "created"}
995 runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {995 runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
@@ -4168,3 +4168,109 @@ func TestDeployAttachedService_SetsAliasOnContainerConfig(t *testing.T) {
4168 require.Len(t, attachIDs, 1)4168 require.Len(t, attachIDs, 1)
4169 assert.Equal(t, "pg-container-1", attachIDs[0])4169 assert.Equal(t, "pg-container-1", attachIDs[0])
4170 }4170 }
4171+
4172+func TestService_BuildValidatedImageRef_RegistryPolicy(t *testing.T) {
4173+ svc := NewService(nil, nil, nil, nil, Config{
4174+ RegistryAuthEnabled: true,
4175+ RegistryDomain: "registry.example.com",
4176+ AllowedRegistries: []string{"docker.io"},
4177+ }, nil)
4178+
4179+ ref, err := svc.buildValidatedImageRef("myapp:latest")
4180+ require.NoError(t, err)
4181+ assert.Equal(t, "registry.example.com/myapp:latest", ref)
4182+
4183+ ref, err = svc.buildValidatedImageRef("registry.example.com/myapp:latest")
4184+ require.NoError(t, err)
4185+ assert.Equal(t, "registry.example.com/myapp:latest", ref)
4186+
4187+ ref, err = svc.buildValidatedImageRef("docker.io/library/nginx:latest")
4188+ require.NoError(t, err)
4189+ assert.Equal(t, "docker.io/library/nginx:latest", ref)
4190+
4191+ for _, image := range []string{
4192+ "ghcr.io/acme/app:latest",
4193+ "localhost:5000/app:latest",
4194+ "127.0.0.1:5000/app:latest",
4195+ "10.0.0.2:5000/app:latest",
4196+ "169.254.169.254/app:latest",
4197+ } {
4198+ t.Run(image, func(t *testing.T) {
4199+ _, err := svc.buildValidatedImageRef(image)
4200+ require.Error(t, err)
4201+ })
4202+ }
4203+}
4204+
4205+func TestService_BuildValidatedImageRef_RejectsExternalRegistryWhenAllowlistEmpty(t *testing.T) {
4206+ svc := NewService(nil, nil, nil, nil, Config{}, nil)
4207+
4208+ _, err := svc.buildValidatedImageRef("ghcr.io/acme/app:latest")
4209+ require.Error(t, err)
4210+ assert.Contains(t, err.Error(), "images.allowed_registries")
4211+}
4212+
4213+func TestService_BuildValidatedImageRef_RequireDigestOnlyForExternalAllowedRegistries(t *testing.T) {
4214+ svc := NewService(nil, nil, nil, nil, Config{
4215+ RegistryAuthEnabled: true,
4216+ RegistryDomain: "registry.example.com",
4217+ RequireImageDigest: true,
4218+ AllowedRegistries: []string{"docker.io"},
4219+ }, nil)
4220+
4221+ _, err := svc.buildValidatedImageRef("docker.io/library/nginx:latest")
4222+ require.Error(t, err)
4223+
4224+ _, err = svc.buildValidatedImageRef("docker.io/library/nginx@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
4225+ require.NoError(t, err)
4226+
4227+ _, err = svc.buildValidatedImageRef("docker.io/library/nginx@sha256:not-a-digest")
4228+ require.Error(t, err)
4229+
4230+ ref, err := svc.buildValidatedImageRef("myapp:latest")
4231+ require.NoError(t, err)
4232+ assert.Equal(t, "registry.example.com/myapp:latest", ref)
4233+
4234+ _, err = svc.buildValidatedImageRef("registry.example.com/myapp:latest")
4235+ require.NoError(t, err)
4236+}
4237+
4238+func TestService_BuildValidatedImageRef_AllowedRegistryMatchesPort(t *testing.T) {
4239+ svc := NewService(nil, nil, nil, nil, Config{AllowedRegistries: []string{"registry.example.com:5000"}}, nil)
4240+
4241+ _, err := svc.buildValidatedImageRef("registry.example.com:5000/app:latest")
4242+ require.NoError(t, err)
4243+
4244+ _, err = svc.buildValidatedImageRef("registry.example.com:5001/app:latest")
4245+ require.Error(t, err)
4246+}
4247+
4248+func TestService_CreateNetworkIfNeeded_UsesInternalOptionWhenConfigured(t *testing.T) {
4249+ runtime := mocks.NewMockContainerRuntime(t)
4250+ svc := NewService(runtime, nil, nil, nil, Config{NetworkInternal: true}, nil)
4251+
4252+ runtime.EXPECT().NetworkExists(mock.Anything, "gordon-app").Return(false, nil)
4253+ runtime.EXPECT().CreateNetwork(mock.Anything, "gordon-app", domain.NetworkConfig{Driver: "bridge", Internal: true}).Return(nil)
4254+
4255+ require.NoError(t, svc.createNetworkIfNeeded(testContext(), "gordon-app"))
4256+}
4257+
4258+func TestService_BuildContainerConfig_StrictSecurityProfile(t *testing.T) {
4259+ svc := NewService(nil, nil, nil, nil, Config{SecurityProfile: "strict"}, nil)
4260+
4261+ cfg := svc.buildContainerConfig(containerConfigInput{Domain: "app.example.com", Image: "app:latest", ImageRef: "app:latest"})
4262+
4263+ assert.True(t, cfg.ReadOnlyRootFS)
4264+ assert.Equal(t, []string{"ALL"}, cfg.CapDrop)
4265+ assert.Equal(t, []string{"NET_BIND_SERVICE"}, cfg.CapAdd)
4266+}
4267+
4268+func TestService_BuildContainerConfig_CompatSecurityProfileDefault(t *testing.T) {
4269+ svc := NewService(nil, nil, nil, nil, Config{}, nil)
4270+
4271+ cfg := svc.buildContainerConfig(containerConfigInput{Domain: "app.example.com", Image: "app:latest", ImageRef: "app:latest"})
4272+
4273+ assert.False(t, cfg.ReadOnlyRootFS)
4274+ assert.Nil(t, cfg.CapDrop)
4275+ assert.Nil(t, cfg.CapAdd)
4276+}
M pkg/runtime/interface.go +8 -1
@@ -25,6 +25,13 @@ type NetworkInfo struct {
2525 Labels map[string]string
2626 }
2727
28+// NetworkConfig holds options for creating a network.
29+type NetworkConfig struct {
30+ Driver string
31+ Internal bool
32+ Labels map[string]string
33+}
34+
2835 // ContainerConfig holds configuration for creating a container
2936 type ContainerConfig struct {
3037 Image string
@@ -110,7 +117,7 @@ type Runtime interface {
110117 InspectImageEnv(ctx context.Context, imageRef string) ([]string, error)
111118
112119 // Network management
113- CreateNetwork(ctx context.Context, name string, options map[string]string) error
120+ CreateNetwork(ctx context.Context, name string, config NetworkConfig) error
114121 RemoveNetwork(ctx context.Context, name string) error
115122 ListNetworks(ctx context.Context) ([]*NetworkInfo, error)
116123 NetworkExists(ctx context.Context, name string) (bool, error)
@@ -25,6 +25,13 @@ type NetworkInfo struct {
25 Labels map[string]string25 Labels map[string]string
26 }26 }
27 27
28+// NetworkConfig holds options for creating a network.
29+type NetworkConfig struct {
30+ Driver string
31+ Internal bool
32+ Labels map[string]string
33+}
34+
28 // ContainerConfig holds configuration for creating a container35 // ContainerConfig holds configuration for creating a container
29 type ContainerConfig struct {36 type ContainerConfig struct {
30 Image string37 Image string
@@ -110,7 +117,7 @@ type Runtime interface {
110 InspectImageEnv(ctx context.Context, imageRef string) ([]string, error)117 InspectImageEnv(ctx context.Context, imageRef string) ([]string, error)
111 118
112 // Network management119 // Network management
113- CreateNetwork(ctx context.Context, name string, options map[string]string) error120+ CreateNetwork(ctx context.Context, name string, config NetworkConfig) error
114 RemoveNetwork(ctx context.Context, name string) error121 RemoveNetwork(ctx context.Context, name string) error
115 ListNetworks(ctx context.Context) ([]*NetworkInfo, error)122 ListNetworks(ctx context.Context) ([]*NetworkInfo, error)
116 NetworkExists(ctx context.Context, name string) (bool, error)123 NetworkExists(ctx context.Context, name string) (bool, error)

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close