---
title: 'Firewall Configuration'
source: 'https://academia.sh/en/courses/servers-and-hardening/firewall-configuration'
course: 'Servers, Networking and Hardening'
language: en
updated: '2026-09-13T05:08:30+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Counts the ports listening after install, the inbound direction, the outbound direction, and management access item by item'
    - 'Writes the same allow list at three rule widths: measures the trade-off between row count and open item count'
    - 'Counts the outbound restriction separately and measures the surface the rule set itself introduces through a single row'
---

# Firewall Configuration

Open surface is counted item by item at the machine level: how many ports listen after install, how many zones can reach them, how many attempts stop once the switch to default-deny happens, how many legitimate flows close by mistake, and what stays open when the outbound direction is left unrestricted.

The previous topic closed with verifying the name and the sender: the query reaches the right name
server, and the email's origin can be proven. What was verified was the name itself; the machine
the name points to still stands with the settings it was installed with, and those settings left it
reachable from everywhere. The machine behind it can be reached without ever using the name — the
address is enough.

Network policy was measured earlier at the cluster-internal scale: which pod can reach which pod.
The scale here is the machine underneath the cluster. Three things are asked: how many items stay
open when nothing is done, how many rows closing them takes, and how many legitimate jobs stop when
they close.

- **HB1.** The arrangement of six machines and twenty-five listening ports belongs to the fictional
  regional measurement network. No real machine is run; what is measured is the model built with
  `node`.
- **HB2.** Source zones were reduced to four. In reality zone separation is written with address
  ranges; that grows the item count without changing the direction of the result.
- **HB3.** Attempt counts were written by hand; there is no randomness, every run produces the same
  numbers.
- **HB4.** A port is either open or closed; rate limiting and state tracking are not modeled.
- **HB5.** The allow list was not derived from a trace, it was written by hand; the model
  deliberately includes the oversight of the two rare flows marked with `SKIPPED`. In reality the
  number of missed flows depends on the observation period.
- **HB6.** The outbound direction was modeled at the destination-class level. In reality outbound
  rules are written by address and port; the item count grows, but the meaning of an unrestricted
  outbound direction does not change.

## Rule Set Model

The model holds three things: which port is listening on which machine, how many attempts came to
which port from which zone during the period, and the allow list the operator wrote. Rules are
written in our own small schema: one row names a source zone, a machine, and a port name, and `*`
means no distinction is made on that axis.

```js
// firewall.mjs — machine, listening port, attempt, and rule set model; not a real network
export const ZONE = ['internal-net', 'office-net', 'field-net', 'external'];
const COMMON = [['management-shell', 'management'], ['metric-endpoint', 'auxiliary'], ['log-shipping', 'auxiliary']];
const SPECIFIC = {                          // ports the install leaves open per machine
  'collector-1': [['read-intake', 'work']], 'collector-2': [['read-intake', 'work']],
  'validator': [['validation-api', 'work']], 'billing': [['billing-api', 'work']],
  'data-store': [['data-endpoint', 'work'], ['backup-endpoint', 'work']], 'management': [['management-interface', 'management']],
};
export const MACHINE = Object.keys(SPECIFIC);
export const PORT = MACHINE.flatMap((m) => [...COMMON, ...SPECIFIC[m]].map(([portName, portKind]) => ({ machine: m, name: portName, type: portKind })));
export const key = (z, m, p) => `${z}|${m}|${p}`;

// Thirty-day attempt log: [zone, machine, port, count, legitimate?]
const perMachine = (z, p, f, ms) => MACHINE.map((m, i) => [z, m, p, f(i), ms]);
export const ATTEMPT = [
  ['field-net', 'collector-1', 'read-intake', 41200, true], ['field-net', 'collector-2', 'read-intake', 38900, true],
  ['internal-net', 'validator', 'validation-api', 26400, true], ['internal-net', 'data-store', 'data-endpoint', 91500, true],
  ['internal-net', 'billing', 'billing-api', 8600, true], ['office-net', 'billing', 'billing-api', 1240, true],
  ['office-net', 'management', 'management-interface', 860, true], ['office-net', 'management', 'management-shell', 310, true],
  ['office-net', 'data-store', 'backup-endpoint', 4, true],       // monthly backup verification
  ['office-net', 'validator', 'validation-api', 12, true], // end-of-quarter manual verification
  ...perMachine('internal-net', 'metric-endpoint', () => 8640, true), ...perMachine('internal-net', 'log-shipping', () => 30, true),
  ...perMachine('internal-net', 'management-shell', () => 62, true),     // config distribution
  ...perMachine('external', 'management-shell', (i) => 12000 + i * 1730, false),
  ...perMachine('external', 'metric-endpoint', (i) => 90 + i * 7, false),
  ['external', 'data-store', 'data-endpoint', 2400, false], ['external', 'data-store', 'backup-endpoint', 180, false],
  ['external', 'collector-1', 'read-intake', 640, false], ['external', 'management', 'management-interface', 5100, false],
  ['field-net', 'data-store', 'data-endpoint', 26, false], ['field-net', 'management', 'management-shell', 14, false],
  ['office-net', 'data-store', 'data-endpoint', 310, false],
];
export const LEGITIMATE = ATTEMPT.filter((d) => d[4]).map((d) => key(d[0], d[1], d[2]));
// The operator wrote the allow list by hand; two rare flows were missed.
const SKIPPED = ['office-net|data-store|backup-endpoint', 'office-net|validator|validation-api'];
export const ALLOW = LEGITIMATE.filter((a) => !SKIPPED.includes(a)).map((a) => a.split('|'));
// The same list can be written in three widths; '*' means no distinction on that axis.
export const widths = () => ({
  'port-name': [...new Set(ALLOW.map((r) => r[2]))].map((k) => ['*', '*', k]),
  'zone+port': [...new Set(ALLOW.map((r) => `${r[0]}|${r[2]}`))].map((s) => [s.split('|')[0], '*', s.split('|')[1]]),
  'zone+machine+port': ALLOW,
});
export function opened(rules) {             // rule set -> open item set
  const open = new Set();
  for (const [z, m, p] of rules)
    for (const zz of (z === '*' ? ZONE : [z]))
      for (const port of PORT)
        if ((m === '*' || port.machine === m) && (p === '*' || port.name === p)) open.add(key(zz, port.machine, port.name));
  return open;
}
export const ALL = opened([['*', '*', '*']]);  // the default-allow arrangement equals a single rule
export function count(open) {
  const closed = (d) => !open.has(key(d[0], d[1], d[2]));
  const sum = (f) => ATTEMPT.filter(f).reduce((a, d) => a + d[3], 0);
  return { items: open.size, unmatched: [...open].filter((a) => !LEGITIMATE.includes(a)).length,
    blocked: sum((d) => !d[4] && closed(d)), passed: sum((d) => !d[4] && !closed(d)),
    wronglyBlocked: ATTEMPT.filter((d) => d[4] && closed(d)).length };
}
export const OUTBOUND_LEGITIMATE = [
  ['collector-1', 'internal:queue'], ['collector-2', 'internal:queue'], ['validator', 'internal:data-store'],
  ['billing', 'internal:data-store'], ['billing', 'external:mail-gateway'], ['management', 'external:package-repo'],
  ['data-store', 'internal:backup-area'], ...MACHINE.map((m) => [m, 'external:time-source']),
];
export const OUTBOUND_ALLOW = OUTBOUND_LEGITIMATE.filter(([m, h]) => h !== 'external:time-source' || m === 'management');
```

The `ALL` row is the model's silent claim: the default-allow arrangement is equivalent to a single
`* -> * -> *` rule; nobody wrote that rule, but its effect is as if it had been. `count` takes an
open item set and returns five numbers: how many items are open, how many of them have no match,
how many illegitimate attempts were blocked, how many got through, and how many legitimate flows
were closed by mistake.

## The Surface Left Open by Default

```js
// run1.mjs — items left open when nothing is done after install are counted
import { ZONE, MACHINE, PORT, ATTEMPT, LEGITIMATE, OUTBOUND_LEGITIMATE, ALL, count } from './firewall.mjs';

const s = count(ALL);
const byType = (t) => PORT.filter((k) => k.type === t).length;
const sum = (f) => ATTEMPT.filter(f).reduce((a, d) => a + d[3], 0);
const portType = (m, p) => PORT.find((x) => x.machine === m && x.name === p).type;
for (const [label, value, note = ''] of [
  ['listening port', PORT.length, `${MACHINE.length} machines: work ${byType('work')}, auxiliary ${byType('auxiliary')}, management ${byType('management')}`],
  ['inbound open item', s.items, `${PORT.length} ports x ${ZONE.length} zones`],
  ['  matching a legitimate flow', LEGITIMATE.length],
  ['  open unmatched', s.unmatched],
  ['management port reachable from external', byType('management')],
  ['outbound restriction', '-', `${MACHINE.length} machines have unrestricted targets, ${OUTBOUND_LEGITIMATE.length} legitimate flows`],
  ['thirty-day illegitimate attempt', sum((d) => !d[4]), `from ${new Set(ATTEMPT.filter((d) => !d[4]).map((d) => d[0])).size} source zones`],
  ['  hitting a management port', sum((d) => !d[4] && portType(d[1], d[2]) === 'management')],
  ['  blocked', s.blocked],
  ['  reached target', s.passed],
]) console.log(`${label.padEnd(41)}${String(value).padStart(7)}  ${note}`.trimEnd());
```

```
listening port                                25  6 machines: work 6, auxiliary 12, management 7
inbound open item                            100  25 ports x 4 zones
  matching a legitimate flow                  28
  open unmatched                              72
management port reachable from external        7
outbound restriction                           -  6 machines have unrestricted targets, 13 legitimate flows
thirty-day illegitimate attempt           107265  from 3 source zones
  hitting a management port               103064
  blocked                                      0
  reached target                          107265
```

Only six of the twenty-five listening ports are open for the work itself. The remaining nineteen
are for metric collection, log shipping, and the management shell; none of them were opened on
purpose, the install opened them. The hundred items come from these ports being reachable from all
four zones at once — twenty-eight of them have a matching legitimate flow, seventy-two do not.

Seven of the management ports are also reachable from the external zone, and one hundred three
thousand of the period's one hundred seven thousand illegitimate attempts land exactly there. The
last two rows are the punch line: zero of these attempts were blocked. A firewall layer whose
default decision is "allow" produces the same number as no layer at all. In the outbound direction
there is not even an item called restriction.

## Default-Deny and Rule Width

```js
// run2.mjs — switch to default-deny; the same allow list is written at three rule widths
import { ZONE, ALLOW, widths, opened, ALL, count } from './firewall.mjs';

const row = (label, n, open) => {
  const s = count(open);
  console.log(`${label.padEnd(52)} ${String(n).padEnd(6)} ${String(s.items).padEnd(7)} ` +
    `${String(s.unmatched).padEnd(12)} ${String(s.blocked).padEnd(18)} ${String(s.passed).padEnd(17)} ${s.wronglyBlocked}`);
};
console.log(`the same allow list (${ALLOW.length} flows) at three widths:\n` +`${'rule width'.padEnd(52)} rows   items   unmatched    blocked attempts   passed attempts   wrongly blocked`);
row('default allow', 0, ALL);
for (const [label, k] of Object.entries(widths())) row(label, k.length, opened(k));
const narrow = widths()['zone+machine+port'];
const A = ['office-net', '*', '*'], B = ['external', 'management', 'management-shell'], C = ['*', '*', 'metric-endpoint'];
console.log('\nadding a temporary row to the narrowest shape:');
for (const [label, ek] of [['office-net -> * -> *', [A]], ['external -> management -> management-shell', [B]],
  ['all three together (+ * -> * -> metric-endpoint)', [A, B, C]]]) row(`  ${label}`, narrow.length + ek.length, opened([...narrow, ...ek]));
const wide = widths()['port-name'].map(([z, m, p]) => [p, opened([[z, m, p]]).size]).filter(([, n]) => n > 4);
console.log(`\nitems opened by a single rule written for a port name (${ZONE.length} zones x machines listening on that port):`);
console.log('  ' + wide.map(([p, n]) => `${p} ${n}`).join(', '));
```

```
the same allow list (26 flows) at three widths:
rule width                                           rows   items   unmatched    blocked attempts   passed attempts   wrongly blocked
default allow                                        0      100     72           0                  107265            0
port-name                                            8      96      69           180                107085            1
zone+port                                            10     31      5            107265             0                 2
zone+machine+port                                    26     26      0            107265             0                 2

adding a temporary row to the narrowest shape:
  office-net -> * -> *                               27     48      20           106955             310               0
  external -> management -> management-shell         27     27      1            86615              20650             2
  all three together (+ * -> * -> metric-endpoint)   29     61      33           85660              21605             0

items opened by a single rule written for a port name (4 zones x machines listening on that port):
  read-intake 8, management-shell 24, metric-endpoint 24, log-shipping 24
```

Switching to default-deny does not by itself produce a number. The `port-name` row shows this:
eight rules were written, it is default-deny, the allow list is applied — and the open items drop
from a hundred to ninety-six, and only a hundred eighty of the illegitimate attempts are blocked.
The last row gives the reason: `management-shell` written as a single rule opens twenty-four items,
because it spans all six machines and all four zones at once. The allow list had six rows for this
port, and all six were from the internal network; dropping the zone axis opens the external zone
too. Three broad rules produce seventy-two of the ninety-six items.

Adding the zone axis raises the row count from eight to ten: open items drop to thirty-one and all
illegitimate attempts are blocked. Two rows close one hundred seven thousand attempts. Adding the
machine axis too asks for twenty-six rows; unmatched items drop from five to zero, but the blocked
attempt count does not change. The zone axis is the axis that closes things, the machine axis only
cleans up the remainder: sixteen rows of extra work buy five items.

The wrongly-blocked column is two in both narrow shapes — the monthly backup verification and the
end-of-quarter manual verification marked with `SKIPPED` in the model, four and twelve attempts
during the period. In a trace of hundreds of thousands of rows, these sixteen rows are invisible.
Nothing breaks on the day the rule is written; it breaks at month- and quarter-end, and what shows
up is a connection error, not an allow-list row.

The middle block measures the surface closing itself introduces; the rule set is itself an item
that gets written, expanded for a reason, and mostly never rolled back. The wildcard row opened for
`office-net` adds twenty-two items but only lets three hundred ten attempts through; the single
management port opened to the external zone adds one item and lets twenty thousand six hundred
fifty attempts through — which zone an item faces matters more than the count. The wildcard row's
last column shows the real problem: wrongly-blocked drops from two to zero, because the row
accidentally reopens the two skipped legitimate flows. The gap goes unnoticed, and no reason arises
to remove the row. When all three stay together, twenty-nine rows open sixty-one items; the rule
set only grows in one direction, because adding a row has a justification and removing one does not.

## Outbound Direction

Every item counted so far belonged to the inbound direction. The outbound direction is left
entirely unrestricted in most setups, and because it does not show up in the item count, nobody
notices it is a surface.

```js
// run3.mjs — outbound direction is counted separately; the inbound rule set touches none of these numbers
import { MACHINE, ALLOW, OUTBOUND_LEGITIMATE, OUTBOUND_ALLOW, widths, opened, count } from './firewall.mjs';

const extClasses = (l) => new Set(l.filter(([, h]) => h.startsWith('external:')).map(([, h]) => h)).size;
const items = count(opened(widths()['zone+machine+port'])).items;
const blocked = OUTBOUND_LEGITIMATE.filter(([k, h]) => !OUTBOUND_ALLOW.some(([a, b]) => a === k && b === h));
console.log('machine       default        allowed dest   blocked legitimate flow');
for (const m of MACHINE)
  console.log((`${m.padEnd(13)} ${'unrestricted'.padEnd(14)} ` +
    `${String(OUTBOUND_ALLOW.filter(([k]) => k === m).length).padEnd(14)} ` +
    blocked.filter(([k]) => k === m).map(([, h]) => h).join(', ')).trimEnd());
console.log(`\nlegitimate outbound flow         ${OUTBOUND_LEGITIMATE.length}   (${extClasses(OUTBOUND_LEGITIMATE)} external dest classes)`);
console.log(`outbound rule row                ${OUTBOUND_ALLOW.length}   (0 by default, unrestricted target)`);
console.log(`allowed external dest class      ${extClasses(OUTBOUND_ALLOW)}   (any, by default)`);
console.log(`wrongly blocked legitimate flow  ${blocked.length}`);
console.log(`inbound open item                ${items} -> ${items}   (unchanged)`);
console.log(`total rule row                   ${ALLOW.length} -> ${ALLOW.length + OUTBOUND_ALLOW.length}`);
```

```
machine       default        allowed dest   blocked legitimate flow
collector-1   unrestricted   1              external:time-source
collector-2   unrestricted   1              external:time-source
validator     unrestricted   1              external:time-source
billing       unrestricted   2              external:time-source
data-store    unrestricted   1              external:time-source
management    unrestricted   2

legitimate outbound flow         13   (3 external dest classes)
outbound rule row                8   (0 by default, unrestricted target)
allowed external dest class      3   (any, by default)
wrongly blocked legitimate flow  5
inbound open item                26 -> 26   (unchanged)
total rule row                   26 -> 34
```

Eight outbound rules bring the open target count per machine down to one or two; that column read
"unrestricted" by default. The difference is what a compromised machine can do: even if the inbound
rule set is complete, a process running inside that can connect out can exfiltrate data, take
commands, and establish its own persistence. Closing this path does not change a single inbound
item; open items stay at twenty-six, and the rule count rises to thirty-four.

The wrongly-blocked column is five. Whoever wrote the allow list saw the time-source dependency on
the management node and wrote it there, and did not think of it for the others. Its defining trait
is that it does not show up right away: a clock does not stop at once, it drifts. The drift surfaces
days later in signature verification, log ordering, and metric windows; all of it looks like a
symptom of something else. The two wrongly-blocked items in the inbound direction throw a
connection error and get fixed; the five in the outbound direction can be misdiagnosed for weeks.

This is also where the new surface closing introduces shows up. One of the three allowed external
destination classes is the package repository, and it is open for the management node; the price of
being able to fetch patches is that a path leading outward stays permanently open. The
thirty-four-row rule set leaves one item that cannot be closed: the management node, privileged in
both directions.

## Summary

- After install, 25 ports listen across six machines; only 6 are for the work itself. Reachability
  from four zones turns this into 100 open items, 72 of which have no matching legitimate flow, and
  all 107,265 of the period's illegitimate attempts reach their target — 103,064 of them at
  management ports.
- Switching to default-deny is not enough by itself: 8 rows written by port name block only 180
  attempts. The zone axis closes all of the attempts with 10 rows; the machine axis asks for 26
  rows and drops the unmatched item count from 5 to 0 without adding anything to the blocked
  attempt count.
- Both narrow shapes wrongly block 2 legitimate flows; because the two together produce only 16
  attempts during the period, they do not show up in the trace and break at month- and quarter-end.
- The rule set itself is a surface: the single row opened to the external zone adds 1 item and lets
  through 20,650 attempts, and the wildcard row opened to the office network adds 22 items and
  hides the incomplete allow list by dropping wrongly-blocked from 2 to 0.
- When the outbound direction is restricted with 8 rows, the open target count per machine drops to
  1–2 and the inbound direction's 26 items do not change; the price is 5 wrongly-blocked flows that
  show up days later as clock drift. The item that cannot be closed is the one machine that faces
  outward so it can fetch patches.

## Next Step

The narrowest rule set left the management shell open to the internal network on six machines. The
port narrowed, but what is behind it did not change: whoever connects still logs in with a password,
and password attempts can be made freely from the zones the firewall allows. The next lesson's
question is not which zone a port is open to, but what getting through the port depends on: with
password-based access, how many attempts come from how many sources in a period; how much of this
surface closes when switching to key-based access; how many copies a key exists in, where they sit,
and how many machines each is authorized on; how many touches revoking it takes — and reducing
access to a single bastion host lowers the number of directly reachable machines while introducing
what single target in return.
