fix: harden image pulls and container runtime
- commit
- 56bea5463be37c48b4561ec8d679d0f9c5b2592c
- author
- brice · · 2026-04-25 10:12 UTC
- parent
- 07c8b0c
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
14 files · +402 -75
M docs/config/deploy.md +12 -0
| @@ -98,6 +98,18 @@ container shutdown. | |||
| 98 | 98 | ||
| 99 | 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 | 113 | ## Notes | |
| 102 | 114 | ||
| 103 | 115 | - 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 | ## Notes | 113 | ## 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 | |||
| 16 | 16 | ## Configuration | |
| 17 | 17 | ||
| 18 | 18 | ```toml | |
| 19 | + | [images] | |
| 20 | + | allowed_registries = [] | |
| 21 | + | require_digest = false | |
| 22 | + | ||
| 19 | 23 | [images.prune] | |
| 20 | 24 | enabled = false | |
| 21 | 25 | schedule = "daily" | |
| @@ -26,6 +30,8 @@ keep_last = 3 | |||
| 26 | 30 | ||
| 27 | 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 | 35 | | `images.prune.enabled` | bool | `false` | Enables scheduled image cleanup | | |
| 30 | 36 | | `images.prune.schedule` | string | `"daily"` | Schedule preset: `hourly`, `daily`, `weekly`, `monthly` | | |
| 31 | 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) | | |
| @@ -16,6 +16,10 @@ The CLI `gordon images prune` uses the same defaults as the scheduled job (keep | |||||
| 16 | ## Configuration | 16 | ## Configuration | ||
| 17 | 17 | ||||
| 18 | ```toml | 18 | ```toml | ||
| 19 | + | [images] | |||
| 20 | + | allowed_registries = [] | |||
| 21 | + | require_digest = false | |||
| 22 | + | ||||
| 19 | [images.prune] | 23 | [images.prune] | ||
| 20 | enabled = false | 24 | 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. | |||
| 8 | 8 | [network_isolation] | |
| 9 | 9 | enabled = true | |
| 10 | 10 | network_prefix = "gordon" | |
| 11 | + | internal = false | |
| 11 | 12 | ``` | |
| 12 | 13 | ||
| 13 | 14 | ## Migration Note | |
| @@ -26,6 +27,7 @@ enabled = false | |||
| 26 | 27 | |--------|------|---------|-------------| | |
| 27 | 28 | | `enabled` | bool | `true` | Enable per-app network isolation (changed from `false`) | | |
| 28 | 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 | 32 | ## How It Works | |
| 31 | 33 | ||
| @@ -8,6 +8,7 @@ Isolate applications in separate Docker networks for enhanced security. | |||||
| 8 | [network_isolation] | 8 | [network_isolation] | ||
| 9 | enabled = true | 9 | enabled = true | ||
| 10 | network_prefix = "gordon" | 10 | network_prefix = "gordon" | ||
| 11 | + | internal = false | |||
| 11 | ``` | 12 | ``` | ||
| 12 | 13 | ||||
| 13 | ## Migration Note | 14 | ## 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 Works | 32 | ## How It Works | ||
| 31 | 33 | ||||
M docs/config/reference.md +15 -0
| @@ -93,6 +93,12 @@ drain_mode = "auto" # "auto", "inflight", "delay" | |||
| 93 | 93 | drain_timeout = "30s" # Max wait for in-flight request drain | |
| 94 | 94 | 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 | 103 | # AUTO-ROUTE | |
| 98 | 104 | # ============================================================================= | |
| @@ -105,6 +111,7 @@ enabled = false # Create routes from image labels a | |||
| 105 | 111 | [network_isolation] | |
| 106 | 112 | enabled = true # Enable per-app Docker networks | |
| 107 | 113 | network_prefix = "gordon" # Prefix for created networks | |
| 114 | + | internal = false # Create Docker internal networks (blocks direct egress) | |
| 108 | 115 | ||
| 109 | 116 | # ============================================================================= | |
| 110 | 117 | # VOLUMES | |
| @@ -157,6 +164,10 @@ monthly = 0 # Keep N monthly backups per DB | |||
| 157 | 164 | # ============================================================================= | |
| 158 | 165 | # IMAGES | |
| 159 | 166 | # ============================================================================= | |
| 167 | + | [images] | |
| 168 | + | allowed_registries = [] | |
| 169 | + | require_digest = false | |
| 170 | + | ||
| 160 | 171 | [images.prune] | |
| 161 | 172 | enabled = false # Enable scheduled image cleanup | |
| 162 | 173 | schedule = "daily" # "hourly", "daily", "weekly", "monthly" | |
| @@ -215,9 +226,11 @@ keep_last = 3 # Keep N newest tags per repository | |||
| 215 | 226 | | `deploy.drain_mode` | `"auto"` | Drain strategy (`auto`, `inflight`, `delay`) | | |
| 216 | 227 | | `deploy.drain_timeout` | `"30s"` | Max wait for in-flight request drain before old stop | | |
| 217 | 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 | 230 | | `auto_route.enabled` | `false` | Auto-route disabled | | |
| 219 | 231 | | `network_isolation.enabled` | `true` | Network isolation enabled | | |
| 220 | 232 | | `network_isolation.network_prefix` | `"gordon"` | Network prefix | | |
| 233 | + | | `network_isolation.internal` | `false` | Create Docker internal networks without direct external egress | | |
| 221 | 234 | | `volumes.auto_create` | `true` | Auto-create volumes | | |
| 222 | 235 | | `volumes.prefix` | `"gordon"` | Volume prefix | | |
| 223 | 236 | | `volumes.preserve` | `true` | Keep volumes | | |
| @@ -228,6 +241,8 @@ keep_last = 3 # Keep N newest tags per repository | |||
| 228 | 241 | | `backups.retention.daily` | `0` | Keep no daily backups by default (recommend `7`) | | |
| 229 | 242 | | `backups.retention.weekly` | `0` | Keep no weekly backups by default | | |
| 230 | 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 | 246 | | `images.prune.enabled` | `false` | Scheduled image cleanup disabled | | |
| 232 | 247 | | `images.prune.schedule` | `"daily"` | Cleanup schedule preset | | |
| 233 | 248 | | `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 drain | 93 | drain_timeout = "30s" # Max wait for in-flight request drain | ||
| 94 | drain_delay = "2s" # Wait after cache invalidation before old stop | 94 | 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-ROUTE | 103 | # 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 networks | 112 | enabled = true # Enable per-app Docker networks | ||
| 107 | network_prefix = "gordon" # Prefix for created networks | 113 | network_prefix = "gordon" # Prefix for created networks | ||
| 114 | + | internal = false # Create Docker internal networks (blocks direct egress) | |||
| 108 | 115 | ||||
| 109 | # ============================================================================= | 116 | # ============================================================================= | ||
| 110 | # VOLUMES | 117 | # VOLUMES | ||
| @@ -157,6 +164,10 @@ monthly = 0 # Keep N monthly backups per DB | |||||
| 157 | # ============================================================================= | 164 | # ============================================================================= | ||
| 158 | # IMAGES | 165 | # 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 cleanup | 172 | 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 | |||
| 161 | 161 | WorkingDir: config.WorkingDir, | |
| 162 | 162 | Cmd: config.Cmd, | |
| 163 | 163 | Labels: config.Labels, | |
| 164 | + | User: config.User, | |
| 164 | 165 | } | |
| 165 | 166 | ||
| 166 | 167 | resources := container.Resources{ | |
| @@ -173,20 +174,24 @@ func (r *Runtime) CreateContainer(ctx context.Context, config *domain.ContainerC | |||
| 173 | 174 | if config.PidsLimit > 0 { | |
| 174 | 175 | 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 | 185 | 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, | |
| 190 | 195 | } | |
| 191 | 196 | ||
| 192 | 197 | // 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 | 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 | 1260 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |
| 1256 | 1261 | zerowrap.FieldLayer: "adapter", | |
| 1257 | 1262 | zerowrap.FieldAdapter: "docker", | |
| @@ -1260,24 +1265,19 @@ func (r *Runtime) CreateNetwork(ctx context.Context, name string, options map[st | |||
| 1260 | 1265 | }) | |
| 1261 | 1266 | log := zerowrap.FromCtx(ctx) | |
| 1262 | 1267 | ||
| 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" | |
| 1267 | 1271 | } | |
| 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 | |
| 1274 | 1275 | } | |
| 1275 | 1276 | ||
| 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, | |
| 1281 | 1281 | } | |
| 1282 | 1282 | ||
| 1283 | 1283 | _, 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.PidsLimit | 175 | 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 dirs | 194 | + | 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 container | 197 | // 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 specified | 1268 | + | 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 labels | 1277 | + | 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] = value | 1280 | + | 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 { | |||
| 172 | 172 | } `mapstructure:"backups"` | |
| 173 | 173 | ||
| 174 | 174 | Images struct { | |
| 175 | - | Prune struct { | |
| 175 | + | AllowedRegistries []string `mapstructure:"allowed_registries"` | |
| 176 | + | RequireDigest bool `mapstructure:"require_digest"` | |
| 177 | + | Prune struct { | |
| 176 | 178 | Enabled bool `mapstructure:"enabled"` | |
| 177 | 179 | Schedule string `mapstructure:"schedule"` | |
| 178 | 180 | KeepLast int `mapstructure:"keep_last"` | |
| @@ -180,9 +182,10 @@ type Config struct { | |||
| 180 | 182 | } `mapstructure:"images"` | |
| 181 | 183 | ||
| 182 | 184 | 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 | |
| 186 | 189 | } `mapstructure:"containers"` | |
| 187 | 190 | ||
| 188 | 191 | Telemetry telemetry.Config `mapstructure:"telemetry"` | |
| @@ -1612,7 +1615,11 @@ func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc | |||
| 1612 | 1615 | NetworkIsolation: v.GetBool("network_isolation.enabled"), | |
| 1613 | 1616 | NetworkPrefix: v.GetString("network_isolation.network_prefix"), | |
| 1614 | 1617 | NetworkGroups: attachmentConfig.NetworkGroups, | |
| 1618 | + | NetworkInternal: v.GetBool("network_isolation.internal"), | |
| 1615 | 1619 | Attachments: attachmentConfig.Attachments, | |
| 1620 | + | AllowedRegistries: cfg.Images.AllowedRegistries, | |
| 1621 | + | RequireImageDigest: cfg.Images.RequireDigest, | |
| 1622 | + | SecurityProfile: cfg.Containers.SecurityProfile, | |
| 1616 | 1623 | ReadinessDelay: v.GetDuration("deploy.readiness_delay"), | |
| 1617 | 1624 | ReadinessMode: v.GetString("deploy.readiness_mode"), | |
| 1618 | 1625 | HealthTimeout: v.GetDuration("deploy.health_timeout"), | |
| @@ -2959,6 +2966,7 @@ func loadConfig(v *viper.Viper, configPath string) error { | |||
| 2959 | 2966 | v.SetDefault("auto_route.enabled", false) | |
| 2960 | 2967 | v.SetDefault("network_isolation.enabled", true) | |
| 2961 | 2968 | v.SetDefault("network_isolation.network_prefix", "gordon") | |
| 2969 | + | v.SetDefault("network_isolation.internal", false) | |
| 2962 | 2970 | v.SetDefault("volumes.auto_create", true) | |
| 2963 | 2971 | v.SetDefault("volumes.prefix", "gordon") | |
| 2964 | 2972 | v.SetDefault("volumes.preserve", true) | |
| @@ -2970,9 +2978,12 @@ func loadConfig(v *viper.Viper, configPath string) error { | |||
| 2970 | 2978 | v.SetDefault("backups.retention.daily", 0) | |
| 2971 | 2979 | v.SetDefault("backups.retention.weekly", 0) | |
| 2972 | 2980 | v.SetDefault("backups.retention.monthly", 0) | |
| 2981 | + | v.SetDefault("images.allowed_registries", []string{}) | |
| 2982 | + | v.SetDefault("images.require_digest", false) | |
| 2973 | 2983 | v.SetDefault("images.prune.enabled", false) | |
| 2974 | 2984 | v.SetDefault("images.prune.schedule", string(domain.ScheduleDaily)) | |
| 2975 | 2985 | v.SetDefault("images.prune.keep_last", domain.DefaultImagePruneKeepLast) | |
| 2986 | + | v.SetDefault("containers.security_profile", "compat") | |
| 2976 | 2987 | v.SetDefault("telemetry.enabled", false) | |
| 2977 | 2988 | v.SetDefault("telemetry.endpoint", "") | |
| 2978 | 2989 | 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 core | 186 | + | CPULimit float64 `mapstructure:"cpu_limit"` // CPU cores, e.g., 1.0 = 1 core |
| 185 | - | PidsLimit int64 `mapstructure:"pids_limit"` // e.g., 512 | 187 | + | 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 | |||
| 246 | 246 | } | |
| 247 | 247 | ||
| 248 | 248 | // 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) | |
| 251 | 251 | ||
| 252 | 252 | if len(ret) == 0 { | |
| 253 | 253 | panic("no return value specified for CreateNetwork") | |
| 254 | 254 | } | |
| 255 | 255 | ||
| 256 | 256 | 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) | |
| 259 | 259 | } else { | |
| 260 | 260 | r0 = ret.Error(0) | |
| 261 | 261 | } | |
| @@ -270,12 +270,12 @@ type MockContainerRuntime_CreateNetwork_Call struct { | |||
| 270 | 270 | // CreateNetwork is a helper method to define mock.On call | |
| 271 | 271 | // - ctx context.Context | |
| 272 | 272 | // - 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)} | |
| 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 | 279 | _c.Call.Run(func(args mock.Arguments) { | |
| 280 | 280 | var arg0 context.Context | |
| 281 | 281 | if args[0] != nil { | |
| @@ -285,9 +285,9 @@ func (_c *MockContainerRuntime_CreateNetwork_Call) Run(run func(ctx context.Cont | |||
| 285 | 285 | if args[1] != nil { | |
| 286 | 286 | arg1 = args[1].(string) | |
| 287 | 287 | } | |
| 288 | - | var arg2 map[string]string | |
| 288 | + | var arg2 domain.NetworkConfig | |
| 289 | 289 | if args[2] != nil { | |
| 290 | - | arg2 = args[2].(map[string]string) | |
| 290 | + | arg2 = args[2].(domain.NetworkConfig) | |
| 291 | 291 | } | |
| 292 | 292 | run( | |
| 293 | 293 | arg0, | |
| @@ -303,7 +303,7 @@ func (_c *MockContainerRuntime_CreateNetwork_Call) Return(err error) *MockContai | |||
| 303 | 303 | 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 | 307 | _c.Call.Return(run) | |
| 308 | 308 | return _c | |
| 309 | 309 | } | |
| @@ -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 MockContainerRuntime | 248 | // 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 error | 256 | 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 call | 270 | // CreateNetwork is a helper method to define mock.On call | ||
| 271 | // - ctx context.Context | 271 | // - ctx context.Context | ||
| 272 | // - name string | 272 | // - name string | ||
| 273 | - | // - options map[string]string | 273 | + | // - 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.Context | 280 | 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]string | 288 | + | 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 _c | 303 | 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 _c | 308 | return _c | ||
| 309 | } | 309 | } | ||
M internal/boundaries/out/runtime.go +1 -1
| @@ -70,7 +70,7 @@ type ContainerRuntime interface { | |||
| 70 | 70 | CopyFromContainer(ctx context.Context, containerID, srcPath string) (io.ReadCloser, error) | |
| 71 | 71 | ||
| 72 | 72 | // 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 | |
| 74 | 74 | RemoveNetwork(ctx context.Context, name string) error | |
| 75 | 75 | ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error) | |
| 76 | 76 | 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 management | 72 | // 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 |
| 74 | RemoveNetwork(ctx context.Context, name string) error | 74 | 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 { | |||
| 26 | 26 | 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 | 36 | // Attachment represents an attached service container. | |
| 30 | 37 | type Attachment struct { | |
| 31 | 38 | Name string | |
| @@ -64,6 +71,10 @@ type ContainerConfig struct { | |||
| 64 | 71 | MemoryLimit int64 // Memory limit in bytes (0 = no limit) | |
| 65 | 72 | NanoCPUs int64 // CPU quota in nanoseconds (1e9 = 1 core, 0 = no limit) | |
| 66 | 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 | 80 | // ContainerStatus represents the current state of a container. | |
| @@ -26,6 +26,13 @@ type NetworkInfo struct { | |||||
| 26 | Labels map[string]string | 26 | 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 string | 38 | 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 | |||
| 72 | 72 | } | |
| 73 | 73 | ||
| 74 | 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 | 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 | 78 | ret0, _ := ret[0].(error) | |
| 79 | 79 | return ret0 | |
| 80 | 80 | } | |
| @@ -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 ret0 | 79 | 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 | |||
| 47 | 47 | require.NoError(t, runtime.PullImage(pullCtx, image)) | |
| 48 | 48 | netCtx, cancelNet := context.WithTimeout(ctx, 2*time.Minute) | |
| 49 | 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 | 51 | t.Cleanup(func() { | |
| 52 | 52 | cleanupCtx, cancelCleanup := context.WithTimeout(context.Background(), 20*time.Second) | |
| 53 | 53 | defer cancelCleanup() | |
| @@ -239,6 +239,9 @@ func (s *integrationContainerService) SyncContainers(context.Context) error { | |||
| 239 | 239 | return nil | |
| 240 | 240 | } | |
| 241 | 241 | ||
| 242 | + | func (s *integrationContainerService) UpdateAttachments(map[string][]string) { | |
| 243 | + | } | |
| 244 | + | ||
| 242 | 245 | func (s *integrationContainerService) AutoStart(context.Context, []domain.Route) error { | |
| 243 | 246 | return nil | |
| 244 | 247 | } | |
| @@ -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 nil | 239 | 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 nil | 246 | return nil | ||
| 244 | } | 247 | } | ||
M internal/usecase/container/service.go +179 -25
| @@ -5,10 +5,12 @@ import ( | |||
| 5 | 5 | "bufio" | |
| 6 | 6 | "context" | |
| 7 | 7 | "encoding/binary" | |
| 8 | + | "encoding/hex" | |
| 8 | 9 | "errors" | |
| 9 | 10 | "fmt" | |
| 10 | 11 | "io" | |
| 11 | 12 | "maps" | |
| 13 | + | "net" | |
| 12 | 14 | "slices" | |
| 13 | 15 | "strconv" | |
| 14 | 16 | "strings" | |
| @@ -43,7 +45,11 @@ type Config struct { | |||
| 43 | 45 | NetworkIsolation bool | |
| 44 | 46 | NetworkPrefix string | |
| 45 | 47 | NetworkGroups map[string][]string | |
| 48 | + | NetworkInternal bool | |
| 46 | 49 | Attachments map[string][]string | |
| 50 | + | AllowedRegistries []string | |
| 51 | + | RequireImageDigest bool | |
| 52 | + | SecurityProfile string | |
| 47 | 53 | ReadinessDelay time.Duration // Delay after container starts before considering it ready | |
| 48 | 54 | ReadinessMode string // Readiness strategy: auto, docker-health, delay | |
| 49 | 55 | 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 | 278 | Image: in.ImageRef, | |
| 273 | 279 | Name: containerName, | |
| 274 | 280 | Ports: ports, | |
| @@ -282,6 +288,8 @@ func (s *Service) buildContainerConfig(in containerConfigInput) *domain.Containe | |||
| 282 | 288 | NanoCPUs: cfg.DefaultNanoCPUs, | |
| 283 | 289 | PidsLimit: cfg.DefaultPidsLimit, | |
| 284 | 290 | } | |
| 291 | + | applySecurityProfile(containerConfig, cfg) | |
| 292 | + | return containerConfig | |
| 285 | 293 | } | |
| 286 | 294 | ||
| 287 | 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 | 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 | 576 | actualImageRef, err := s.ensureImage(ctx, imageRef) | |
| 566 | 577 | if err != nil { | |
| 567 | 578 | 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 | 1690 | func (s *Service) buildImageRef(image string) string { | |
| 1672 | 1691 | s.mu.RLock() | |
| 1673 | 1692 | cfg := s.config | |
| @@ -1743,6 +1762,108 @@ func hasExplicitRegistry(image string) bool { | |||
| 1743 | 1762 | 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 | 1867 | // isNumeric checks if a string contains only digits. | |
| 1747 | 1868 | func isNumeric(s string) bool { | |
| 1748 | 1869 | for _, c := range s { | |
| @@ -1767,7 +1888,16 @@ func normalizePullPolicy(policy string) string { | |||
| 1767 | 1888 | } | |
| 1768 | 1889 | ||
| 1769 | 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 | 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 | 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 | 2238 | return log.WrapErr(err, "failed to create network") | |
| 2106 | 2239 | } | |
| 2107 | 2240 | log.Info().Msg("created network for app isolation") | |
| @@ -2415,32 +2548,18 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic | |||
| 2415 | 2548 | log.Info().Str(zerowrap.FieldService, serviceImage).Msg("deploying attached service") | |
| 2416 | 2549 | ||
| 2417 | 2550 | // 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) | |
| 2420 | 2552 | if err != nil { | |
| 2421 | 2553 | return err | |
| 2422 | 2554 | } | |
| 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) | |
| 2433 | 2556 | if err != nil { | |
| 2434 | - | log.WrapErr(err, "failed to setup volumes for attachment") | |
| 2435 | - | volumes = make(map[string]string) | |
| 2557 | + | return err | |
| 2436 | 2558 | } | |
| 2437 | 2559 | ||
| 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) | |
| 2444 | 2563 | envHash := hashEnvironment(envVars) | |
| 2445 | 2564 | ||
| 2446 | 2565 | s.mu.RLock() | |
| @@ -2468,6 +2587,7 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic | |||
| 2468 | 2587 | NanoCPUs: cfg.DefaultNanoCPUs, | |
| 2469 | 2588 | PidsLimit: cfg.DefaultPidsLimit, | |
| 2470 | 2589 | } | |
| 2590 | + | applySecurityProfile(config, cfg) | |
| 2471 | 2591 | ||
| 2472 | 2592 | container, err := s.runtime.CreateContainer(ctx, config) | |
| 2473 | 2593 | if err != nil { | |
| @@ -2502,8 +2622,42 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic | |||
| 2502 | 2622 | 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 | 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 | 2661 | if err != nil { | |
| 2508 | 2662 | return false, err | |
| 2509 | 2663 | } | |
| @@ -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 bool | 45 | NetworkIsolation bool | ||
| 44 | NetworkPrefix string | 46 | NetworkPrefix string | ||
| 45 | NetworkGroups map[string][]string | 47 | NetworkGroups map[string][]string | ||
| 48 | + | NetworkInternal bool | |||
| 46 | Attachments map[string][]string | 49 | Attachments map[string][]string | ||
| 50 | + | AllowedRegistries []string | |||
| 51 | + | RequireImageDigest bool | |||
| 52 | + | SecurityProfile string | |||
| 47 | ReadinessDelay time.Duration // Delay after container starts before considering it ready | 53 | ReadinessDelay time.Duration // Delay after container starts before considering it ready | ||
| 48 | ReadinessMode string // Readiness strategy: auto, docker-health, delay | 54 | ReadinessMode string // Readiness strategy: auto, docker-health, delay | ||
| 49 | HealthTimeout time.Duration // Max wait for health-based readiness | 55 | 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, err | 578 | 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.config | 1692 | cfg := s.config | ||
| @@ -1743,6 +1762,108 @@ func hasExplicitRegistry(image string) bool { | |||||
| 1743 | return false | 1762 | 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 err | 2553 | 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 nil | 2622 | 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, err | 2662 | 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) { | |||
| 989 | 989 | ||
| 990 | 990 | // Network isolation - should check and create network | |
| 991 | 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 | 994 | container := &domain.Container{ID: "container-123", Status: "created"} | |
| 995 | 995 | runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool { | |
| @@ -4168,3 +4168,109 @@ func TestDeployAttachedService_SetsAliasOnContainerConfig(t *testing.T) { | |||
| 4168 | 4168 | require.Len(t, attachIDs, 1) | |
| 4169 | 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 | + | } | |
| @@ -989,7 +989,7 @@ func TestService_Deploy_WithNetworkIsolation(t *testing.T) { | |||||
| 989 | 989 | ||||
| 990 | // Network isolation - should check and create network | 990 | // 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 { | |||
| 25 | 25 | 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 | 35 | // ContainerConfig holds configuration for creating a container | |
| 29 | 36 | type ContainerConfig struct { | |
| 30 | 37 | Image string | |
| @@ -110,7 +117,7 @@ type Runtime interface { | |||
| 110 | 117 | InspectImageEnv(ctx context.Context, imageRef string) ([]string, error) | |
| 111 | 118 | ||
| 112 | 119 | // Network management | |
| 113 | - | CreateNetwork(ctx context.Context, name string, options map[string]string) error | |
| 120 | + | CreateNetwork(ctx context.Context, name string, config NetworkConfig) error | |
| 114 | 121 | RemoveNetwork(ctx context.Context, name string) error | |
| 115 | 122 | ListNetworks(ctx context.Context) ([]*NetworkInfo, error) | |
| 116 | 123 | NetworkExists(ctx context.Context, name string) (bool, error) | |
| @@ -25,6 +25,13 @@ type NetworkInfo struct { | |||||
| 25 | Labels map[string]string | 25 | 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 container | 35 | // ContainerConfig holds configuration for creating a container | ||
| 29 | type ContainerConfig struct { | 36 | type ContainerConfig struct { | ||
| 30 | Image string | 37 | 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 management | 119 | // Network management | ||
| 113 | - | CreateNetwork(ctx context.Context, name string, options map[string]string) error | 120 | + | CreateNetwork(ctx context.Context, name string, config NetworkConfig) error |
| 114 | RemoveNetwork(ctx context.Context, name string) error | 121 | 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) | ||