|
1 | 1 | import { describe, expect, it } from 'vitest'; |
2 | 2 | import { ShipnodeConfigSchema } from '../../src/config/schema.js'; |
3 | 3 | import { accessoryHostVar, fleetFirewallRules, isLoopbackOnly } from '../../src/domain/networking.js'; |
4 | | -import { ufwConfigureCommands } from '../../src/infrastructure/provisioning/security.js'; |
| 4 | +import { dockerUserRules, sanitizeUfwComment, ufwAllowRule, ufwConfigureCommands } from '../../src/infrastructure/provisioning/security.js'; |
5 | 5 | import type { ShipnodeConfig } from '../../src/shared/types.js'; |
6 | 6 |
|
7 | 7 | function workspace(overrides: Record<string, unknown> = {}): unknown { |
@@ -65,8 +65,8 @@ describe('fleetFirewallRules', () => { |
65 | 65 | const rules = fleetFirewallRules(parsed(), 'db-1'); |
66 | 66 |
|
67 | 67 | expect(rules).toEqual([ |
68 | | - { port: 5432, from: '10.0.0.11', comment: "postgres for db-1's dependants" }, |
69 | | - { port: 5432, from: '10.0.0.12', comment: "postgres for db-1's dependants" }, |
| 68 | + { port: 5432, from: '10.0.0.11', comment: 'shipnode postgres accessory', docker: true }, |
| 69 | + { port: 5432, from: '10.0.0.12', comment: 'shipnode postgres accessory', docker: true }, |
70 | 70 | ]); |
71 | 71 | }); |
72 | 72 |
|
@@ -104,7 +104,7 @@ describe('fleetFirewallRules', () => { |
104 | 104 |
|
105 | 105 | expect(rules).toContainEqual({ |
106 | 106 | port: 8080, |
107 | | - comment: 'api replica port (load balancer ingress)', |
| 107 | + comment: 'shipnode api replica port', |
108 | 108 | }); |
109 | 109 | }); |
110 | 110 |
|
@@ -213,3 +213,66 @@ describe('cross-server accessory addressing', () => { |
213 | 213 | expect(parsed.success).toBe(true); |
214 | 214 | }); |
215 | 215 | }); |
| 216 | + |
| 217 | +describe('sanitizeUfwComment', () => { |
| 218 | + it('strips the characters ufw rejects a rule for', () => { |
| 219 | + // ufw answers `ERROR: Invalid syntax` and adds nothing, however the shell |
| 220 | + // quotes it — while `ufw --force enable` still succeeds. The firewall comes |
| 221 | + // up hard with the hole never opened, which is the worst available outcome. |
| 222 | + expect(sanitizeUfwComment("postgres for db-1's dependants")).toBe('postgres for db-1 s dependants'); |
| 223 | + expect(sanitizeUfwComment('a "quoted" name')).toBe('a quoted name'); |
| 224 | + expect(sanitizeUfwComment('back\\slash and `tick` and $var')).toBe('back slash and tick and var'); |
| 225 | + }); |
| 226 | + |
| 227 | + it('caps length, since comments come from user-controlled names', () => { |
| 228 | + expect(sanitizeUfwComment('x'.repeat(500))).toHaveLength(200); |
| 229 | + }); |
| 230 | + |
| 231 | + it('produces a rule ufw can parse for every generated comment', () => { |
| 232 | + const rule = ufwAllowRule({ port: 5432, from: '10.0.0.11', comment: "it's \"fine\"" }); |
| 233 | + |
| 234 | + expect(rule).toBe('ufw allow from 10.0.0.11 to any port 5432 proto tcp comment "it s fine"'); |
| 235 | + }); |
| 236 | +}); |
| 237 | + |
| 238 | +describe('dockerUserRules', () => { |
| 239 | + const accessory = (from: string) => ({ port: 5432, from, comment: 'pg', docker: true }); |
| 240 | + |
| 241 | + it('drops after the allowed sources, never before', () => { |
| 242 | + // Each `-I ... 1` pushes the previous entry down, so the DROP must be |
| 243 | + // inserted first to end up last. Insert it after and it shadows every |
| 244 | + // RETURN, cutting the accessory off from the replicas that need it. |
| 245 | + const [script] = dockerUserRules([accessory('10.0.0.11'), accessory('10.0.0.12')]); |
| 246 | + |
| 247 | + const drop = script.indexOf('-I DOCKER-USER 1 -p tcp --dport 5432 -j DROP'); |
| 248 | + const first = script.indexOf('-I DOCKER-USER 1 -p tcp --dport 5432 -s 10.0.0.11 -j RETURN'); |
| 249 | + expect(drop).toBeGreaterThan(-1); |
| 250 | + expect(first).toBeGreaterThan(drop); |
| 251 | + }); |
| 252 | + |
| 253 | + it('never appends, which would land after Docker own trailing RETURN', () => { |
| 254 | + const [script] = dockerUserRules([accessory('10.0.0.11')]); |
| 255 | + |
| 256 | + expect(script).not.toContain('-A DOCKER-USER'); |
| 257 | + }); |
| 258 | + |
| 259 | + it('deletes before inserting, so re-running harden does not stack duplicates', () => { |
| 260 | + const [script] = dockerUserRules([accessory('10.0.0.11')]); |
| 261 | + |
| 262 | + const del = script.indexOf('-D DOCKER-USER -p tcp --dport 5432 -s 10.0.0.11 -j RETURN'); |
| 263 | + const ins = script.indexOf('-I DOCKER-USER 1 -p tcp --dport 5432 -s 10.0.0.11 -j RETURN'); |
| 264 | + expect(del).toBeGreaterThan(-1); |
| 265 | + expect(ins).toBeGreaterThan(del); |
| 266 | + }); |
| 267 | + |
| 268 | + it('persists the rules, which iptables loses on reboot', () => { |
| 269 | + const [script] = dockerUserRules([accessory('10.0.0.11')]); |
| 270 | + |
| 271 | + expect(script).toContain('netfilter-persistent save'); |
| 272 | + }); |
| 273 | + |
| 274 | + it('emits nothing for a host-listening port, which ufw already covers', () => { |
| 275 | + expect(dockerUserRules([{ port: 8080, comment: 'replica port' }])).toEqual([]); |
| 276 | + expect(dockerUserRules([])).toEqual([]); |
| 277 | + }); |
| 278 | +}); |
0 commit comments