#!/bin/bash
# =============================================================================
# machine-setup — network configuration
# =============================================================================
#
# Definitions only, like the other lib/ files.

[[ -n "${MACHINE_SETUP_NETWORK_LOADED:-}" ]] && return 0
MACHINE_SETUP_NETWORK_LOADED=1

# -----------------------------------------------------------------------------
# DNS
# -----------------------------------------------------------------------------
#
# ── What is actually being changed here ──
#
# On a machine running systemd-resolved there are two layers, and only one of
# them is ours to set:
#
#   per-link   what DHCP handed each interface, and what Tailscale installs on
#              its own. These answer for that link's domains — the provider's
#              internal names, and the tailnet — and are NOT touched here.
#              Overriding them is how private networking quietly stops resolving.
#
#   global     the resolver used when no link claims the query. This is what the
#              step sets.
#
# So this changes where public lookups go, and leaves the machine's own networks
# resolving exactly as they did.
#
# ── Drop-in, and note the sort order ──
#
# systemd reads drop-ins in lexical order and the LAST value wins, so 99- is what
# overrides. That is the opposite of sshd, three files away in this same
# directory, where the FIRST value wins and the drop-in has to sort early. Worth
# stating because getting it backwards fails silently in both directions.
#
# The original rewrote /etc/systemd/resolved.conf wholesale, which discards
# anything else in it — DNSSEC, DNSOverTLS, Domains, Cache — without mentioning
# that it had.

RESOLVED_DROPIN=/etc/systemd/resolved.conf.d/99-machine-setup.conf

resolved_is_active() { systemctl is-active --quiet systemd-resolved 2>/dev/null; }

# The global resolvers in force, space separated, or empty if none are set.
dns_current_global() {
  if resolved_is_active; then
    resolvectl status 2>/dev/null | awk '/^ *DNS Servers:/ { $1 = ""; $2 = ""; print; exit }' | xargs
  else
    awk '/^nameserver/ { printf "%s ", $2 }' /etc/resolv.conf 2>/dev/null | xargs
  fi
}

# What each interface was handed. Printed, never changed — the point is to show
# that this step is not touching them.
dns_per_link() {
  resolved_is_active || return 0
  resolvectl status 2>/dev/null |
    awk '/^Link [0-9]+ \(/ { link = $3; gsub(/[()]/, "", link) }
         /^ *DNS Servers:/ && link { $1 = ""; $2 = ""; printf "%s:%s\n", link, $0; link = "" }'
}

dns_set_global() {
  local primary="$1" fallback="$2"

  if resolved_is_active; then
    install -d -m 0755 "$(dirname "$RESOLVED_DROPIN")"
    cat >"$RESOLVED_DROPIN" <<EOF
# Written by machine-setup. 99- so it sorts last: systemd drop-ins are
# last-value-wins. Only the GLOBAL resolvers are set here — per-link DNS from
# DHCP and from Tailscale is left alone, so internal names keep resolving.
[Resolve]
DNS=${primary}
FallbackDNS=${fallback}
EOF
    chmod 644 "$RESOLVED_DROPIN"

    # resolv.conf has to point at the stub for any of this to be consulted. A
    # machine where something replaced the symlink with a static file bypasses
    # resolved entirely, and the drop-in would have no effect at all.
    local target
    target="$(readlink -f /etc/resolv.conf 2>/dev/null || true)"
    if [[ "$target" != /run/systemd/resolve/*resolv.conf ]]; then
      cp -a /etc/resolv.conf "/etc/resolv.conf.before-machine-setup" 2>/dev/null || true
      ln -sf /run/systemd/resolve/stub-resolv.conf /etc/resolv.conf
    fi

    systemctl restart systemd-resolved
  else
    # No resolved: write resolv.conf directly, and say plainly that anything
    # managing the interface may put its own back.
    cp -a /etc/resolv.conf "/etc/resolv.conf.before-machine-setup" 2>/dev/null || true
    if lsattr /etc/resolv.conf 2>/dev/null | cut -c1-20 | grep -q i; then
      chattr -i /etc/resolv.conf
    fi
    {
      echo "# Written by machine-setup."
      local ns
      for ns in $primary $fallback; do echo "nameserver ${ns}"; done
    } >/etc/resolv.conf
  fi
}

# Does name resolution actually work now? Asked after the change rather than
# assumed, because a resolver that does not answer is the one failure that makes
# everything after it look broken for unrelated reasons.
dns_works() { getent hosts one.one.one.one >/dev/null 2>&1 || getent hosts example.com >/dev/null 2>&1; }

# -----------------------------------------------------------------------------
# The address this machine gets
# -----------------------------------------------------------------------------
#
# ── Why a fresh Ubuntu box takes a new IP on every reboot ──
#
# Not a router fault, and not something a static IP is the right answer to.
# systemd-networkd's ClientIdentifier defaults to `duid` — an RFC 4361 client ID
# built from an IAID and a DUID — so the machine introduces itself to DHCP by
# that, and `networkctl status` shows it as "DHCP4 Client ID: IAID:0x…/DUID".
#
# Consumer routers key their leases and their reservations on the MAC address.
# The two never match, so the router does not recognise the machine as a client
# it has seen before and hands out the next free address instead. A reservation
# pinned to the MAC never takes effect, which is the part that makes it look like
# the router is broken.
#
# `dhcp-identifier: mac` in netplan sets ClientIdentifier=mac, and the router then
# sees what it expects. DHCP keeps working, the reservation starts being honoured,
# and nothing is pinned on the machine itself — which is why this is offered ahead
# of a static address rather than beside it.

NETPLAN_DHCP_ID=/etc/netplan/99-machine-setup-dhcp-identifier.yaml
NETPLAN_STATIC=/etc/netplan/99-machine-setup-static.yaml

# What the machine is sending as its DHCP identity: "mac", "duid", or empty when
# the link is not on DHCP at all.
dhcp_client_identifier() {
  local iface="$1"
  local id
  # Everything after the FIRST colon, not field 2 of a colon split: the value is
  # itself "IAID:0x…/DUID", so splitting on colons yields "IAID" and the DUID
  # test silently answers backwards.
  id="$(networkctl status "$iface" 2>/dev/null | awk '/DHCP4 Client ID/ { sub(/^[^:]*:[[:space:]]*/, ""); print; exit }')"
  [[ -z "$id" ]] && return 0
  if [[ "$id" == *DUID* ]]; then echo duid; else echo mac; fi
}

# Already asked for by some netplan file?
dhcp_identifier_is_mac() { grep -rqs "dhcp-identifier:[[:space:]]*mac" /etc/netplan/ 2>/dev/null; }

iface_ipv4() { ip -4 addr show "$1" 2>/dev/null | grep -oP '(?<=inet\s)\d+(\.\d+){3}/\d+' | head -1; }
iface_gateway() { ip route | awk '/^default/ { print $3; exit }'; }
iface_is_dhcp() { networkctl status "$1" 2>/dev/null | grep -q "DHCP4"; }

# Ask for MAC-based identity, as its own netplan file.
#
# Netplan reads /etc/netplan in lexical order and merges, so a 99- file adds this
# one key to whatever the installer or cloud-init already wrote, without this
# script having to parse and rewrite their YAML.
set_dhcp_identifier_mac() {
  local iface="$1"
  cat >"$NETPLAN_DHCP_ID" <<EOF
# Written by machine-setup.
#
# Identify to DHCP by MAC rather than by DUID, so the router recognises this
# machine across reboots and any reservation pinned to its MAC is honoured.
# Merged with whatever else is in /etc/netplan; 99- so it is read last.
network:
  version: 2
  ethernets:
    ${iface}:
      dhcp-identifier: mac
EOF
  chmod 600 "$NETPLAN_DHCP_ID"
  # Returns 0 whatever happens. This is an optional improvement, and a
  # function that ends on a failing command is fatal under `set -e` when it
  # is called as a plain command — which would abort the remaining sections
  # over something the run could simply report. The caller checks the outcome.
  return 0
}

# Freeze the current lease into a static address.
write_static_netplan() {
  local iface="$1" cidr="$2" gateway="$3"
  cat >"$NETPLAN_STATIC" <<EOF
# Written by machine-setup. Delete this file and run 'netplan apply' to go back
# to DHCP.
network:
  version: 2
  ethernets:
    ${iface}:
      dhcp4: false
      addresses:
        - ${cidr}
      routes:
        - to: default
          via: ${gateway}
EOF
  chmod 600 "$NETPLAN_STATIC"
  # Returns 0 whatever happens. This is an optional improvement, and a
  # function that ends on a failing command is fatal under `set -e` when it
  # is called as a plain command — which would abort the remaining sections
  # over something the run could simply report. The caller checks the outcome.
  return 0
}

netplan_check() { netplan generate 2>&1; }

# -----------------------------------------------------------------------------
# Firewall
# -----------------------------------------------------------------------------
#
# Last in the run, for the reason the original gave: enabling a firewall is the
# one step that can cut the connection it is being run over. Everything else
# should be done and working first.
#
# ── The bug in the shipped Docker rules ──
#
# ufw-docker-rules.conf hardcodes eth0. Docker publishes ports by writing its own
# iptables rules, which bypass ufw entirely — DOCKER-USER is the hook that lets
# ufw have a say. But every rule in that file names eth0, so on a machine with
# predictable interface names (ens18, enp1s0, and most VPS images) they match
# nothing, the final DROP never fires, and every published container port is open
# to the internet while `ufw status` says active. A firewall that reports itself
# working and is not is worse than none.

UFW_AFTER_RULES=/etc/ufw/after.rules

ufw_is_active() { ufw status 2>/dev/null | grep -q "^Status: active"; }
ufw_allows_ssh() { ufw status 2>/dev/null | grep -qiE "^(22/tcp|OpenSSH)"; }
ufw_has_rule() { ufw status 2>/dev/null | grep -qF "$1"; }
ufw_docker_rules_applied() { grep -q "DOCKER-USER" "$UFW_AFTER_RULES" 2>/dev/null; }

# The shipped rules, with eth0 replaced by the interface this machine actually
# uses. Appended once — the DOCKER-USER marker is the guard.
apply_ufw_docker_rules() {
  local src="$1" iface
  iface="$(default_iface)"
  [[ -n "$iface" ]] || return 1
  [[ -r "$src" ]] || return 1

  {
    echo ""
    echo "# Appended by machine-setup. Interface substituted for the one this"
    echo "# machine actually uses; the shipped file hardcodes eth0."
    sed "s/-i eth0/-i ${iface}/g" "$src"
  } >>"$UFW_AFTER_RULES"
}
