diff --git a/README.md b/README.md
index 191a08d..99ec031 100644
--- a/README.md
+++ b/README.md
@@ -10,6 +10,8 @@ Either clone repository (and install dependencies either through distribution or
In production put a reverse proxy in front of the local web ui (on 127.0.0.1:8000), and handle `/static` path either to `src/capport/api/static/` or your customized version of static files.
+See the `contrib` directory for config of other software needed to setup a captive portal.
+
## Customization
Create `custom/templates` and put customized templates (from `src/capport/api/templates`) there.
diff --git a/contrib/README.md b/contrib/README.md
new file mode 100644
index 0000000..c3bf77d
--- /dev/null
+++ b/contrib/README.md
@@ -0,0 +1,54 @@
+# Various other parts of a captive portal setup
+
+Network (HA) setup with IPv4 NAT:
+- two nodes
+- shared uplink L2, some transfer network (can be private or public)
+ * virtual addresses on active node for IPv4 and IPv6 to route traffic to
+- shared downlink L2
+ * virtual addresses on active node for IPv4 and IPv6 as gateway for clients
+ * using `fe80::1` as gateway, but also add a public IPv6 virtual address
+ * connected: private IPv4 prefix (e.g. CGNAT), not routed
+ * connected: public IPv6 prefix (routed to virtual uplink address of nodes)
+- public IPv4 prefix routed virtual uplink address of nodes to use for NAT
+ * IPv4-traffic from clients will be (S)NATted from this prefix; size depends
+ on number of parallel connections you want to support.
+- webserver on nodes:
+ * port 8080: receives transparent http redirects from the firewall; should return a temporary redirect to your portal page.
+ * port 80: redirect to https
+ * port 443: reverse-proxy to 127.0.0.1:8000 (the webui backend), but serve `/static` directly from directory (see main README)
+
+To access the portal page on the clients you'll need a DNS-name; it should point to the virtual addresses. In some ways downlink address is preferred, but you also might want to avoid private addresses - i.e. use the uplink IPv4 address and the downlink IPv6 address.
+
+Also the management traffic for the virtual address should use the uplink interface if possible (`keepalived` supports this).
+
+## ISC dhcpd
+
+See `dhcpd.conf.erb` and `dhcpd6.conf.erb`.
+
+Note: don't use too large IPv4 pools or dhcpd will take a long time to sync and build up the leases files.
+
+## Firewall / NAT
+
+See `nftables.conf.erb` for forwarding rules; if you want traffic shaping as well see `shape_non_whitelisted.sh`.
+Local policies (ssh access and normal "host protection") are not included in the example.
+
+You also might want to set a high `net.netfilter.nf_conntrack_max` with sysctl (e.g. `16777216`).
+
+## Conntrackd
+
+Active/failover configuration TBD.
+
+I strongly recommend not to enable any tracking helpers; they often make significant holes into your stateful firewall (i.e. make clients reachable from the outside in ways they didn't actually want).
+
+## Keepalived (for virtual addresses)
+
+See `keepalived.conf.erb`.
+
+## Apache2
+
+See `apache2.conf` (only contains "interesting" parts, probably won't start that way).
+Any other webserver configured in a similar way should do just as well.
+
+## systemd units
+
+See the `systemd` directory for examples of systemd units.
diff --git a/contrib/apache2.conf b/contrib/apache2.conf
new file mode 100644
index 0000000..eeec041
--- /dev/null
+++ b/contrib/apache2.conf
@@ -0,0 +1,43 @@
+Listen 80
+Listen 443
+Listen 8080
+
+
+ ServerName redirect
+
+ Header always set Cache-Control "no-store"
+ # trailing '?' drops request query string:
+ RedirectMatch seeother ^.*$ https://portal.example.com?
+ KeepAlive off
+
+
+
+ ServerName portal.example.com
+ ServerAlias portal-node1.example.com
+
+ Redirect permanent / https://portal.example.com/
+
+
+
+ ServerName portal.example.com
+ ServerAlias portal-node1.example.com
+
+ SSLEngine on
+ SSLCertificateFile "/etc/ssl/certs/portal.example.com-with-chain.crt"
+ SSLCertificateKeyFile "/etc/ssl/private/portal.example.com.key"
+
+ # The static directory of your theme (or the builtin one)
+ Alias /static "/var/lib/python-capport/custom/static"
+
+ Header always set X-Frame-Options DENY
+ Header always set Referrer-Policy same-origin
+ Header always set X-Content-Type-Options nosniff
+ Header always set Strict-Transport-Security "max-age=31556926;"
+
+ RequestHeader set "X-Forwarded-Proto" expr=%{REQUEST_SCHEME}
+
+ ProxyRequests Off
+ ProxyPreserveHost On
+ ProxyPass /static !
+ ProxyPass / http://127.0.0.1:8000/
+
diff --git a/contrib/dhcpd.conf.erb b/contrib/dhcpd.conf.erb
new file mode 100644
index 0000000..8e93a1a
--- /dev/null
+++ b/contrib/dhcpd.conf.erb
@@ -0,0 +1,35 @@
+option domain-name-servers <%= ', '.join(@dns_resolvers_ipv4) %>;
+option ntp-servers <%= ', '.join(@ntp_servers_ipv4) %>;
+
+# specify API server URL (RFC8910)
+option default-url "https://<%= @service_name %>/api/captive-portal";
+
+default-lease-time 600;
+max-lease-time 3600;
+
+authoritative;
+
+<% if @instances.length == 2 -%>
+failover peer "dhcp-peer" {
+ <% if @instance_index == 0 %>primary<% else %>secondary<% end %>;
+ address <%= @instances[@instance_index]['external_ipv4'] %>;
+ peer address <%= @instances[1-@instance_index]['external_ipv4'] %>;
+ max-response-delay 60;
+ max-unacked-updates 10;
+ load balance max seconds 3;
+<% if @instance_index == 0 -%>
+ split 128;
+ mclt 180;
+<%- end %>
+}
+<%- end %>
+
+subnet <%= @client_ipv4_net %> netmask <%= @client_netmask %> {
+ option routers <%= @client_ipv4_gateway %>;
+ pool {
+ range <%= @client_ipv4_dhcp_from %> <%= @client_ipv4_dhcp_to %>;
+<% if @instances.length == 2 -%>
+ failover peer "dhcp-peer";
+<%- end %>
+ }
+}
diff --git a/contrib/dhcpd6.conf.erb b/contrib/dhcpd6.conf.erb
new file mode 100644
index 0000000..b20d2c9
--- /dev/null
+++ b/contrib/dhcpd6.conf.erb
@@ -0,0 +1,13 @@
+option dhcp6.name-servers <%= ', '.join(@dns_resolvers_ipv6) %>;
+option dhcp6.sntp-servers <%= ', '.join(@ntp_servers_ipv6) %>;
+
+# specify API server URL (RFC8910)
+option dhcp6.v6-captive-portal "https://<%= @service_name %>/api/captive-portal";
+
+# The delay before information-request refresh
+# (minimum is 10 minutes, maximum one day, default is to not refresh)
+# (set to 6 hours)
+option dhcp6.info-refresh-time 3600;
+
+subnet6 <%= @client_ipv6 %> {
+}
diff --git a/contrib/keepalived.conf.erb b/contrib/keepalived.conf.erb
new file mode 100644
index 0000000..0adb373
--- /dev/null
+++ b/contrib/keepalived.conf.erb
@@ -0,0 +1,49 @@
+global_defs {
+ vrrp_no_swap
+ checker_no_swap
+ script_user nobody
+ enable_script_security
+}
+
+vrrp_instance capport_ipv4_default {
+ state BACKUP
+
+ interface <%= @uplink_interface %>
+
+ virtual_router_id 1
+
+ priority 100
+
+ virtual_ipaddress {
+ <% @uplink_virtual_ipv4 %>
+ <% @client_virtual_ipv4 %> dev <%= @client_interface %>
+ }
+
+ promote_secondaries
+}
+
+vrrp_instance capport_ipv6_default {
+ state BACKUP
+
+ interface <%= @uplink_interface %>
+
+ virtual_router_id 2
+
+ priority 100
+
+ virtual_ipaddress {
+ fe80::1:1
+ <%= @uplink_virtual_ipv6 %>
+ fe80::1 dev <%= @client_interface %>
+ <%= @client_virtual_ipv6 %> dev <%= @client_interface %>
+ }
+
+ promote_secondaries
+}
+
+vrrp_sync_group capport_default {
+ group {
+ capport_ipv4_default
+ capport_ipv6_default
+ }
+}
diff --git a/contrib/nftables.conf.erb b/contrib/nftables.conf.erb
new file mode 100644
index 0000000..b28df9a
--- /dev/null
+++ b/contrib/nftables.conf.erb
@@ -0,0 +1,360 @@
+#!/usr/sbin/nft -f
+
+# Template notes: most variables should have an obvious meaning, but:
+# - client_ipv4: private IPv4 network for clients (not routed outside), connected
+# - client_ipv4_public: public IPv4 network for clients, must be routed to this host
+# and should be blackholed here.
+# - client_ipv6: public IPv6 network, must be routed to this host, connected
+
+# NOTE: mustn't flush full ruleset; need to keep the table `captive_mark` and its set around
+# DON'T ENABLE THIS:
+# flush ruleset
+
+# fully whitelist certain sites, e.g. VPN gateways your users are
+# allowed to connect to even without accepting the terms.
+define full_ipv4 = {
+<%- @whitelist_full_ipv4.each do |n| -%>
+ <%= n %>,
+<%- end -%>
+}
+define full_ipv6 = {
+<%- @whitelist_full_ipv6.each do |n| -%>
+ <%= n %>,
+<%- end -%>
+}
+
+# whitelist http[s] traffic to certain sites, e.g. your
+# homepage hosting the terms, OCSP responders, websites
+# to setup other Wifi configurations (cat.eduroam.org)
+define http_server_ipv4 = {
+<%- @whitelist_http_ipv4.each do |n| -%>
+ <%= n %>,
+<%- end -%>
+}
+define http_server_ipv6 = {
+<%- @whitelist_http_ipv6.each do |n| -%>
+ <%= n %>,
+<%- end -%>
+}
+
+option ntp-servers <%= ', '.join(@ntp_servers_ipv4) %>;
+option dhcp6.sntp-servers <%= ', '.join(@ntp_servers_ipv6) %>;
+
+# whitelist your DNS resolvers
+define dns_server_ipv4 = {
+<%- @dns_resolvers_ipv4.each do |n| -%>
+ <%= n %>,
+<%- end -%>
+}
+define dns_server_ipv6 = {
+<%- @dns_resolvers_ipv6.each do |n| -%>
+ <%= n %>,
+<%- end -%>
+}
+
+# whitelist your (and possible other friendly) NTP servers
+define ntp_server_ipv4 = {
+<%- @ntp_servers_ipv4.each do |n| -%>
+ <%= n %>,
+<%- end -%>
+}
+define ntp_server_ipv6 = {
+<%- @ntp_servers_ipv6.each do |n| -%>
+ <%= n %>,
+<%- end -%>
+}
+
+# ports to block traffic for completely
+## block traffic from clients to certain server ports
+define backlist_tcp = {
+ 25, # SMTP
+ 161, # SNMP
+ 135, # epmap (netbios/portmapping)
+ 137, # TCP netbios-ns
+ 138, # TCP netbios-dgm
+ 139, # netbios-ssn
+ 445, # microsoft-ds (cifs, samba)
+}
+define backlist_udp = {
+ 25, # SMTP
+ 161, # SNMP
+ 135, # UDP epmap (netbios/portmapping)
+ 137, # netbios-ns
+ 138, # netbios-dgm
+ 139, # UDP netbios-ssn
+ 445, # UDP microsoft-ds (cifs, samba)
+ 5353, # mDNS
+}
+## block traffic from certain client ports
+define backlist_udp_source = {
+ 162, # SNMP trap
+}
+
+# once a client accepted the terms:
+# * define "good" (whitelisted) traffic with the following lists
+# * decide in "chain forward" below whether to block other traffic completely or
+# e.g. shape it to low bandwidth (also see shape_non_whitelisted.sh)
+define whitelist_tcp = {
+ 22, # ssh
+ 53, # dns
+ 80, # http
+ 443, # https
+ 3128, # http proxy (squid)
+ 8080, # http alt
+ 110, # pop3
+ 995, # pop3s
+ 143, # imap
+ 993, # imaps
+ 587, # submission
+ 465, # submissions
+ 1194, # openvpn default
+}
+# https://help.webex.com/en-us/article/WBX264/How-Do-I-Allow-Webex-Meetings-Traffic-on-My-Network
+define whitelist_udp = {
+ 53, # dns
+ 123, # ntp
+ 443, # http/3
+ 1194, # openvpn default
+ 51820, # wireguard default
+ 500, # IPsec isakmp
+ 4500, # IPsec ipsec-nat-t
+ 10000, # IPSec Cisco NAT-T
+ 9000, # Primary Webex Client Media
+ 5004, # Webex Client Media
+}
+# whitelist traffic to local sites
+define whitelist_dest_ipv4 = {
+<%- @local_site_prefixes_ipv4.each do |n| -%>
+ <%= n %>,
+<%- end -%>
+}
+define whitelist_dest_ipv6 = {
+<%- @local_site_prefixes_ipv6.each do |n| -%>
+ <%= n %>,
+<%- end -%>
+}
+
+# IPv4 HTTP redirect + SNAT
+table ip nat4 {}
+flush table ip nat4
+table ip nat4 {
+ chain prerouting {
+ type nat hook prerouting priority -100;
+ policy accept;
+ # needs to be marked from client interface (bit 0), captive (bit 1), and http dnat (bit 2) - otherwise accept
+ meta mark & 0x00000007 != 0x00000007 accept
+ tcp dport 80 redirect to 8080
+ }
+
+ chain postrouting {
+ type nat hook postrouting priority 100;
+ policy accept;
+ # needs to be marked from client interface (bit 0) - otherwise no NAT
+ meta mark & 0x00000001 != 0x00000001 accept
+ oifname <%= @uplink_interface %> snat to <%= @client_ipv4_public %>
+ }
+}
+
+# IPv6 HTTP redirect
+table ip6 nat6 {}
+flush table ip6 nat6
+table ip6 nat6 {
+ chain prerouting {
+ type nat hook prerouting priority -100;
+ policy accept;
+ # needs to be marked from client interface (bit 0), captive (bit 1), and http dnat (bit 2) - otherwise accept
+ meta mark & 0x00000007 != 0x00000007 accept
+ tcp dport 80 redirect to 8080
+ }
+}
+
+# ipv4 + ipv6
+table inet captive_filter {}
+flush table inet captive_filter
+table inet captive_filter {
+ chain antispoof_input {
+ # need to accept packet for input and forward!
+ meta mark & 0x00000001 == 0 accept comment "accept from non-client interface"
+ ip saddr { 0.0.0.0, <%= @client_ipv4 %> } return
+ ip6 saddr { fe80::/64, <%= @client_ipv6 %> } return
+ drop
+ }
+
+ # we need the "redirect" decision before DNAT in prerouting:-100
+ chain mark_clients {
+ type filter hook prerouting priority -110;
+ policy accept;
+ meta mark & 0x00000001 == 0 accept comment "accept from non-client interface"
+ jump antispoof_input
+ meta mark & 0x00000002 == 0 accept comment "accept packets from non-captive clients"
+ # now accept all traffic to destinations allowed in captive state, and mark "redirect" packets:
+ jump captive_allowed
+ }
+
+ chain input {
+ type filter hook input priority 0;
+ policy accept;
+ # TODO: limit services available to clients? iptconf might already be enough
+ }
+
+ chain forward_down {
+ # only filter uplink -> client here:
+ iifname != <%= @uplink_interface %> accept
+ oifname != <%= @client_interface %> accept
+ # established connections
+ ct state established,related accept
+ # allow incoming ipv6 ping (ipv4 ping can't work due to NAT)
+ icmpv6 type echo-request accept
+ drop
+ }
+
+ chain antispoof_forward {
+ meta mark & 0x00000001 == 0 goto forward_down comment "handle forwardings not from client interface"
+ ip saddr { <%= @client_ipv4 %> } return
+ ip6 saddr { <%= @client_ipv6 %> } return
+ drop
+ }
+
+ chain captive_allowed_icmp {
+ # allow all pings to servers we allow other kind of traffic
+ icmp type echo-request accept
+ icmpv6 type echo-request accept
+ }
+
+ chain captive_allowed_http {
+ # http + https (but not QUIC)
+ tcp dport { 80, 443 } accept
+ goto captive_allowed_icmp
+ }
+
+ chain captive_allowed_dns {
+ # DNS, DoT, DoH
+ udp dport { 53, 853 } accept
+ tcp dport { 53, 443, 853 } accept
+ goto captive_allowed_icmp
+ }
+
+ chain captive_allowed_ntp {
+ # only NTP
+ udp dport 123 accept
+ goto captive_allowed_icmp
+ }
+
+ chain captive_allowed {
+ # all protocols for fully whitelisted
+ ip daddr $full_ipv4 accept
+ ip6 daddr $full_ipv6 accept
+
+ ip daddr $http_server_ipv4 jump captive_allowed_http
+ ip6 daddr $http_server_ipv6 jump captive_allowed_http
+
+ ip daddr $dns_server_ipv4 jump captive_allowed_dns
+ ip6 daddr $dns_server_ipv6 jump captive_allowed_dns
+
+ ip daddr $ntp_server_ipv4 jump captive_allowed_ntp
+ ip6 daddr $ntp_server_ipv6 jump captive_allowed_ntp
+
+ # mark (new) http clients to redirect to local http server with bit 2
+ tcp dport 80 ct state new meta mark set meta mark | 0x00000004 accept comment "DNAT HTTP"
+ # mark packets to reject in forward with bit 3
+ meta mark set meta mark | 0x00000008 comment "reject in forwarding"
+ }
+
+ # for DNS+NTP
+ ct timeout udp-oneshot {
+ protocol udp;
+ policy = { unreplied: 10, replied: 0 }
+ }
+
+ chain forward_reject {
+ # could reject TCP connections with tcp reset, but ICMP unreachable should be good enough
+ # (and it's also semantically correct):
+ # ip protocol tcp reject with tcp reset
+ # ip6 nexthdr tcp reject with tcp reset
+
+ # but we need to close existing tcp sessions: (when client moves to captive state)
+ ct state != new ip protocol tcp reject with tcp reset
+ ct state != new ip6 nexthdr tcp reject with tcp reset
+
+ # default icmp reject
+ reject with icmpx type admin-prohibited
+ }
+
+ # block some ports always
+ chain blacklist {
+ tcp dport $backlist_tcp goto forward_reject
+ udp dport $backlist_udp goto forward_reject
+ udp sport $backlist_udp_source goto forward_reject
+ }
+
+ # ports we assume are "proper" - still only allowed in non-captive state
+ chain whitelist {
+ ip daddr $whitelist_dest_ipv4 accept
+ ip6 daddr $whitelist_dest_ipv6 accept
+ tcp dport $whitelist_tcp accept
+ udp dport $whitelist_udp accept
+ ip protocol esp accept
+ ip6 nexthdr esp accept
+ icmp type echo-request accept
+ icmpv6 type echo-request accept
+ # icmp related to existing connections, ...
+ ct state established,related accept
+ }
+
+ chain forward {
+ type filter hook forward priority 0;
+ policy drop;
+ jump antispoof_forward
+ # optimize conntrack timeouts for DNS and NTP
+ udp dport { 53, 123 } ct timeout set "udp-oneshot"
+ jump blacklist
+ # reject packets marked for rejection in mark_clients/captive_allowed (bit 3)
+ meta mark & 0x00000008 != 0 goto forward_reject
+ jump whitelist
+ # optional (policy):
+ goto forward_reject comment "drop not-whitelisted traffic completely"
+ # (conntrack) mark connection with bit 4 for shaping
+ ct mark set ct mark | 0x00000010 counter accept comment "accept shaped traffic"
+ }
+
+ chain forward_con_shaped_mark {
+ type filter hook forward priority 10;
+ policy accept;
+ # copy conntrack mark bit 4 to meta mark bit 4
+ ct mark & 0x00000010 == 0 accept comment "non shaped connection"
+ meta mark set meta mark | 0x00000010 counter accept comment "shaped connection"
+ }
+}
+
+
+# NOTE: mustn't flush this table to keep the set around
+# DON'T ENABLE THIS:
+# flush table inet captive_mark
+
+# as table wasn't flushed, at least delete the single chain we're expecting
+table inet captive_mark {
+ chain prerouting { }
+}
+delete chain inet captive_mark prerouting;
+
+table inet captive_mark {
+ # set isn't recreated, i.e. keeps dynamically added members
+ set allowed {
+ type ether_addr
+ flags timeout
+ }
+
+ chain prerouting {
+ type filter hook prerouting priority -150;
+ policy accept;
+ iifname != <%= @client_interface %> accept
+ # mark packets from client interface with bit 0
+ meta mark set meta mark | 0x00000001
+ ether saddr @allowed accept
+ # mark "captive" clients with bit 1
+ meta mark set meta mark | 0x00000002
+ accept
+ }
+
+ # further existing elements in this table won't be cleared by loading this file!
+}
diff --git a/contrib/radvd.conf.erb b/contrib/radvd.conf.erb
new file mode 100644
index 0000000..7ac4eb9
--- /dev/null
+++ b/contrib/radvd.conf.erb
@@ -0,0 +1,22 @@
+interface <%= client_interface %>
+{
+ AdvSendAdvert on;
+ AdvDefaultPreference high;
+ AdvSourceLLAddress off;
+ AdvRASrcAddress {
+ fe80::1;
+ };
+
+ RDNSS <%= ' '.join(@dns_resolvers_ipv6) %> {
+ FlushRDNSS off;
+ };
+
+ AdvOtherConfigFlag on;
+
+ # will require radvd > 2.19 (not released yet)
+ # AdvCaptivePortalAPI "https://<%= @service_name %>/api/captive-portal";
+
+ prefix <%= client_ipv6 %>
+ {
+ };
+};
diff --git a/contrib/shape_non_whitelisted.sh b/contrib/shape_non_whitelisted.sh
new file mode 100644
index 0000000..f8adda7
--- /dev/null
+++ b/contrib/shape_non_whitelisted.sh
@@ -0,0 +1,31 @@
+#!/bin/bash
+
+limit_iface() {
+ local dev=$1
+ local shape_rate=$2 # "guaranteed"
+ local shape_ceil=$3 # "upper limit"
+
+ tc qdisc delete dev "${dev}" root 2>/dev/null
+ tc qdisc add dev "${dev}" root handle 1: htb default 0x11
+ # basically no limit for default traffic
+ tc class add dev "${dev}" parent 1: classid 1:11 htb rate 10Gbit ceil 100Gbit quantum 100000
+ # limit "bad" (not whitelisted) traffic
+ tc class add dev "${dev}" parent 1: classid 1:12 htb prio 1 rate "${shape_rate}" ceil "${shape_ceil}"
+ # use "codel" qdisc for both classes, but with larger queue for default traffic
+ tc qdisc add dev "${dev}" parent 1:11 handle 11: codel limit 20000
+ tc qdisc add dev "${dev}" parent 1:12 handle 12: codel
+
+ # sort into bad class based on netfilter mark (if bit 0x10 is set)
+ tc filter add dev "${dev}" parent 1: prio 1 basic match 'meta(nf_mark mask 0x10 eq 0x10)' classid 1:12
+}
+
+uplink=$1
+downlink=$2
+
+if [ -z "${uplink}" -o -z "${downlink}" ]; then
+ echo >&2 "Missing uplink and downlink interface names"
+ exit 1
+fi
+
+limit_iface "${uplink}" "1Mbit" "1Mbit"
+limit_iface "${downlink}" "1Mbit" "1Mbit"
diff --git a/contrib/systemd/capport-enforcement.service b/contrib/systemd/capport-enforcement.service
new file mode 100644
index 0000000..847a5b4
--- /dev/null
+++ b/contrib/systemd/capport-enforcement.service
@@ -0,0 +1,18 @@
+[Unit]
+Description=Captive Portal enforcement service
+Wants=basic.target
+After=basic.target network.target
+ConditionFileIsExecutable=/var/lib/python-capport/start-control.sh
+ConditionPathIsDirectory=/var/lib/python-capport/venv
+
+# TODO: start as unprivileged user but with CAP_NET_ADMIN ?
+[Service]
+Type=notify
+WatchdogSec=10
+ExecStart=/var/lib/python-capport/start-control.sh
+Restart=always
+ProtectSystem=full
+ProtectHome=true
+
+[Install]
+WantedBy=multi-user.target
diff --git a/contrib/systemd/capport-nftables.service b/contrib/systemd/capport-nftables.service
new file mode 100644
index 0000000..b3634ac
--- /dev/null
+++ b/contrib/systemd/capport-nftables.service
@@ -0,0 +1,18 @@
+[Unit]
+Description=NFT Firewall Shim for Captive Portal
+Wants=network-pre.target
+Before=network-pre.target shutdown.target
+Conflicts=shutdown.target
+DefaultDependencies=no
+
+[Service]
+Type=oneshot
+RemainAfterExit=yes
+StandardInput=null
+ProtectSystem=full
+ProtectHome=true
+ExecStart=/usr/sbin/nft -f /etc/nftables.conf
+ExecReload=/usr/sbin/nft -f /etc/nftables.conf
+
+[Install]
+WantedBy=sysinit.target
diff --git a/contrib/systemd/capport-shaping.service.erb b/contrib/systemd/capport-shaping.service.erb
new file mode 100644
index 0000000..b0bbb6d
--- /dev/null
+++ b/contrib/systemd/capport-shaping.service.erb
@@ -0,0 +1,15 @@
+[Unit]
+Description=Captive Portal traffic shaping
+Wants=basic.target
+After=basic.target network.target
+
+[Service]
+Type=oneshot
+RemainAfterExit=yes
+StandardInput=null
+ProtectSystem=full
+ProtectHome=true
+ExecStart=/etc/capport-tc.sh <%= @uplink_interface %> <%= @client_interface %>
+
+[Install]
+WantedBy=multi-user.target
diff --git a/contrib/systemd/capport-webui.service b/contrib/systemd/capport-webui.service
new file mode 100644
index 0000000..dc8aa04
--- /dev/null
+++ b/contrib/systemd/capport-webui.service
@@ -0,0 +1,18 @@
+[Unit]
+Description=Captive Portal web ui service
+Wants=basic.target
+After=basic.target network.target
+ConditionFileIsExecutable=/var/lib/python-capport/start-control.sh
+ConditionPathIsDirectory=/var/lib/python-capport/venv
+
+[Service]
+User=capport
+Type=notify
+WatchdogSec=10
+ExecStart=/var/lib/python-capport/start-api.sh
+Restart=always
+ProtectSystem=full
+ProtectHome=true
+
+[Install]
+WantedBy=multi-user.target