Initial commit, squashed

This commit is contained in:
Timotej Lazar 2023-12-18 11:22:14 +01:00
commit 158e8740b8
83 changed files with 2718 additions and 0 deletions

View file

@ -0,0 +1,2 @@
# The init script for conntrackd wants this, not sure about conntrackd itself.
net.netfilter.nf_conntrack_tcp_be_liberal = 1

View file

@ -0,0 +1,15 @@
# This is used by sshd in default VRF to receive configuration updates. Lock
# down to only allow executing the update script.
# Only allow pubkey auth.
KbdInteractiveAuthentication no
PasswordAuthentication no
PermitRootLogin prohibit-password
# Disable what we can.
AllowTcpForwarding no
GatewayPorts no
X11Forwarding no
# And then disable everything else.
ForceCommand /usr/local/bin/update

View file

@ -0,0 +1,34 @@
#!/bin/sh
apply() {
cp -R /opt/config/etc/nftables.d /etc || return 1
nft -I /etc/nftables.d -f /etc/nftables.nft || return 2
cp -R /opt/config/etc/wireguard /etc || return 3
wg syncconf wg /etc/wireguard/wg.conf || return 4
}
cleanup() {
rm -fr /opt/config
}
# clean now and on exit
cleanup
trap cleanup EXIT
mkdir -p /opt/config
tar xz -C /opt/config --warning=no-timestamp
current="$(cat /opt/version 2>/dev/null || echo -1)"
next="$(cat /opt/config/version 2>/dev/null || echo -1)"
echo "Updating config from v${current} to v${next}"
if [ "${next:-0}" -ne "${current:-0}" ] ; then
echo "Applying config v${next}"
if apply ; then
echo "${next}" > /opt/version
echo "Applied config v${next}"
else
error="$?"
echo "Could not apply config v${next}, error ${error}"
exit "${error}"
fi
fi

View file

@ -0,0 +1,41 @@
- name: enable interfaces
command: ifup --auto
when: "'handler' not in ansible_skip_tags"
- name: mkinitfs
command: mkinitfs
when: "'handler' not in ansible_skip_tags"
- name: reload frr
command: /usr/lib/frr/frr-reload.py --reload /etc/frr/frr.conf
when: "'handler' not in ansible_skip_tags"
- name: reload nftables
service:
name: nftables
state: reloaded
when: "'handler' not in ansible_skip_tags"
- name: restart conntrackd
service:
name: conntrackd
state: restarted
when: "'handler' not in ansible_skip_tags"
- name: restart frr
service:
name: frr
state: restarted
when: "'handler' not in ansible_skip_tags"
- name: reload sshd.friwall
service:
name: sshd.friwall
state: reloaded
when: "'handler' not in ansible_skip_tags"
- name: restart sshd.friwall
service:
name: sshd.friwall
state: restarted
when: "'handler' not in ansible_skip_tags"

View file

@ -0,0 +1,59 @@
- name: Install packages for config updates
package:
name: tar
- name: Limit SSH for config updates
copy:
dest: /etc/ssh/
src: sshd_config.friwall
notify: reload sshd.friwall
- name: Create SSH service for config updates
file:
path: /etc/init.d/sshd.friwall
src: /etc/init.d/sshd
state: link
- name: Configure SSH service for config updates
copy:
dest: /etc/conf.d/sshd.friwall
content: |
cfgfile="/etc/ssh/sshd_config.friwall"
vrf="default"
notify: restart sshd.friwall
- name: Enable SSH service for config updates
service:
name: sshd.friwall
enabled: yes
state: started
- name: Install config updater
copy:
dest: /usr/local/bin/
src: update
mode: 0700
- name: Get master SSH key
delegate_to: '{{ master }}'
command: "cat ~friwall/.ssh/id_ed25519.pub"
register: master_key
changed_when: false
- name: Deploy master key on node
authorized_key: "user=root key={{ master_key.stdout }}"
- name: Get my host SSH key
command: cat /etc/ssh/ssh_host_ed25519_key.pub
register: node_key
changed_when: false
- name: Introduce myself to master
delegate_to: '{{ master }}'
become: yes
become_user: friwall
become_method: su
become_flags: "-s /bin/sh" # no login shell for user
known_hosts:
name: "{{ inventory_hostname }}"
key: "{{ inventory_hostname }},{{ interfaces | selectattr('name', '==', 'lo') | map(attribute='ip_addresses') | first | selectattr('role') | selectattr('role.value', '==', 'loopback') | map(attribute='address') | ipv4 | first | ipaddr('address') }} {{ node_key.stdout }}" # TODO make IP retrieval less terrifying

View file

@ -0,0 +1,36 @@
- name: Install conntrack-tools
package:
name: conntrack-tools
# Ensure the module is loaded before setting sysctl values.
- name: Autoload nf_conntrack
lineinfile:
dest: /etc/modules-load.d/netfilter.conf
line: nf_conntrack
create: yes
# Set required sysctl values.
- name: Set sysctl values for conntrackd
copy:
dest: /etc/sysctl.d/
src: conntrackd.conf
- name: Set up conntrackd
template:
dest: /etc/conntrackd/conntrackd.conf
src: conntrackd.conf.j2
mode: 0644
notify: restart conntrackd
- name: Run conntrackd in default VRF
lineinfile:
dest: /etc/conf.d/conntrackd
line: 'vrf="default"'
regexp: '^vrf='
notify: restart conntrackd
- name: Enable conntrackd
service:
name: conntrackd
enabled: yes
state: started

View file

@ -0,0 +1,48 @@
- name: Enable sysctl service
service:
name: sysctl
enabled: yes
runlevel: boot
state: started
- name: Enable community package repo
lineinfile:
path: /etc/apk/repositories
regexp: '^# *(http.*/v[^/]*/community)'
line: '\1'
backrefs: yes
- name: Install FRR
package:
name: frr,frr-pythontools
state: latest
- name: Set datacenter defaults
lineinfile:
path: /etc/frr/daemons
regexp: '^frr_profile='
line: 'frr_profile="datacenter"'
notify: restart frr
- name: Enable BGP and BFD
lineinfile:
path: /etc/frr/daemons
regexp: "^{{ item }}="
line: "{{ item }}=yes"
loop:
- bfdd
- bgpd
notify: restart frr
- name: Enable FRR service
service:
name: frr
enabled: yes
state: started
- name: Copy FRR config
template:
dest: /etc/frr/frr.conf
src: frr.conf.j2
mode: 0644
notify: reload frr

View file

@ -0,0 +1,64 @@
- name: Update package cache
package:
update_cache: yes
- name: Install packages
package:
name: bash,bonding,iproute2
state: latest
- name: Tell mdev to rename network interfaces
lineinfile:
path: /etc/mdev.conf
line: '-net/.* root:root 600 @/sbin/nameif -s'
insertafter: '^# net devices'
notify: mkinitfs
- name: Configure interface names
template:
dest: /etc/mactab
src: mactab.j2
mode: 0644
- name: Create /etc/network/interfaces.d
file:
path: /etc/network/interfaces.d
state: directory
mode: 0755
- name: Set up interfaces
template:
dest: /etc/network/interfaces
src: interfaces.j2
mode: 0644
notify: enable interfaces
- name: Set up management interfaces
import_tasks: mgmt.yml
- name: Set up data interfaces
template:
dest: /etc/network/interfaces.d/fabric.intf
src: fabric.intf.j2
mode: 0644
notify: enable interfaces
- name: Set up sysctls
template:
dest: /etc/sysctl.d/firewall.conf
src: sysctl.conf.j2
- name: Set up FRR
import_tasks: frr.yml
- name: Set up wireguard
import_tasks: wireguard.yml
- name: Set up nftables
import_tasks: nftables.yml
- name: Set up conntrackd
import_tasks: conntrackd.yml
- name: Set up configuration channel
import_tasks: config.yml

View file

@ -0,0 +1,25 @@
- name: Set up management interfaces
template:
dest: /etc/network/interfaces.d/mgmt.intf
src: mgmt.intf.j2
mode: 0644
register: task_mgmt_interface
- name: Run SSH in management VRF
lineinfile:
path: /etc/conf.d/sshd
line: "vrf=\"mgmt\""
register: task_ssh_vrf
- name: Reboot for new VRF
reboot:
when: task_mgmt_interface.changed or task_ssh_vrf.changed
register: task_reboot
- name: Reset the connection
meta: reset_connection
- name: Wait for the network device to reload
wait_for_connection:
delay: 10
when: task_reboot.changed

View file

@ -0,0 +1,25 @@
- name: Install nftables
package:
name: nftables
- name: Copy nftables config
template:
dest: /etc/nftables.nft
src: nftables.nft.j2
mode: 0644
notify: reload nftables
- name: Copy static nftables includes
template:
dest: '/etc/nftables.d/{{ item }}'
src: '{{ item }}.j2'
mode: 0644
loop:
- interfaces.nft
notify: reload nftables
- name: Enable nftables service
service:
name: nftables
enabled: yes
state: started

View file

@ -0,0 +1,26 @@
# All firewall nodes share one external IP for wireguard connections.
# Private key and peer configuration is the same for all nodes. Peers
# connected to each node are installed in the routing table and
# distributed into fabric.
- name: Install wireguard tools
package:
name: wireguard-tools
- name: Create wireguard directory
file:
path: /etc/wireguard
state: directory
- name: Touch wireguard config
file:
path: /etc/wireguard/wg.conf
state: touch
access_time: preserve
modification_time: preserve
- name: Add wireguard interface
template:
dest: /etc/network/interfaces.d/wg.intf
src: wg.intf.j2
notify: enable interfaces

View file

@ -0,0 +1,50 @@
{% set fw = inventory_hostname.split('-')[1]|int -%}
Sync {
Mode FTFW {
# Add received rules immediately so we don’t need a
# signal on failover.
DisableExternalCache On
}
UDP {
Interface {{ iface_sync }}
IPv6_address fe80::{{ fw }}
IPv6_Destination_Address fe80::{{ 2 if fw == 1 else 1 }}
Port 3780
# Recommended by manual.
Checksum on
RcvSocketBuffer 1249280
SndSocketBuffer 1249280
}
#Options {
# TCPWindowTracking Off
#}
}
General {
UNIX {
Path /var/run/conntrackd.ctl
}
Syslog on
# Recommended by manual.
HashLimit 524288
NetlinkBufferSize 2097152
NetlinkBufferSizeMaxGrowth 8388608
Filter From Kernelspace {
# Don’t replicate rules for traffic from/to firewall.
Address Ignore {
IPv4_address 127.0.0.1/8
IPv6_address ::1/128
IPv6_address fe80::/64 # link-local addresses
IPv4_address {{ wg_ip }}
{% for address in interfaces | map(attribute='ip_addresses') | flatten | sort(attribute='address') %}
IPv{{ address.family.value }}_address {{ address.address }}
{% endfor %}
}
}
}

View file

@ -0,0 +1,12 @@
{% for iface in interfaces | selectattr('name', 'match', '^lan') | map(attribute='name') %}
auto {{ iface }}
iface {{ iface }}
mtu 9216
auto {{ iface }}.2
iface {{ iface }}.2
auto {{ iface }}.4
iface {{ iface }}.4
{% endfor %}

View file

@ -0,0 +1,141 @@
{% set addrs = interfaces | selectattr('name', '==', 'lo') |
map(attribute='ip_addresses') | first | selectattr('role') %}
{% set loopback = addrs | selectattr('role.value', '==', 'loopback') | map(attribute='address') -%}
frr defaults datacenter
service integrated-vtysh-config
log syslog
# Without this frr and kernel ECMP routes sometimes get desynced when a link is
# lost and found. Maybe related to https://github.com/FRRouting/frr/issues/12239.
zebra nexthop-group keep 1
router-id {{ loopback | ipv4 | first | ipaddr('address') }}
# Don’t announce anything at start until we get routes from all our peers.
# Without this packets might get dropped until all routes are synced.
bgp update-delay 10
bfd
profile fast
receive-interval 150
transmit-interval 150
# Default VRF has two connections to each exit, one for inside and one
# for outside networks. The efault route is received from the outside
# peers and distributed back to inside peers. Routes to office
# networks and NAT IPs are distributed to outside peers.
router bgp {{ asn.asn }}
# Allow multipathing through different ASs with equal path length.
bgp bestpath as-path multipath-relax
# NAT IPs are not on any interface so disable checking for it.
no bgp network import-check
{% for group in ['inside', 'outside'] %}
neighbor {{ group }} peer-group
neighbor {{ group }} remote-as external
neighbor {{ group }} capability extended-nexthop
{% endfor %}
{% for iface in interfaces | selectattr('name', 'match', '^lan') %}
neighbor {{ iface.name }}.2 interface peer-group inside
neighbor {{ iface.name }}.2 bfd profile fast
neighbor {{ iface.name }}.4 interface peer-group outside
neighbor {{ iface.name }}.4 bfd profile fast
{% endfor %}
address-family ipv4 unicast
{% for network in nat %}
network {{ network }}
{% endfor %}
redistribute connected route-map loopback
maximum-paths 16
neighbor outside soft-reconfiguration inbound
neighbor outside route-map outside->default in
neighbor outside route-map default->outside out
neighbor inside allowas-in origin
neighbor inside default-originate
neighbor inside soft-reconfiguration inbound
neighbor inside route-map inside->default in
neighbor inside route-map default->inside out
exit-address-family
address-family ipv6 unicast
redistribute connected route-map loopback
maximum-paths 16
neighbor outside activate
neighbor outside soft-reconfiguration inbound
neighbor outside route-map outside->default in
neighbor outside route-map default->outside out
neighbor inside activate
neighbor inside allowas-in origin
neighbor inside default-originate
neighbor inside soft-reconfiguration inbound
neighbor inside route-map inside->default in
neighbor inside route-map default->inside out
exit-address-family
# Prefix lists.
ip prefix-list default permit 0.0.0.0/0
ipv6 prefix-list default permit ::/0
ip prefix-list fabric permit 10.34.0.0/24 ge 32
{% for vlan in vlans %}
{% for prefix in query('netbox.netbox.nb_lookup', 'prefixes', api_filter='vlan_id='~vlan.id, raw_data=true) %}
{% if prefix.family.value == 4 %}
ip prefix-list office permit {{ prefix.prefix }} ge 24
{% else %}
ipv6 prefix-list office permit {{ prefix.prefix }} ge 64
{% endif %}
{% endfor %}
{% endfor %}
ip prefix-list vpn permit {{ wg_net | ipaddr('subnet') }}
{% for network in nat %}
ip prefix-list nat permit {{ network }}
{% endfor %}
{# TODO WG endpoint should probably be in a separate prefix-list. #}
ip prefix-list nat permit {{ wg_ip }}
route-map loopback permit 1
match interface lo
# Get routes to offices and VPN users on other firewalls from inside peers.
route-map inside->default permit 10
match ip address prefix-list fabric
route-map inside->default permit 20
match ip address prefix-list office
route-map inside->default permit 21
match ipv6 address prefix-list office
# Send default route and VPN network to inside peers.
route-map default->inside permit 1
match interface lo
route-map default->inside permit 20
match ip address prefix-list default
route-map default->inside permit 21
match ipv6 address prefix-list default
route-map default->inside permit 30
match ip address prefix-list vpn
# Get default route from outside peers.
route-map outside->default permit 10
match ip address prefix-list default
route-map outside->default permit 11
match ipv6 address prefix-list default
# Send IPv6 office addresses and IPv4 NAT addresses to outside peers
# so inbound packets go through the firewall.
route-map default->outside permit 1
match interface lo
route-map default->outside permit 11
match ipv6 address prefix-list office
route-map default->outside permit 20
match ip address prefix-list nat

View file

@ -0,0 +1,10 @@
{% set addrs = interfaces | selectattr('name', '==', 'lo') | map(attribute='ip_addresses') | first -%}
source-directory /etc/network/interfaces.d
auto lo
iface lo inet loopback
address {{ wg_ip }}
{% for address in addrs %}
address {{ address.address }}
{% endfor %}

View file

@ -0,0 +1,10 @@
{% set ifaces_fabric = interfaces | selectattr('name', 'match', '^lan') | map(attribute='name') %}
set inside {
type iface_index
elements = { {{ ifaces_fabric | product(['2']) | map('join', '.') | join(', ') }}, wg }
}
set outside {
type iface_index
elements = { {{ ifaces_fabric | product(['4']) | map('join', '.') | join(', ') }} }
}

View file

@ -0,0 +1,3 @@
{% for iface in interfaces | iface_real %}
{{ iface.name }} {{ iface.mac_address | lower }}
{% endfor %}

View file

@ -0,0 +1,24 @@
auto mgmt
iface mgmt
pre-up ip link add $IFACE type vrf table 100
up ip link set dev $IFACE up
post-down ip link del $IFACE
{% for iface in interfaces | selectattr('name', 'match', '^mgmt') %}
auto {{ iface.name }}
iface {{ iface.name }}
{% if iface.vrf %}
requires {{ iface.vrf.name }}
pre-up ip link set $IFACE master {{ iface.vrf.name }}
{% endif %}
{% if iface.mtu %}
mtu {{ iface.mtu }}
{% endif %}
{% for addr in iface.ip_addresses %}
address {{ addr.address }}
{% endfor %}
{% if iface.custom_fields.gateway %}
up ip route add default via {{ iface.custom_fields.gateway.address | ipaddr('address') }}{% if iface.vrf %} vrf {{ iface.vrf.name }}{% endif %}
{% endif +%}
{% endfor %}

View file

@ -0,0 +1,117 @@
#!/usr/sbin/nft -f
{% set ifaces_fabric = interfaces | selectattr('name', 'match', '^lan') | map(attribute='name') %}
flush ruleset
table inet filter {
include "/etc/nftables.d/interfaces.nft"
include "/etc/nftables.d/sets.nft*"
set link {
type iface_index
elements = { {{ ifaces_fabric | product(['2', '4']) | map('join', '.') | join(', ') }} }
}
chain input {
type filter hook input priority 0; policy drop
ct state vmap { established : accept, related : accept, invalid : drop } \
comment "Accept established streams and drop invalid connections"
iif lo accept \
comment "Accept any localhost traffic"
iif mgmt tcp dport ssh accept \
comment "Accept SSH from management VRF"
tcp dport ssh ip saddr {{ hostvars[master]['ansible_host'] }} accept \
comment "Accept SSH from firewall master"
iif @link tcp dport bgp ip6 saddr fe80::/10 accept \
comment "Accept link-local BGP on fabric links"
iif @link udp dport 3784 ip6 saddr fe80::/10 accept \
comment "Accept link-local BFD on fabric links"
iif @outside udp dport 51820 accept \
comment "Accept WireGuard from outside"
iif {{ iface_sync }} ip6 saddr fe80::/10 udp dport 3780 accept \
comment "Accept connection tracking sync data"
tcp dport auth reject with icmpx type port-unreachable \
comment "Reject AUTH to make it fail fast"
# ICMPv4
ip protocol icmp icmp type {
echo-request, echo-reply, destination-unreachable,
parameter-problem, time-exceeded,
} accept \
comment "Accept ICMP"
# ICMPv6
ip6 nexthdr icmpv6 icmpv6 type {
echo-request, echo-reply, destination-unreachable,
packet-too-big, parameter-problem, time-exceeded,
} accept \
comment "Accept basic IPv6 functionality"
ip6 nexthdr icmpv6 icmpv6 type {
nd-neighbor-solicit, nd-neighbor-advert,
nd-router-solicit, nd-router-advert,
} ip6 hoplimit 255 accept \
comment "Allow IPv6 neighbor discovery"
}
chain forward {
type filter hook forward priority filter; policy drop
ct state { established, related } accept \
comment "Forward all established and related traffic"
ct status dnat accept \
comment "Forward DNAT traffic for servers and suchlike"
# Forward IPv4 to/from VPN users in the same network.
{% for vlan in vlans %}
iif @inside ip saddr @{{ vlan.name }} ip daddr @{{ vlan.name }} accept
{% endfor %}
# Forward IPv6 to/from VPN users in the same network.
{% for vlan in vlans %}
iif @inside ip6 saddr @{{ vlan.name }}/6 ip6 daddr @{{ vlan.name }}/6 accept
{% endfor %}
include "/etc/nftables.d/forward.nft*"
}
chain output {
type filter hook output priority 0; policy accept
}
}
table ip nat {
include "/etc/nftables.d/interfaces.nft"
include "/etc/nftables.d/sets.nft*"
include "/etc/nftables.d/netmap.nft*"
# Ensure these maps exist even if empty.
map netmap-in { type ipv4_addr : interval ipv4_addr; flags interval; }
map netmap-out { type ipv4_addr : interval ipv4_addr; flags interval; }
chain postrouting {
type nat hook postrouting priority srcnat
iif @inside oif @outside snat ip prefix to ip saddr map @netmap-out \
comment "Static source NAT for 1:1 mapped addresses"
include "/etc/nftables.d/nat.nft*"
}
chain prerouting {
type nat hook prerouting priority dstnat
dnat ip prefix to ip daddr map @netmap-in \
comment "Static destination NAT for 1:1 mapped addresses"
}
}

View file

@ -0,0 +1,19 @@
# We are router.
net.ipv4.ip_forward = 1
net.ipv6.conf.all.forwarding = 1
# But not for management interfaces.
{% for iface in interfaces | selectattr('name', 'match', '^mgmt') %}
net.ipv4.conf.{{ iface.name }}.forwarding = 0
net.ipv6.conf.{{ iface.name }}.forwarding = 0
{% endfor %}
# Zebra docs recommend these.
net.ipv6.conf.all.keep_addr_on_down = 1
net.ipv6.route.skip_notify_on_dev_down = 1
# Do not send ICMP redirects. Happens because firewall sees all office
# networks coming from the same routers, and gets confused as to why
# firewall is routing packets between them.
net.ipv4.conf.all.send_redirects = 0
net.ipv4.conf.default.send_redirects = 0

View file

@ -0,0 +1,4 @@
auto wg
iface wg inet static
use wireguard
address {{ wg_net }}