Skip to content

Commit e9915d4

Browse files
committed
Make the accessory firewall actually restrict anything
Testing the fleet firewall against a real UFW found the rules were decorative. Two separate faults, both invisible to unit tests. The generated comment contained an apostrophe — "postgres for db-1's dependants". ufw rejects the whole rule with `ERROR: Invalid syntax` however the shell quotes it, and `ufw --force enable` still succeeds, so the firewall came up hard with the hole never opened. Comments are built from app, accessory and server names, so this is now sanitised rather than merely avoided in today's strings. More seriously, Docker bypasses ufw. Publishing a port writes ACCEPT rules into the FORWARD path that are consulted before ufw's chain, so `ufw allow from X to any port 5432` restricted nothing — the rule was accepted, showed up in `ufw status`, and left Postgres reachable from every host on the network. Verified: an unrelated VM connected to the database with the rules in place. Accessory rules now also write DOCKER-USER entries, the one chain Docker guarantees to consult first and never rewrite. Allowed sources RETURN, everything else for that port is DROPped. The DROP is inserted before the RETURNs because each `-I ... 1` pushes the previous entry down — appending it instead would land after the RETURN Docker keeps at the end of the chain, where it is never reached. Rules are deleted before being inserted so re-running harden cannot stack duplicates, and persisted with netfilter-persistent since iptables loses them on reboot. Verified live: outsider blocked, both declared replicas still connected, chain unchanged and still enforcing after three consecutive runs. Also adds `--on` to setup, which every other fan-out command already had.
1 parent 99c61af commit e9915d4

6 files changed

Lines changed: 164 additions & 8 deletions

File tree

src/cli/commands/harden.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,17 @@ export async function cmdHarden(cwd: string, options: { config?: string; on?: st
113113
for (const rule of extra) {
114114
ui.info(` allowed ${rule.port}/tcp${rule.from ? ` from ${rule.from}` : ''}${rule.comment}`);
115115
}
116+
117+
// Accessory ports are Docker's, and Docker's own FORWARD rules are
118+
// consulted before ufw's — so the rules above are accepted, appear in
119+
// `ufw status`, and restrict nothing. DOCKER-USER is what actually holds.
120+
const dockerRules = sec.dockerUserRules(extra);
121+
for (const cmd of dockerRules) {
122+
await executor.exec(cmd);
123+
}
124+
if (dockerRules.length > 0) {
125+
changes.push('DOCKER-USER: container ports restricted to their declared consumers');
126+
}
116127
}
117128

118129
// List installed pm2 systemd units so we can spot stale ones from a previous

src/cli/commands/setup.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ const DEPLOY_USER = 'deploy';
2121
interface SetupOptions {
2222
config?: string;
2323
noDeployUser?: boolean;
24+
on?: string;
2425
}
2526

2627
export async function cmdSetup(cwd: string, options: SetupOptions): Promise<void> {
@@ -45,7 +46,7 @@ export async function cmdSetup(cwd: string, options: SetupOptions): Promise<void
4546
ui.outro('Server ready — run: shipnode deploy');
4647
}
4748
},
48-
{ configPath: options.config, includeEmpty: true },
49+
{ configPath: options.config, includeEmpty: true, serverName: options.on },
4950
);
5051
}
5152

src/cli/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ program
6464
.command('setup')
6565
.description('Setup a new server with required dependencies')
6666
.option('--no-deploy-user', 'Skip creating the deploy user (advanced)')
67+
.option('--on <server>', 'Provision one server instead of every server in the workspace')
6768
.option('--config <path>', 'Use a specific config file')
6869
// Commander turns `--no-deploy-user` into `deployUser: false`, not
6970
// `noDeployUser: true`, so passing opts straight through silently ignored the

src/domain/networking.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,16 @@ export interface FirewallRule {
8787
port: number;
8888
from?: string;
8989
comment: string;
90+
/**
91+
* Whether the listener is a Docker published port.
92+
*
93+
* Docker inserts its own ACCEPT rules into the FORWARD path, which are
94+
* evaluated before ufw's — so `ufw allow from X to any port 5432` does not
95+
* restrict a container's port at all, and the port stays open to everything.
96+
* Rules flagged here get an additional DOCKER-USER entry, the one chain Docker
97+
* guarantees to consult first.
98+
*/
99+
docker?: boolean;
90100
}
91101

92102
/** The host-side port of a Docker `-p` mapping, if it publishes one. */
@@ -128,7 +138,7 @@ export function fleetFirewallRules(config: ShipnodeConfig, serverName: string):
128138
if (app.fleet.port === 80 || app.fleet.port === 443) continue;
129139
add({
130140
port: app.fleet.port,
131-
comment: `${app.name} replica port (load balancer ingress)`,
141+
comment: `shipnode ${app.name} replica port`,
132142
});
133143
}
134144

@@ -155,7 +165,7 @@ export function fleetFirewallRules(config: ShipnodeConfig, serverName: string):
155165

156166
for (const port of ports) {
157167
for (const from of consumers) {
158-
add({ port, from, comment: `${name} for ${serverName}'s dependants` });
168+
add({ port, from, comment: `shipnode ${name} accessory`, docker: true });
159169
}
160170
}
161171
}

src/infrastructure/provisioning/security.ts

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,20 @@ export function ufwConfigureCommands(extra: FirewallRule[] = []): string[] {
5151
];
5252
}
5353

54+
/**
55+
* ufw rejects a rule outright — `ERROR: Invalid syntax` — when the comment
56+
* contains a quote or an apostrophe, however the shell quotes it. Comments are
57+
* built from app, accessory and server names, so this has to be defended rather
58+
* than merely avoided in the strings shipnode happens to generate today. A
59+
* rejected rule is the worst failure mode available: `ufw --force enable` still
60+
* succeeds, so the firewall comes up hard with the hole never opened.
61+
*/
62+
export function sanitizeUfwComment(comment: string): string {
63+
return comment.replace(/['"\\`$]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 200);
64+
}
65+
5466
export function ufwAllowRule(rule: FirewallRule): string {
55-
const comment = `comment ${JSON.stringify(rule.comment)}`;
67+
const comment = `comment "${sanitizeUfwComment(rule.comment)}"`;
5668
return rule.from === undefined
5769
? `ufw allow ${rule.port}/tcp ${comment}`
5870
: `ufw allow from ${rule.from} to any port ${rule.port} proto tcp ${comment}`;
@@ -95,3 +107,61 @@ export function fail2banApplyConfigCommand(): string {
95107
export function fail2banEnableCommand(): string {
96108
return `${SUDO}; $SUDO systemctl enable fail2ban && $SUDO systemctl restart fail2ban`;
97109
}
110+
111+
/**
112+
* Restrict Docker-published ports, which ufw cannot.
113+
*
114+
* Docker writes its own ACCEPT rules into the FORWARD path when it publishes a
115+
* port. Those are consulted before ufw's chain, so a container port stays open
116+
* to the whole network no matter what `ufw allow from ...` says — the rule is
117+
* accepted, appears in `ufw status`, and does nothing. An accessory is always a
118+
* container, so every accessory rule needs this.
119+
*
120+
* DOCKER-USER is the one chain Docker promises to evaluate first and never
121+
* rewrite. Allowed sources RETURN (falling through to Docker's own ACCEPT);
122+
* everything else for that port is dropped.
123+
*
124+
* Ordering is why the DROP is inserted before the RETURNs: each `-I ... 1`
125+
* pushes the previous entry down, so inserting DROP first leaves it last.
126+
* Appending it with `-A` would place it after the RETURN that Docker keeps at
127+
* the end of the chain, where it would never be reached.
128+
*/
129+
export function dockerUserRules(rules: FirewallRule[]): string[] {
130+
const docker = rules.filter((rule) => rule.docker);
131+
if (docker.length === 0) return [];
132+
133+
const ports = [...new Set(docker.map((rule) => rule.port))];
134+
const commands: string[] = [`${SUDO}`];
135+
136+
for (const port of ports) {
137+
const sources = docker
138+
.filter((rule) => rule.port === port && rule.from !== undefined)
139+
.map((rule) => rule.from!);
140+
if (sources.length === 0) continue;
141+
142+
// Re-running harden must not stack duplicates, so delete each rule we are
143+
// about to add first. `|| true` because a missing rule is the normal case.
144+
for (const source of sources) {
145+
commands.push(`$SUDO iptables -D DOCKER-USER -p tcp --dport ${port} -s ${source} -j RETURN 2>/dev/null || true`);
146+
}
147+
commands.push(`$SUDO iptables -D DOCKER-USER -p tcp --dport ${port} -j DROP 2>/dev/null || true`);
148+
149+
commands.push(`$SUDO iptables -I DOCKER-USER 1 -p tcp --dport ${port} -j DROP`);
150+
for (const source of [...sources].reverse()) {
151+
commands.push(`$SUDO iptables -I DOCKER-USER 1 -p tcp --dport ${port} -s ${source} -j RETURN`);
152+
}
153+
}
154+
155+
if (commands.length === 1) return [];
156+
157+
// iptables rules are lost on reboot, and a firewall that silently stops
158+
// applying is worse than one that was never configured.
159+
commands.push(
160+
'command -v netfilter-persistent >/dev/null 2>&1 || ' +
161+
'{ DEBIAN_FRONTEND=noninteractive $SUDO apt-get install -y -qq iptables-persistent >/dev/null 2>&1 || true; }',
162+
'$SUDO netfilter-persistent save >/dev/null 2>&1 || ' +
163+
'echo "shipnode: could not persist iptables rules; they will be lost on reboot" >&2',
164+
);
165+
166+
return [commands.join('; ')];
167+
}

tests/unit/networking.test.ts

Lines changed: 67 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { describe, expect, it } from 'vitest';
22
import { ShipnodeConfigSchema } from '../../src/config/schema.js';
33
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';
55
import type { ShipnodeConfig } from '../../src/shared/types.js';
66

77
function workspace(overrides: Record<string, unknown> = {}): unknown {
@@ -65,8 +65,8 @@ describe('fleetFirewallRules', () => {
6565
const rules = fleetFirewallRules(parsed(), 'db-1');
6666

6767
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 },
7070
]);
7171
});
7272

@@ -104,7 +104,7 @@ describe('fleetFirewallRules', () => {
104104

105105
expect(rules).toContainEqual({
106106
port: 8080,
107-
comment: 'api replica port (load balancer ingress)',
107+
comment: 'shipnode api replica port',
108108
});
109109
});
110110

@@ -213,3 +213,66 @@ describe('cross-server accessory addressing', () => {
213213
expect(parsed.success).toBe(true);
214214
});
215215
});
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

Comments
 (0)