The ntfy rule was written as a special case. It is an instance of a pattern this runbook did not have, and the pattern is the part worth documenting: one port per service on the proxy, one rule per port on the node. A rule here matches an address and a port and nothing else. Proxmox cannot filter by hostname; a reverse proxy routes by one. So a rule naming a shared proxy on a shared port reaches every service behind that port, and a rule reading "the devbox may reach ntfy" actually says "the devbox may reach whatever is on that port today" - a sentence that changes without anyone touching the firewall. Giving each service its own listen port closes that. The port is enforced on the node, where the guest cannot reach it, so each rule grants exactly one service and the list reads as what it claims to be. The runbook section is now "Reaching a service behind a reverse proxy", with ntfy as the worked example and a table to extend. The template block takes the same shape, with a commented second line showing that further services are the same address and a different port each time - never a second service on a port already listed, which widens a rule without adding one. Two proxy-side requirements are named because a rule is only worth what they are, and both are already in dejima's docs/nginx-dejima.conf.example rather than restated here: a reject default_server on each listen, or server_name isolates nothing; and $http_host rather than $host on any port but 443, or a service comparing Origin against Host refuses every request. Tested with `bats test/` (136 passing). links.bats covers the new anchor, since it globs every tracked markdown file.
251 lines
13 KiB
Plaintext
251 lines
13 KiB
Plaintext
# Proxmox VM firewall for a dejima devbox, as the file Proxmox itself writes
|
|
# to /etc/pve/firewall/<vmid>.fw.
|
|
#
|
|
# REFERENCE, not something to install. proxmox/README.md builds this through
|
|
# the web console instead, one rule at a time - a runbook you can check as you
|
|
# follow it beats a file to drop onto a hypervisor. This exists because the
|
|
# rules are easier to review in one piece than in a table, and because it can
|
|
# say why each one is there.
|
|
#
|
|
# Substitute the placeholders if you do use it directly.
|
|
#
|
|
# For more than one devbox, prefer a security group: the same rules, defined
|
|
# once at datacenter level and referenced by each VM, so the ordering below is
|
|
# got right in one place and cannot drift between hosts. proxmox/README.md
|
|
# section 3 builds it that way. The [OPTIONS] block is NOT part of a group and
|
|
# stays per-VM either way.
|
|
#
|
|
# THIS IS THE BOUNDARY, and on a devbox it is the only one. A CI runner has a
|
|
# second: it is rebuilt from a snapshot on a schedule, so anything that gets in
|
|
# has until the next rollback. A devbox holds uncommitted work and cannot be
|
|
# rolled back on a timer, so nothing reclaims it. What is left is this file,
|
|
# applied to the VM's tap device, where the guest cannot reach it.
|
|
#
|
|
# dejima's own controls inside the container are not a second boundary either.
|
|
# They bound what a session reaches; they do not bound what the VM reaches, and
|
|
# that is the gap these rules exist to close.
|
|
#
|
|
# Requires the firewall enabled at datacenter level as well
|
|
# (Datacenter -> Firewall -> Options -> Firewall: Yes) AND the Firewall
|
|
# checkbox on the VM's network device. Without either, this file is read and
|
|
# enforced nowhere - which looks exactly like it working.
|
|
|
|
[OPTIONS]
|
|
enable: 1
|
|
|
|
# Inbound denies by default. Unlike a CI runner, this VM does offer services
|
|
# somebody should reach - you work on it - so the exceptions below are longer
|
|
# than a runner's and are the part of this file that grows.
|
|
#
|
|
# It covers both address families and needed nothing said about IPv6, because a
|
|
# default policy is not a list of ranges and so cannot leave one out. Compare
|
|
# the outbound half, where the same coverage costs an extra rule.
|
|
policy_in: DROP
|
|
|
|
# Outbound ACCEPTs by default, and the LAN is denied explicitly below.
|
|
#
|
|
# Do not be tempted to invert this - deny outbound, then allow tcp/80, tcp/443
|
|
# and udp/123 - because it is wrong twice over.
|
|
#
|
|
# It breaks things: apt during a first install, and anything else that happens
|
|
# to need a port nobody listed. Every such breakage looks like a network fault
|
|
# rather than a firewall rule, so each one is found the hard way.
|
|
#
|
|
# And it buys almost nothing. This VM is assumed to be owned by an attacker
|
|
# with root; the guarantee being made is "reaches the internet, not your
|
|
# network", not "reaches only three ports". Someone who owns the box tunnels
|
|
# over 443 like everyone else, so the port list constrains honest software and
|
|
# not them.
|
|
#
|
|
# Deny-by-default earns its keep where the protected set is enumerable, which
|
|
# is what the IPv4 DROPs below are. It does not apply to the internet, which
|
|
# is not enumerable, and treating it as though it were gets you a rule set
|
|
# that looks strict and only gets in the way.
|
|
#
|
|
# The cost of default-ACCEPT is that every range to be denied has to be named,
|
|
# and IPv6 is the one it is easiest to forget to name. See the last rule.
|
|
policy_out: ACCEPT
|
|
|
|
log_level_in: nolog
|
|
log_level_out: nolog
|
|
|
|
# If this VM gets its address by DHCP, it needs the two rules marked DHCP
|
|
# below. See the note above them. There is no option to set here.
|
|
|
|
[RULES]
|
|
|
|
# --- inbound ----------------------------------------------------------------
|
|
# Two lists, not one, and the front door gets the narrower.
|
|
#
|
|
# The intuition that SSH is the more dangerous of the two is wrong. Both put a
|
|
# terminal in front of whoever reaches them, so they do not differ in
|
|
# capability. They differ in what stands in front. SSH with public-key auth has
|
|
# nothing to guess and nothing to stuff. The front door has a password, and
|
|
# basic auth brings no rate limiting, no lockout and no second factor unless
|
|
# they are added on purpose. Same capability behind a weaker gate, so the front
|
|
# door is the higher-risk service and takes the smaller source range.
|
|
#
|
|
# Usage lands on that split without anyone forcing it: you administer over SSH
|
|
# from whatever device is to hand, and you want the front door for a phone,
|
|
# because that is what it is for.
|
|
#
|
|
# ONE RULE PER SUBNET. Repeat these lines for each. Do not widen the mask to
|
|
# cover two networks with one entry - a range spanning both is not two subnets,
|
|
# it is every subnet between them.
|
|
IN ACCEPT -source __ADMIN_SUBNET__ -p tcp -dport 22 -log nolog
|
|
IN ACCEPT -source __FRONT_DOOR_SUBNET__ -p tcp -dport __FRONT_DOOR_PORT__ -log nolog
|
|
|
|
# DHCP: omit this and the OUT rule below if the address is static in the guest.
|
|
#
|
|
# The reply to a lease request arrives from the server's own address to this
|
|
# VM's, which is not the tuple the request created - the request went to the
|
|
# server, or to 255.255.255.255 when rebinding. Connection tracking is
|
|
# unlikely to match it against policy_in: DROP, so without this line the reply
|
|
# is dropped and the lease is never renewed.
|
|
#
|
|
# This lets anything on the segment send to the client port, which is a
|
|
# spoofed-reply surface. It is close to free: DHCP on a LAN is
|
|
# trust-on-first-use for every host on that segment whatever the firewall does,
|
|
# so a rule permitting the reply does not create the exposure.
|
|
IN ACCEPT -p udp -dport 68 -log nolog
|
|
|
|
# --- the two LAN exceptions -------------------------------------------------
|
|
# ORDER MATTERS: these must come before the DROPs below, because first match
|
|
# wins and both of these addresses are inside the ranges those DROPs cover.
|
|
#
|
|
# In the web console this is harder than it looks. New rules are inserted at
|
|
# the TOP of the list, so entering them in the order written here produces the
|
|
# reverse of it - DROPs above the pinholes, and a devbox that cannot reach its
|
|
# forge. Drag each one to the bottom as you add it, or enter them backwards.
|
|
# proxmox/README.md section 3 says this where someone following it will see it.
|
|
#
|
|
# These are the entire remaining LAN exposure, and they are why "no LAN
|
|
# access" is not literally true: you cannot push without the forge, and the
|
|
# forge's name cannot resolve without a resolver.
|
|
#
|
|
# Two hosts, however many ports - three if you add the optional one below.
|
|
# Everything else on your network is dropped below, and every addition here
|
|
# should be weighed against that sentence staying short enough to read.
|
|
OUT ACCEPT -dest __FORGE_IP__ -p tcp -dport 443 -log nolog
|
|
# A devbox pushes over SSH. A CI runner only ever talked to the API, which is
|
|
# why this line has no counterpart there.
|
|
OUT ACCEPT -dest __FORGE_IP__ -p tcp -dport 22 -log nolog
|
|
OUT ACCEPT -dest __RESOLVER_IP__ -p udp -dport 53 -log nolog
|
|
OUT ACCEPT -dest __RESOLVER_IP__ -p tcp -dport 53 -log nolog
|
|
|
|
# --- services on your LAN, one port each ------------------------------------
|
|
# OPTIONAL. Omit this block entirely if this VM needs nothing on your network
|
|
# beyond the forge and the resolver.
|
|
#
|
|
# ONE PORT PER SERVICE, AND ONE RULE PER PORT. That is the whole pattern, and
|
|
# it is what makes each line below mean something you can read.
|
|
#
|
|
# The reason is that a rule here matches an address and a port and nothing
|
|
# else. Proxmox cannot filter by hostname; a reverse proxy routes BY hostname.
|
|
# So a rule naming a shared proxy on a shared port reaches every service behind
|
|
# that port, not the one you had in mind - a compromised devbox picks its own
|
|
# Host header and nothing here can tell the difference.
|
|
#
|
|
# Give each service its own listen port on the proxy and that stops being true:
|
|
# the port is enforced below, by something the guest cannot reach, and each
|
|
# line grants exactly one service. Then this list reads as what it is - the
|
|
# services this VM may reach - rather than as a set of addresses whose meaning
|
|
# depends on a proxy config kept somewhere else.
|
|
#
|
|
# Two things to keep true on the proxy side, both in dejima's
|
|
# docs/nginx-dejima.conf.example:
|
|
#
|
|
# - a reject default_server on each listen, or `server_name` isolates
|
|
# nothing and every name resolving to that address reaches the first block;
|
|
# - on a port other than 443, `$http_host` rather than `$host`, or a service
|
|
# that compares Origin against Host refuses every request.
|
|
#
|
|
# proxmox/README.md, "Reaching a service behind a reverse proxy", has the
|
|
# reasoning and a table to fill in.
|
|
#
|
|
# ntfy, below, is the worked example because dejima's remote access needs it:
|
|
# dejima-notify-watch is a systemd USER unit on this VM, not something in a
|
|
# container, so its push leaves the guest and crosses the LAN. Without the
|
|
# rule, remote access is configured, enabled, and silently sends nothing - no
|
|
# error where the operator is looking, and a phone that never buzzes.
|
|
#
|
|
# CHECK WHETHER YOU NEED IT AT ALL. If the name resolves to a public address,
|
|
# egress to the internet from this VM is already unbounded and the rule is
|
|
# redundant. It is needed when the name resolves to a private address.
|
|
#
|
|
# Read the address and the port off your own deployment. Do not assume 443.
|
|
#
|
|
# What this does NOT add is a way to phone home. Egress to the internet from
|
|
# this VM is already unbounded and deliberate, so this widens lateral reach and
|
|
# nothing else.
|
|
#
|
|
# If the service has its own access control, scope this VM's credential to what
|
|
# it actually needs. A devbox with a token that can write to more topics than
|
|
# dejima publishes to can send messages that appear to come from your own
|
|
# infrastructure; one that can READ a topic carrying alerts about the devbox
|
|
# learns what any monitoring did and did not notice.
|
|
OUT ACCEPT -dest __NTFY_IP__ -p tcp -dport __NTFY_PORT__ -log nolog
|
|
# One more line per service, same shape. Same address where they share a proxy,
|
|
# and a different port each time - never a second service on a port already
|
|
# listed above, which would widen a rule without adding one.
|
|
# OUT ACCEPT -dest __SERVICE_IP__ -p tcp -dport __SERVICE_PORT__ -log nolog
|
|
|
|
# DHCP: omit this and the IN rule above if the address is static in the guest.
|
|
#
|
|
# A DHCP server almost always sits inside one of the four ranges dropped
|
|
# below, so a unicast renewal at T1 is dropped without this. It names a port
|
|
# and no host, which makes it narrower than an exception naming the server's
|
|
# address: it adds no reachable host to the list above.
|
|
#
|
|
# Do not rely on rebinding instead. At T2 the client stops unicasting and
|
|
# broadcasts to 255.255.255.255, which none of the DROPs match, so the request
|
|
# leaves - but see the IN rule above for why the reply does not come back.
|
|
# Both halves are needed or neither is.
|
|
OUT ACCEPT -p udp -dport 67 -log nolog
|
|
|
|
# --- everything else on your network ----------------------------------------
|
|
# The six lines the whole VM exists to make true.
|
|
OUT DROP -dest 10.0.0.0/8 -log nolog
|
|
OUT DROP -dest 172.16.0.0/12 -log nolog
|
|
OUT DROP -dest 192.168.0.0/16 -log nolog
|
|
# Link-local, which includes cloud metadata services at 169.254.169.254.
|
|
OUT DROP -dest 169.254.0.0/16 -log nolog
|
|
# Carrier-grade NAT, which is where Tailscale numbers a tailnet. Without this
|
|
# line "your network" quietly means the LAN and not the overlay, and the
|
|
# overlay is the one that reaches the machines you own somewhere else.
|
|
OUT DROP -dest 100.64.0.0/10 -log nolog
|
|
|
|
# IPv6, all of it, and the reason it is one blanket line rather than four more
|
|
# mirroring the ones above.
|
|
#
|
|
# Those work because the protected set is enumerable: RFC 1918 named three
|
|
# ranges and your LAN is inside them. **IPv6 has no RFC 1918.** A v6 network is
|
|
# numbered out of a globally routable prefix your ISP delegates, so "the private
|
|
# ranges" is not a list that can be written here - fc00::/7 covers unique-local
|
|
# addresses most networks do not use, and misses the prefix the machine next
|
|
# door is actually addressed on. Enumerating is not available, so the family
|
|
# goes.
|
|
#
|
|
# This is not the trade policy_out declines above. That argument is about the
|
|
# internet, and it still holds: the internet is reached over IPv4 by the default
|
|
# policy, unbounded and deliberately. What is dropped here is a second path to
|
|
# the same place, and a devbox loses nothing by not having it.
|
|
#
|
|
# It also closes something that is open on a network with no IPv6 at all. Every
|
|
# Linux host brings up a fe80:: address on every interface whether or not
|
|
# anything routes v6, and a daemon bound to :: answers on it. Without this line
|
|
# the five DROPs above can all be in force and the machine beside this one is
|
|
# still one `ssh fe80::...%eth0` away.
|
|
#
|
|
# Do this here rather than by disabling IPv6 in the guest. The guest's own
|
|
# settings are administered by whoever holds root on the guest, and this whole
|
|
# file exists because that is assumed to be an attacker.
|
|
#
|
|
# If the console rejects `::/0`, the same coverage is fe80::/10, fc00::/7,
|
|
# ff00::/8 and 2000::/3. Prefer the one line: four are four chances to enter
|
|
# three.
|
|
OUT DROP -dest ::/0 -log nolog
|
|
|
|
# Nothing follows. The public internet is reached by the default policy, not
|
|
# by a list of ports - see the note on policy_out above.
|