From c8460aa65c0874fef0c16c26536e44fac09dc2b7 Mon Sep 17 00:00:00 2001 From: Mikko Reinikainen Date: Tue, 24 Mar 2026 20:54:31 +0200 Subject: [PATCH 1/9] Add recipe for allowing and denying internet for LAN through server --- packages/ytl-linux-tasks/justfile | 6 ++ .../scripts/allow-internet-for-lan.sh | 60 +++++++++++++++++++ .../scripts/deny-internet-for-lan.sh | 35 +++++++++++ 3 files changed, 101 insertions(+) create mode 100755 packages/ytl-linux-tasks/scripts/allow-internet-for-lan.sh create mode 100755 packages/ytl-linux-tasks/scripts/deny-internet-for-lan.sh diff --git a/packages/ytl-linux-tasks/justfile b/packages/ytl-linux-tasks/justfile index 21421c9e..db091b00 100644 --- a/packages/ytl-linux-tasks/justfile +++ b/packages/ytl-linux-tasks/justfile @@ -9,3 +9,9 @@ maintenance: migrate-package-keys # Installs public keys of package sources to supported location migrate-package-keys: './scripts/2026-02-18_migrate-package-keys.py' + +allow-internet-for-lan net_device_wan net_device_lan: + ./scripts/allow-internet-for-lan.sh {{ net_device_wan }} {{ net_device_lan }} + +deny-internet-for-lan net_device_wan net_device_lan: + ./scripts/deny-internet-for-lan.sh {{ net_device_wan }} {{ net_device_lan }} \ No newline at end of file diff --git a/packages/ytl-linux-tasks/scripts/allow-internet-for-lan.sh b/packages/ytl-linux-tasks/scripts/allow-internet-for-lan.sh new file mode 100755 index 00000000..91f602b8 --- /dev/null +++ b/packages/ytl-linux-tasks/scripts/allow-internet-for-lan.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +set -euo pipefail + +wan="$1" +lan="$2" + +get_ipv4_cidr() { + local dev="$1" + ip -o -4 addr show dev "$dev" scope global | awk '{print $4; exit}' +} + +get_ipv4_addr() { + local dev="$1" + ip -o -4 addr show dev "$dev" scope global | awk '{print $4; exit}' | cut -d/ -f1 +} + +if ! ip link show "$wan" >/dev/null 2>&1; then + echo "ERROR WAN device '$wan' does not exist" >&2 + exit 1 +fi + +if ! ip link show "$lan" >/dev/null 2>&1; then + echo "ERROR LAN device '$lan' does not exist" >&2 + exit 1 +fi + +wan_cidr="$(get_ipv4_cidr "$wan")" +lan_cidr="$(get_ipv4_cidr "$lan")" +lan_ip="$(get_ipv4_addr "$lan")" + +if [[ -z "$wan_cidr" ]]; then + echo "ERROR WAN device '$wan' has no global IPv4 address" >&2 + exit 1 +fi + +if [[ -z "$lan_cidr" ]]; then + echo "ERROR LAN device '$lan' has no global IPv4 address" >&2 + exit 1 +fi + +echo "INFO WAN device: $wan ($wan_cidr)" +echo "INFO LAN device: $lan ($lan_cidr)" +echo "INFO Enabling IPv4 forwarding" + +echo 'net.ipv4.ip_forward=1' | sudo tee /etc/sysctl.d/99-router.conf >/dev/null +sudo sysctl -w net.ipv4.ip_forward=1 >/dev/null + +echo "INFO Allowing internet access for clients on $lan via $wan" + +sudo iptables -t nat -C POSTROUTING -o "$wan" -j MASQUERADE 2>/dev/null || \ + sudo iptables -t nat -A POSTROUTING -o "$wan" -j MASQUERADE + +sudo iptables -C FORWARD -i "$lan" -o "$wan" -j ACCEPT 2>/dev/null || \ + sudo iptables -A FORWARD -i "$lan" -o "$wan" -j ACCEPT + +sudo iptables -C FORWARD -i "$wan" -o "$lan" -m state --state RELATED,ESTABLISHED -j ACCEPT 2>/dev/null || \ + sudo iptables -A FORWARD -i "$wan" -o "$lan" -m state --state RELATED,ESTABLISHED -j ACCEPT + +echo "INFO Done" +echo "INFO Clients on $lan should use $lan_ip as their default gateway" \ No newline at end of file diff --git a/packages/ytl-linux-tasks/scripts/deny-internet-for-lan.sh b/packages/ytl-linux-tasks/scripts/deny-internet-for-lan.sh new file mode 100755 index 00000000..2b6b7aea --- /dev/null +++ b/packages/ytl-linux-tasks/scripts/deny-internet-for-lan.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -euo pipefail + +wan="$1" +lan="$2" + +if ! ip link show "$wan" >/dev/null 2>&1; then + echo "ERROR WAN device '$wan' does not exist" >&2 + exit 1 +fi + +if ! ip link show "$lan" >/dev/null 2>&1; then + echo "ERROR LAN device '$lan' does not exist" >&2 + exit 1 +fi + +echo "INFO Removing internet access for clients on $lan via $wan" + +while sudo iptables -t nat -C POSTROUTING -o "$wan" -j MASQUERADE 2>/dev/null; do + sudo iptables -t nat -D POSTROUTING -o "$wan" -j MASQUERADE +done + +while sudo iptables -C FORWARD -i "$lan" -o "$wan" -j ACCEPT 2>/dev/null; do + sudo iptables -D FORWARD -i "$lan" -o "$wan" -j ACCEPT +done + +while sudo iptables -C FORWARD -i "$wan" -o "$lan" -m state --state RELATED,ESTABLISHED -j ACCEPT 2>/dev/null; do + sudo iptables -D FORWARD -i "$wan" -o "$lan" -m state --state RELATED,ESTABLISHED -j ACCEPT +done + +echo "INFO Disabling IPv4 forwarding" +echo 'net.ipv4.ip_forward=0' | sudo tee /etc/sysctl.d/99-router.conf >/dev/null +sudo sysctl -w net.ipv4.ip_forward=0 >/dev/null + +echo "INFO Done" \ No newline at end of file From d908d0e65bf3e6761a210b890e435ee02a461325 Mon Sep 17 00:00:00 2001 From: Mikko Reinikainen Date: Wed, 25 Mar 2026 12:59:37 +0200 Subject: [PATCH 2/9] Disable/enable null route that prevents DNS queries to internet --- .../scripts/allow-internet-for-lan.sh | 16 ++++++++++------ .../scripts/deny-internet-for-lan.sh | 14 +++++++++----- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/packages/ytl-linux-tasks/scripts/allow-internet-for-lan.sh b/packages/ytl-linux-tasks/scripts/allow-internet-for-lan.sh index 91f602b8..4bab592a 100755 --- a/packages/ytl-linux-tasks/scripts/allow-internet-for-lan.sh +++ b/packages/ytl-linux-tasks/scripts/allow-internet-for-lan.sh @@ -1,6 +1,8 @@ #!/usr/bin/env bash set -euo pipefail +DNSMASQ_CONFIG_FILE="/etc/dnsmasq.d/ytl-linux.conf" + wan="$1" lan="$2" @@ -40,10 +42,6 @@ fi echo "INFO WAN device: $wan ($wan_cidr)" echo "INFO LAN device: $lan ($lan_cidr)" -echo "INFO Enabling IPv4 forwarding" - -echo 'net.ipv4.ip_forward=1' | sudo tee /etc/sysctl.d/99-router.conf >/dev/null -sudo sysctl -w net.ipv4.ip_forward=1 >/dev/null echo "INFO Allowing internet access for clients on $lan via $wan" @@ -53,8 +51,14 @@ sudo iptables -t nat -C POSTROUTING -o "$wan" -j MASQUERADE 2>/dev/null || \ sudo iptables -C FORWARD -i "$lan" -o "$wan" -j ACCEPT 2>/dev/null || \ sudo iptables -A FORWARD -i "$lan" -o "$wan" -j ACCEPT -sudo iptables -C FORWARD -i "$wan" -o "$lan" -m state --state RELATED,ESTABLISHED -j ACCEPT 2>/dev/null || \ - sudo iptables -A FORWARD -i "$wan" -o "$lan" -m state --state RELATED,ESTABLISHED -j ACCEPT +sudo iptables -C FORWARD -i "$wan" -o "$lan" -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT 2>/dev/null || \ + sudo iptables -A FORWARD -i "$wan" -o "$lan" -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT + +echo "INFO Disabling null route in dnsmasq configuration file ${DNSMASQ_CONFIG_FILE}" +sudo sed -i 's/^address=\/#\/0\.0\.0\.0$/# address=\/#\/0.0.0.0/' "${DNSMASQ_CONFIG_FILE}" + +echo "INFO Restarting dnsmasq" +sudo systemctl restart dnsmasq.service echo "INFO Done" echo "INFO Clients on $lan should use $lan_ip as their default gateway" \ No newline at end of file diff --git a/packages/ytl-linux-tasks/scripts/deny-internet-for-lan.sh b/packages/ytl-linux-tasks/scripts/deny-internet-for-lan.sh index 2b6b7aea..b32aad12 100755 --- a/packages/ytl-linux-tasks/scripts/deny-internet-for-lan.sh +++ b/packages/ytl-linux-tasks/scripts/deny-internet-for-lan.sh @@ -1,6 +1,8 @@ #!/usr/bin/env bash set -euo pipefail +DNSMASQ_CONFIG_FILE="/etc/dnsmasq.d/ytl-linux.conf" + wan="$1" lan="$2" @@ -24,12 +26,14 @@ while sudo iptables -C FORWARD -i "$lan" -o "$wan" -j ACCEPT 2>/dev/null; do sudo iptables -D FORWARD -i "$lan" -o "$wan" -j ACCEPT done -while sudo iptables -C FORWARD -i "$wan" -o "$lan" -m state --state RELATED,ESTABLISHED -j ACCEPT 2>/dev/null; do - sudo iptables -D FORWARD -i "$wan" -o "$lan" -m state --state RELATED,ESTABLISHED -j ACCEPT +while sudo iptables -C FORWARD -i "$wan" -o "$lan" -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT 2>/dev/null; do + sudo iptables -D FORWARD -i "$wan" -o "$lan" -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT done -echo "INFO Disabling IPv4 forwarding" -echo 'net.ipv4.ip_forward=0' | sudo tee /etc/sysctl.d/99-router.conf >/dev/null -sudo sysctl -w net.ipv4.ip_forward=0 >/dev/null +echo "INFO Enabling null route in dnsmasq configuration file ${DNSMASQ_CONFIG_FILE}" +sudo sed -i 's/^# address=\/#\/0\.0\.0\.0$/address=\/#\/0.0.0.0/' "${DNSMASQ_CONFIG_FILE}" + +echo "INFO Restarting dnsmasq" +sudo systemctl restart dnsmasq.service echo "INFO Done" \ No newline at end of file From e84161b82cc60dd2692d4ac62ca27d6b36dc09c7 Mon Sep 17 00:00:00 2001 From: Mikko Reinikainen Date: Sun, 5 Apr 2026 01:10:10 +0300 Subject: [PATCH 3/9] Add LAN to WAN forwarding for list of allowed domains with dnsmasq+ipset+iptables --- packages/ytl-linux-digabi2-examnet/Makefile | 3 + .../templates/dnsmasq.conf.template | 14 +- .../ytl-linux-digabi2-examnet | 254 +++++++++++++++--- 3 files changed, 236 insertions(+), 35 deletions(-) diff --git a/packages/ytl-linux-digabi2-examnet/Makefile b/packages/ytl-linux-digabi2-examnet/Makefile index c83a58e9..975144c3 100644 --- a/packages/ytl-linux-digabi2-examnet/Makefile +++ b/packages/ytl-linux-digabi2-examnet/Makefile @@ -4,6 +4,9 @@ VERSION := 0.2.1 DEPENDENCIES := \ --depends apt \ --depends dnsmasq \ + --depends dnsutils \ + --depends ipset \ + --depends iptables \ --depends jq \ --depends network-manager \ --depends zenity diff --git a/packages/ytl-linux-digabi2-examnet/templates/dnsmasq.conf.template b/packages/ytl-linux-digabi2-examnet/templates/dnsmasq.conf.template index 1fd55e09..06d0f0cb 100644 --- a/packages/ytl-linux-digabi2-examnet/templates/dnsmasq.conf.template +++ b/packages/ytl-linux-digabi2-examnet/templates/dnsmasq.conf.template @@ -33,8 +33,16 @@ resolv-file=/etc/resolv.conf # need DNS to tell student computers where to go server=/koe.abitti.net/# -# Null-route all other traffic -# This prevents software on the student computer from getting confused by when DNS queries work, but the TCP -# request stalls (since this is not a router) for however long the client timeout is set to; possibly Infinity +# Null-route all other traffic by default. +# +# Note: ytl-linux-digabi2-examnet configures a permanent limited Internet allowlist +# using dnsmasq+ipset+iptables: +# - This line stays enabled to deny DNS answers by default (clients get 0.0.0.0) +# - Allowlisted domains are forwarded to upstream DNS using: +# /etc/dnsmasq.d/ytl-linux-internet-allowlist-server.conf (server=/domain/#) +# - Returned A records for allowlisted domains are added to an ipset using: +# /etc/dnsmasq.d/ytl-linux-internet-allowlist-ipset.conf (ipset=/domain/set) +# - iptables then only forwards/NATs LAN->WAN traffic when the destination IP +# is in that ipset. address=/#/0.0.0.0 address=/#/:: diff --git a/packages/ytl-linux-digabi2-examnet/ytl-linux-digabi2-examnet b/packages/ytl-linux-digabi2-examnet/ytl-linux-digabi2-examnet index bee2bca7..598d21ad 100755 --- a/packages/ytl-linux-digabi2-examnet/ytl-linux-digabi2-examnet +++ b/packages/ytl-linux-digabi2-examnet/ytl-linux-digabi2-examnet @@ -1,5 +1,13 @@ #!/usr/bin/env bash +# These domains are allowed from LAN to WAN for Windows Defender cloud verification. +# Keep this list minimal. +# TODO replace with list of actual domains used by Defender +readonly INTERNET_ALLOWLIST_DOMAINS=( + "defender.microsoft.com" + "api.microsoft.com" +) + # Exit codes readonly EXIT_CODE_MUST_BE_ROOT=1 # The script must be executed by root or sudo readonly EXIT_CODE_NETWORK_DEVICE_NAME_MISSING_WAN=2 # Network device name (internet) is missing @@ -50,6 +58,11 @@ readonly PATH_DNSMASQ_CONF=$PATH_DNSMASQ/ytl-linux.conf readonly PATH_DNSMASQ_CONF_TEMPLATE=$PATH_TEMPLATES/dnsmasq.conf.template readonly PATH_DNSMASQ_STATIC_DNS_CONF=$PATH_DNSMASQ/ytl-linux-static-dns-records.conf readonly PATH_DNSMASQ_KTP_ALIASES_CONF=$PATH_DNSMASQ/ytl-linux-ktp-aliases.conf +readonly PATH_DNSMASQ_IPSET_ALLOWLIST_CONF=$PATH_DNSMASQ/ytl-linux-internet-allowlist-ipset.conf +readonly PATH_DNSMASQ_SERVER_ALLOWLIST_CONF=$PATH_DNSMASQ/ytl-linux-internet-allowlist-server.conf +readonly PATH_DOCKER=/etc/docker +readonly PATH_DOCKER_DAEMON_CONF=$PATH_DOCKER/daemon.json +readonly PATH_DOCKER_DAEMON_CONF_TEMPLATE=$PATH_TEMPLATES/docker-daemon.json.template readonly PATH_NAKSU2_WORKDIR="${NAKSU2_WORKDIR:-/home/school/.local/share/digabi/naksu2}" readonly PATH_NAKSU2_CERTS_DIR="$PATH_NAKSU2_WORKDIR/certs" readonly PATH_NAKSU2_CERT="$PATH_NAKSU2_CERTS_DIR/cert.pem" @@ -73,6 +86,9 @@ readonly BIN_NM_ONLINE=/usr/bin/nm-online readonly BIN_SED=/usr/bin/sed readonly BIN_XARGS=/usr/bin/xargs readonly BIN_OPENSSL=/usr/bin/openssl +readonly BIN_IPTABLES=/usr/sbin/iptables +readonly BIN_IPSET=/usr/sbin/ipset +readonly BIN_DIG=/usr/bin/dig readonly BIN_DIGABI2_EXAMNET_BOUNCER=/usr/local/libexec/ytl-linux-digabi2-examnet/bouncer readonly BIN_DIGABI2_EXAMNET_DISCOVERY=/usr/local/libexec/ytl-linux-digabi2-examnet/discovery readonly BIN_STAT=/usr/bin/stat @@ -82,6 +98,11 @@ readonly CONST_MIN_SERVER_NUMBER=1 readonly CONST_MAX_SERVER_NUMBER=24 readonly CONST_SUBNETS_PER_SERVER=10 +readonly INTERNET_ALLOWLIST_IPSET_NAME="ytl_internet_allowlist" +readonly INTERNET_ALLOWLIST_FILTER_CHAIN="YTL_LAN_WAN_IPSET" +readonly INTERNET_ALLOWLIST_LOG_CHAIN="YTL_LAN_WAN_IPSET_LOG" +readonly INTERNET_ALLOWLIST_IPSET_TIMEOUT_SECONDS=3600 + function get_dir_owner() { _DIR=$1 if [ -d "$_DIR" ]; then @@ -310,6 +331,106 @@ function remove_dnsmasq_settings() { rm -f $PATH_DNSMASQ_STATIC_DNS_CONF exit_if_error $? $EXIT_CODE_CANNOT_REMOVE_FILES "Failed to remove system file '$PATH_DNSMASQ_STATIC_DNS_CONF'" fi + + if [ -f $PATH_DNSMASQ_KTP_ALIASES_CONF ]; then + rm -f $PATH_DNSMASQ_KTP_ALIASES_CONF + exit_if_error $? $EXIT_CODE_CANNOT_REMOVE_FILES "Failed to remove system file '$PATH_DNSMASQ_KTP_ALIASES_CONF'" + fi + + if [ -f $PATH_DNSMASQ_IPSET_ALLOWLIST_CONF ]; then + rm -f $PATH_DNSMASQ_IPSET_ALLOWLIST_CONF + exit_if_error $? $EXIT_CODE_CANNOT_REMOVE_FILES "Failed to remove system file '$PATH_DNSMASQ_IPSET_ALLOWLIST_CONF'" + fi + + if [ -f $PATH_DNSMASQ_SERVER_ALLOWLIST_CONF ]; then + rm -f $PATH_DNSMASQ_SERVER_ALLOWLIST_CONF + exit_if_error $? $EXIT_CODE_CANNOT_REMOVE_FILES "Failed to remove system file '$PATH_DNSMASQ_SERVER_ALLOWLIST_CONF'" + fi +} + +function remove_internet_allowlist() { + # When running --remove, NET_DEVICE_WAN/LAN might not be set (user might not pass params). + # Prefer values from saved examnet config files if available. + if [ -z "$NET_DEVICE_WAN" ] && [ -f "$PATH_NET_DEVICE_WAN_CONF" ]; then + NET_DEVICE_WAN=$(cat "$PATH_NET_DEVICE_WAN_CONF") + fi + if [ -z "$NET_DEVICE_LAN" ] && [ -f "$PATH_NET_DEVICE_LAN_CONF" ]; then + NET_DEVICE_LAN=$(cat "$PATH_NET_DEVICE_LAN_CONF") + fi + + if [ -z "$NET_DEVICE_WAN" ] || [ -z "$NET_DEVICE_LAN" ]; then + # Can't remove interface-specific rules; still flush/destroy ipset. + if [ -x "$BIN_IPSET" ]; then + $BIN_IPSET flush "$INTERNET_ALLOWLIST_IPSET_NAME" 2>/dev/null || true + $BIN_IPSET destroy "$INTERNET_ALLOWLIST_IPSET_NAME" 2>/dev/null || true + fi + return 0 + fi + + # Only attempt if binaries exist. + if [ -x "$BIN_IPTABLES" ]; then + # Remove NAT rule + while $BIN_IPTABLES -t nat -C POSTROUTING -o "$NET_DEVICE_WAN" -m set --match-set "$INTERNET_ALLOWLIST_IPSET_NAME" dst -j MASQUERADE 2>/dev/null; do + $BIN_IPTABLES -t nat -D POSTROUTING -o "$NET_DEVICE_WAN" -m set --match-set "$INTERNET_ALLOWLIST_IPSET_NAME" dst -j MASQUERADE + done + + # Remove FORWARD jump rules + while $BIN_IPTABLES -t filter -C FORWARD -i "$NET_DEVICE_LAN" -o "$NET_DEVICE_WAN" -j "$INTERNET_ALLOWLIST_LOG_CHAIN" 2>/dev/null; do + $BIN_IPTABLES -t filter -D FORWARD -i "$NET_DEVICE_LAN" -o "$NET_DEVICE_WAN" -j "$INTERNET_ALLOWLIST_LOG_CHAIN" + done + + while $BIN_IPTABLES -t filter -C FORWARD -i "$NET_DEVICE_LAN" -o "$NET_DEVICE_WAN" -j "$INTERNET_ALLOWLIST_FILTER_CHAIN" 2>/dev/null; do + $BIN_IPTABLES -t filter -D FORWARD -i "$NET_DEVICE_LAN" -o "$NET_DEVICE_WAN" -j "$INTERNET_ALLOWLIST_FILTER_CHAIN" + done + + # Remove established return rule + while $BIN_IPTABLES -t filter -C FORWARD -i "$NET_DEVICE_WAN" -o "$NET_DEVICE_LAN" -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT 2>/dev/null; do + $BIN_IPTABLES -t filter -D FORWARD -i "$NET_DEVICE_WAN" -o "$NET_DEVICE_LAN" -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT + done + + # Delete chains + $BIN_IPTABLES -t filter -F "$INTERNET_ALLOWLIST_FILTER_CHAIN" 2>/dev/null || true + $BIN_IPTABLES -t filter -X "$INTERNET_ALLOWLIST_FILTER_CHAIN" 2>/dev/null || true + $BIN_IPTABLES -t filter -F "$INTERNET_ALLOWLIST_LOG_CHAIN" 2>/dev/null || true + $BIN_IPTABLES -t filter -X "$INTERNET_ALLOWLIST_LOG_CHAIN" 2>/dev/null || true + fi + + if [ -x "$BIN_IPSET" ]; then + $BIN_IPSET flush "$INTERNET_ALLOWLIST_IPSET_NAME" 2>/dev/null || true + $BIN_IPSET destroy "$INTERNET_ALLOWLIST_IPSET_NAME" 2>/dev/null || true + fi +} + +function enable_systemd_services() { + $BIN_SYSTEMCTL enable ytl-linux-digabi2-examnet.service >>"$PATH_DEBUG" 2>&1 + exit_if_error $? $EXIT_CODE_CANNOT_CHANGE_SERVICE_STATE "Failed to enable ytl-linux-digabi2-examnet.service" + + $BIN_SYSTEMCTL enable dnsmasq.service >>"$PATH_DEBUG" 2>&1 + exit_if_error $? $EXIT_CODE_CANNOT_CHANGE_SERVICE_STATE "Failed to enable dnsmasq.service" + + $BIN_SYSTEMCTL enable ytl-linux-digabi2-examnet-discovery.service >>"$PATH_DEBUG" 2>&1 + exit_if_error $? $EXIT_CODE_CANNOT_CHANGE_SERVICE_STATE "Failed to enable ytl-linux-digabi2-examnet-discovery.service" + + $BIN_SYSTEMCTL enable ytl-linux-digabi2-examnet-discovery.timer >>"$PATH_DEBUG" 2>&1 + exit_if_error $? $EXIT_CODE_CANNOT_CHANGE_SERVICE_STATE "Failed to enable ytl-linux-digabi2-examnet-discovery.timer" +} + +function disable_systemd_services() { + $BIN_SYSTEMCTL disable --now ytl-linux-digabi2-examnet.service >>"$PATH_DEBUG" 2>&1 + exit_if_error $? $EXIT_CODE_CANNOT_CHANGE_SERVICE_STATE "Failed to disable ytl-linux-digabi2-examnet.service" + + $BIN_SYSTEMCTL disable --now dnsmasq.service >>"$PATH_DEBUG" 2>&1 + exit_if_error $? $EXIT_CODE_CANNOT_CHANGE_SERVICE_STATE "Failed to disable dnsmasq.service" + + $BIN_SYSTEMCTL disable --now ytl-linux-digabi2-examnet-discovery.timer >>"$PATH_DEBUG" 2>&1 + exit_if_error $? $EXIT_CODE_CANNOT_CHANGE_SERVICE_STATE "Failed to disable ytl-linux-digabi2-examnet-discovery.timer" + + $BIN_SYSTEMCTL disable --now ytl-linux-digabi2-examnet-discovery.service >>"$PATH_DEBUG" 2>&1 + exit_if_error $? $EXIT_CODE_CANNOT_CHANGE_SERVICE_STATE "Failed to disable ytl-linux-digabi2-examnet-discovery.service" +} + +function remove_school_host_entries() { + $BIN_SED -i '/^# BEGIN SCHOOL DOMAIN ENTRIES$/,/^# END SCHOOL DOMAIN ENTRIES$/d' /etc/hosts } function remove_all_settings() { @@ -351,6 +472,7 @@ function remove_all_settings() { fi remove_dnsmasq_settings + remove_internet_allowlist remove_school_host_entries $BIN_NMCLI -f UUID,NAME connection show | $BIN_GREP -P -v "^UUID" | $BIN_TR -s ' ' | while read -r line ; do @@ -376,6 +498,105 @@ function restart_dnsmasq() { fi } +function configure_internet_allowlist() { + if [ -z "$NET_DEVICE_LAN" ] || [ -z "$NET_DEVICE_WAN" ] || [ -z "$SERVER_OWN_IP" ]; then + print_error "Internet allowlist: missing NET_DEVICE_LAN/NET_DEVICE_WAN/SERVER_OWN_IP" + exit_script $EXIT_CODE_CANNOT_START_DAEMON + fi + + if [ ! -x "$BIN_IPTABLES" ]; then + print_error "Internet allowlist: iptables not found ($BIN_IPTABLES)" + exit_script $EXIT_CODE_CANNOT_START_DAEMON + fi + + if [ ! -x "$BIN_IPSET" ]; then + print_error "Internet allowlist: ipset not found ($BIN_IPSET)" + exit_script $EXIT_CODE_CANNOT_START_DAEMON + fi + + if [ ! -x "$BIN_DIG" ]; then + print_error "Internet allowlist: dig not found ($BIN_DIG); install dnsutils" + exit_script $EXIT_CODE_CANNOT_START_DAEMON + fi + + # DNS-level deny: + # Keep address=/#/0.0.0.0 in the main dnsmasq config (deny by default). + # Allowlisted domains are the ONLY ones we forward to upstream resolvers. + # + # This prevents LAN clients from receiving real DNS answers for arbitrary + # Internet domains. + + # 1) Write a dnsmasq snippet that (a) adds resolved IPs for allowed domains into ipset + # and (b) forwards ONLY those domains to upstream resolver(s). + { + echo "# Managed by ytl-linux-digabi2-examnet" + echo "# Internet allowlist (DNS + ipset)" + echo "# - Allowlisted domains are forwarded to upstream resolvers" + echo "# - All other domains are null-routed by address=/#/0.0.0.0 in the main config" + echo "# - Returned IPs for allowlisted domains are added to ipset '$INTERNET_ALLOWLIST_IPSET_NAME'" + for d in "${INTERNET_ALLOWLIST_DOMAINS[@]}"; do + echo "ipset=/$d/$INTERNET_ALLOWLIST_IPSET_NAME" + done + } >"$PATH_DNSMASQ_IPSET_ALLOWLIST_CONF" + + # 2) Write a dnsmasq snippet that forwards allowlisted domains to upstream resolvers. + { + echo "# Managed by ytl-linux-digabi2-examnet" + echo "# Forward ONLY allowlisted domains to upstream DNS (as defined by resolv-file=/etc/resolv.conf)" + for d in "${INTERNET_ALLOWLIST_DOMAINS[@]}"; do + echo "server=/$d/#" + done + } >"$PATH_DNSMASQ_SERVER_ALLOWLIST_CONF" + + # 3) Ensure ipset exists (with timeout). + # Note: -exist prevents failure if it already exists. + $BIN_IPSET create "$INTERNET_ALLOWLIST_IPSET_NAME" hash:ip timeout "$INTERNET_ALLOWLIST_IPSET_TIMEOUT_SECONDS" -exist + + # 4) Install iptables rules. + # Strategy: + # - LOG chain logs NEW flows allowed by ipset + # - FILTER chain allows LAN->WAN only if dst in ipset; then drop + # - FORWARD has: + # (a) established return traffic WAN->LAN + # (b) LAN->WAN jump to LOG and FILTER chain + # - NAT POSTROUTING masquerade only when dst in ipset + + $BIN_IPTABLES -t filter -N "$INTERNET_ALLOWLIST_LOG_CHAIN" 2>/dev/null || true + $BIN_IPTABLES -t filter -F "$INTERNET_ALLOWLIST_LOG_CHAIN" 2>/dev/null || true + $BIN_IPTABLES -t filter -A "$INTERNET_ALLOWLIST_LOG_CHAIN" \ + -m conntrack --ctstate NEW \ + -m set --match-set "$INTERNET_ALLOWLIST_IPSET_NAME" dst \ + -m limit --limit 10/second --limit-burst 30 \ + -j LOG --log-prefix "YTL ALLOW NEW " --log-level 6 + $BIN_IPTABLES -t filter -A "$INTERNET_ALLOWLIST_LOG_CHAIN" -j RETURN + + $BIN_IPTABLES -t filter -N "$INTERNET_ALLOWLIST_FILTER_CHAIN" 2>/dev/null || true + $BIN_IPTABLES -t filter -F "$INTERNET_ALLOWLIST_FILTER_CHAIN" 2>/dev/null || true + $BIN_IPTABLES -t filter -A "$INTERNET_ALLOWLIST_FILTER_CHAIN" -m set --match-set "$INTERNET_ALLOWLIST_IPSET_NAME" dst -j ACCEPT + $BIN_IPTABLES -t filter -A "$INTERNET_ALLOWLIST_FILTER_CHAIN" -j DROP + + # Established/related return traffic from WAN->LAN + $BIN_IPTABLES -t filter -C FORWARD -i "$NET_DEVICE_WAN" -o "$NET_DEVICE_LAN" -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT 2>/dev/null || \ + $BIN_IPTABLES -t filter -A FORWARD -i "$NET_DEVICE_WAN" -o "$NET_DEVICE_LAN" -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT + + # LAN->WAN logging (NEW allowed only) + enforcement + $BIN_IPTABLES -t filter -C FORWARD -i "$NET_DEVICE_LAN" -o "$NET_DEVICE_WAN" -j "$INTERNET_ALLOWLIST_LOG_CHAIN" 2>/dev/null || \ + $BIN_IPTABLES -t filter -I FORWARD 1 -i "$NET_DEVICE_LAN" -o "$NET_DEVICE_WAN" -j "$INTERNET_ALLOWLIST_LOG_CHAIN" + + $BIN_IPTABLES -t filter -C FORWARD -i "$NET_DEVICE_LAN" -o "$NET_DEVICE_WAN" -j "$INTERNET_ALLOWLIST_FILTER_CHAIN" 2>/dev/null || \ + $BIN_IPTABLES -t filter -I FORWARD 2 -i "$NET_DEVICE_LAN" -o "$NET_DEVICE_WAN" -j "$INTERNET_ALLOWLIST_FILTER_CHAIN" + + # NAT only for allowed destinations + $BIN_IPTABLES -t nat -C POSTROUTING -o "$NET_DEVICE_WAN" -m set --match-set "$INTERNET_ALLOWLIST_IPSET_NAME" dst -j MASQUERADE 2>/dev/null || \ + $BIN_IPTABLES -t nat -A POSTROUTING -o "$NET_DEVICE_WAN" -m set --match-set "$INTERNET_ALLOWLIST_IPSET_NAME" dst -j MASQUERADE + + # 5) Restart dnsmasq to pick up ipset=/server= rules, then proactively populate ipset via local dnsmasq. + restart_dnsmasq + for d in "${INTERNET_ALLOWLIST_DOMAINS[@]}"; do + $BIN_DIG +time=1 +tries=1 @127.0.0.1 "$d" A >/dev/null 2>&1 || true + done +} + function restart_networkmanager() { $BIN_SYSTEMCTL restart NetworkManager.service >>"$PATH_DEBUG" 2>&1 exit_if_error $? $EXIT_CODE_CANNOT_RESTART_NM "Failed to restart NetworkManager.service" @@ -417,34 +638,6 @@ function restart_network_services() { restart_examnet_daemon } -function enable_systemd_services() { - $BIN_SYSTEMCTL enable ytl-linux-digabi2-examnet.service >>"$PATH_DEBUG" 2>&1 - exit_if_error $? $EXIT_CODE_CANNOT_CHANGE_SERVICE_STATE "Failed to enable ytl-linux-digabi2-examnet.service" - - $BIN_SYSTEMCTL enable dnsmasq.service >>"$PATH_DEBUG" 2>&1 - exit_if_error $? $EXIT_CODE_CANNOT_CHANGE_SERVICE_STATE "Failed to enable dnsmasq.service" - - $BIN_SYSTEMCTL enable ytl-linux-digabi2-examnet-discovery.service >>"$PATH_DEBUG" 2>&1 - exit_if_error $? $EXIT_CODE_CANNOT_CHANGE_SERVICE_STATE "Failed to enable ytl-linux-digabi2-examnet-discovery.service" - - $BIN_SYSTEMCTL enable ytl-linux-digabi2-examnet-discovery.timer >>"$PATH_DEBUG" 2>&1 - exit_if_error $? $EXIT_CODE_CANNOT_CHANGE_SERVICE_STATE "Failed to enable ytl-linux-digabi2-examnet-discovery.timer" -} - -function disable_systemd_services() { - $BIN_SYSTEMCTL disable --now ytl-linux-digabi2-examnet.service >>"$PATH_DEBUG" 2>&1 - exit_if_error $? $EXIT_CODE_CANNOT_CHANGE_SERVICE_STATE "Failed to disable ytl-linux-digabi2-examnet.service" - - $BIN_SYSTEMCTL disable --now dnsmasq.service >>"$PATH_DEBUG" 2>&1 - exit_if_error $? $EXIT_CODE_CANNOT_CHANGE_SERVICE_STATE "Failed to disable dnsmasq.service" - - $BIN_SYSTEMCTL disable --now ytl-linux-digabi2-examnet-discovery.timer >>"$PATH_DEBUG" 2>&1 - exit_if_error $? $EXIT_CODE_CANNOT_CHANGE_SERVICE_STATE "Failed to disable ytl-linux-digabi2-examnet-discovery.timer" - - $BIN_SYSTEMCTL disable --now ytl-linux-digabi2-examnet-discovery.service >>"$PATH_DEBUG" 2>&1 - exit_if_error $? $EXIT_CODE_CANNOT_CHANGE_SERVICE_STATE "Failed to disable ytl-linux-digabi2-examnet-discovery.service" -} - function restart_docker() { $BIN_SYSTEMCTL restart docker exit_if_error $? $EXIT_CODE_CANNOT_RESTART_DOCKER "Failed to restart docker" @@ -517,10 +710,6 @@ function configure_static_dns_records() { write_file "$PATH_NAKSU2_DOMAIN" "$THIS_SERVER_DOMAIN" "$NAKSU2_WORKDIR_OWNER" } -function remove_school_host_entries() { - $BIN_SED -i '/^# BEGIN SCHOOL DOMAIN ENTRIES$/,/^# END SCHOOL DOMAIN ENTRIES$/d' /etc/hosts -} - # ---------- Main script logic starts here ---------- # if [ -z "$PARAM_NET_DEVICE_WAN" ]; then @@ -732,6 +921,7 @@ write_file $PATH_RESOLVED_CONF "$(envsubst < $PATH_RESOLVED_CONF_TEMPLATE)" remove_dnsmasq_settings export FRIENDLY_NAME_SEARCH_DOMAIN write_file $PATH_DNSMASQ_CONF "$(envsubst < $PATH_DNSMASQ_CONF_TEMPLATE)" +configure_internet_allowlist if [ $USE_STATIC_LOCAL_DNS -gt 0 ]; then configure_static_dns_records "$SERVER_NUMBER" From 7808006ce3209066a078ac8253212d6a6c686bc0 Mon Sep 17 00:00:00 2001 From: Mikko Reinikainen Date: Sun, 5 Apr 2026 01:45:40 +0300 Subject: [PATCH 4/9] Add flag --configure-only-static-local-dns - This flag runs only the part of the examnet script which uses the certificate to configure static DNS records. - This part can be run separately after the certificate has been downloaded. # Conflicts: # packages/ytl-linux-digabi2-examnet/ytl-linux-digabi2-examnet --- .../ytl-linux-digabi2-examnet | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/ytl-linux-digabi2-examnet/ytl-linux-digabi2-examnet b/packages/ytl-linux-digabi2-examnet/ytl-linux-digabi2-examnet index 598d21ad..abeec04f 100755 --- a/packages/ytl-linux-digabi2-examnet/ytl-linux-digabi2-examnet +++ b/packages/ytl-linux-digabi2-examnet/ytl-linux-digabi2-examnet @@ -141,7 +141,7 @@ function print_info() { } function print_usage() { - $BIN_ECHO "usage: $SCRIPT_PATH wan-network-device local-network-device server-number [custom-server-name] [--use-static-local-dns]" >&2 + $BIN_ECHO "usage: $SCRIPT_PATH wan-network-device local-network-device server-number [custom-server-name] [--use-static-local-dns] [--configure-only-static-local-dns]" >&2 $BIN_ECHO " $SCRIPT_PATH --daemon" >&2 $BIN_ECHO " $SCRIPT_PATH --restart-daemon" >&2 $BIN_ECHO " $SCRIPT_PATH --discover" >&2 @@ -831,6 +831,21 @@ SERVER_NUMBER=$PARAM_SERVER_NUMBER debug "SERVER_NUMBER: $SERVER_NUMBER" check_server_number "$SERVER_NUMBER" +if [[ $* == *--configure-only-static-local-dns* ]]; then + # Configuring static DNS records is the only part of examnet that needs the certificate at $PATH_NAKSU2_CERT + # This part can be run separately after the certificate has been downloaded. + + configure_static_dns_records "$SERVER_NUMBER" + + enable_systemd_services + restart_systemd_resolved + restart_dnsmasq + restart_examnet_daemon + restart_docker + + exit_script 0 +fi + write_file $PATH_NET_DEVICE_LAN_CONF "$NET_DEVICE_LAN" write_file $PATH_NET_DEVICE_WAN_CONF "$NET_DEVICE_WAN" From 72d19d9ae7698c962a3bd1e5a01555164a2575f3 Mon Sep 17 00:00:00 2001 From: Mikko Reinikainen Date: Tue, 7 Apr 2026 13:42:58 +0300 Subject: [PATCH 5/9] Add real Microsoft domains that should be allowlisted --- .../ytl-linux-digabi2-examnet | 27 ++++++++++++++----- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/packages/ytl-linux-digabi2-examnet/ytl-linux-digabi2-examnet b/packages/ytl-linux-digabi2-examnet/ytl-linux-digabi2-examnet index abeec04f..fc53a4ad 100755 --- a/packages/ytl-linux-digabi2-examnet/ytl-linux-digabi2-examnet +++ b/packages/ytl-linux-digabi2-examnet/ytl-linux-digabi2-examnet @@ -1,11 +1,11 @@ #!/usr/bin/env bash -# These domains are allowed from LAN to WAN for Windows Defender cloud verification. +# These domains and their subdomains are allowed from LAN to WAN for Windows Defender cloud verification. # Keep this list minimal. -# TODO replace with list of actual domains used by Defender readonly INTERNET_ALLOWLIST_DOMAINS=( - "defender.microsoft.com" - "api.microsoft.com" + "endpoint.security.microsoft.com" + "smartscreen-prod.microsoft.com" + "smartscreen.microsoft.com" ) # Exit codes @@ -528,14 +528,26 @@ function configure_internet_allowlist() { # 1) Write a dnsmasq snippet that (a) adds resolved IPs for allowed domains into ipset # and (b) forwards ONLY those domains to upstream resolver(s). + normalize_dnsmasq_domain_suffix() { + # dnsmasq treats /domain/ as a suffix match; providing a leading dot makes intent explicit. + # Example: endpoint.security.microsoft.com -> .endpoint.security.microsoft.com + _d="$1" + if [[ "$_d" == .* ]]; then + echo "$_d" + else + echo ".$_d" + fi + } + { echo "# Managed by ytl-linux-digabi2-examnet" echo "# Internet allowlist (DNS + ipset)" echo "# - Allowlisted domains are forwarded to upstream resolvers" echo "# - All other domains are null-routed by address=/#/0.0.0.0 in the main config" - echo "# - Returned IPs for allowlisted domains are added to ipset '$INTERNET_ALLOWLIST_IPSET_NAME'" + echo "# - Returned A records for allowlisted domains (and their subdomains) are added to ipset '$INTERNET_ALLOWLIST_IPSET_NAME'" for d in "${INTERNET_ALLOWLIST_DOMAINS[@]}"; do - echo "ipset=/$d/$INTERNET_ALLOWLIST_IPSET_NAME" + sd="$(normalize_dnsmasq_domain_suffix "$d")" + echo "ipset=/$sd/$INTERNET_ALLOWLIST_IPSET_NAME" done } >"$PATH_DNSMASQ_IPSET_ALLOWLIST_CONF" @@ -544,7 +556,8 @@ function configure_internet_allowlist() { echo "# Managed by ytl-linux-digabi2-examnet" echo "# Forward ONLY allowlisted domains to upstream DNS (as defined by resolv-file=/etc/resolv.conf)" for d in "${INTERNET_ALLOWLIST_DOMAINS[@]}"; do - echo "server=/$d/#" + sd="$(normalize_dnsmasq_domain_suffix "$d")" + echo "server=/$sd/#" done } >"$PATH_DNSMASQ_SERVER_ALLOWLIST_CONF" From db07330ab9e68d1c333637af6794e36038f04193 Mon Sep 17 00:00:00 2001 From: Mikko Reinikainen Date: Tue, 14 Apr 2026 09:35:16 +0300 Subject: [PATCH 6/9] Add automatic happy path tests for examnet - examnet script supports flag --not-root which allows running the script as non-root user - call external programs as they are found in path - allow overriding paths - add automatic tests that call the examnet script --- .github/workflows/examnet.yml | 28 + .../ytl-linux-digabi2-examnet/.prettierrc | 7 + .../package-lock.json | 905 ++++++++++++++++++ .../ytl-linux-digabi2-examnet/package.json | 17 + .../test/examnet.test.ts | 541 +++++++++++ .../ytl-linux-digabi2-examnet/tsconfig.json | 5 + .../ytl-linux-digabi2-examnet | 274 +++--- 7 files changed, 1628 insertions(+), 149 deletions(-) create mode 100644 .github/workflows/examnet.yml create mode 100644 packages/ytl-linux-digabi2-examnet/.prettierrc create mode 100644 packages/ytl-linux-digabi2-examnet/package-lock.json create mode 100644 packages/ytl-linux-digabi2-examnet/package.json create mode 100644 packages/ytl-linux-digabi2-examnet/test/examnet.test.ts create mode 100644 packages/ytl-linux-digabi2-examnet/tsconfig.json diff --git a/.github/workflows/examnet.yml b/.github/workflows/examnet.yml new file mode 100644 index 00000000..d2063b42 --- /dev/null +++ b/.github/workflows/examnet.yml @@ -0,0 +1,28 @@ +name: 'Examnet' + +on: + pull_request: + push: + branches: + - main + workflow_dispatch: + +jobs: + test-unit: + name: Unit tests + runs-on: ubuntu-latest + defaults: + run: + working-directory: packages/ytl-linux-digabi2-examnet + permissions: + id-token: write + contents: read + steps: + - uses: actions/checkout@v6 + - uses: digabi/workflows-common/actions/setup-node@main + with: + npm-token: ${{ secrets.NPM_TOKEN }} + - name: Install Node.js dependencies + run: npm ci --workspaces --iwr --no-audit --no-fund + - name: Run unit tests + run: npm run test \ No newline at end of file diff --git a/packages/ytl-linux-digabi2-examnet/.prettierrc b/packages/ytl-linux-digabi2-examnet/.prettierrc new file mode 100644 index 00000000..a01141e9 --- /dev/null +++ b/packages/ytl-linux-digabi2-examnet/.prettierrc @@ -0,0 +1,7 @@ +{ + "printWidth": 120, + "semi": false, + "singleQuote": true, + "trailingComma": "none", + "arrowParens": "avoid" +} diff --git a/packages/ytl-linux-digabi2-examnet/package-lock.json b/packages/ytl-linux-digabi2-examnet/package-lock.json new file mode 100644 index 00000000..34350908 --- /dev/null +++ b/packages/ytl-linux-digabi2-examnet/package-lock.json @@ -0,0 +1,905 @@ +{ + "name": "ytl-linux-digabi2-examnet", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ytl-linux-digabi2-examnet", + "devDependencies": { + "@types/node": "^24.10.9", + "execa": "^9.6.1", + "prettier": "^3.8.1", + "tsx": "^4.21.0" + }, + "engines": { + "node": ">=24" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@sec-ant/readable-stream": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", + "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sindresorhus/merge-streams": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", + "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@types/node": { + "version": "24.12.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", + "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/execa": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz", + "integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^4.0.0", + "cross-spawn": "^7.0.6", + "figures": "^6.1.0", + "get-stream": "^9.0.0", + "human-signals": "^8.0.1", + "is-plain-obj": "^4.1.0", + "is-stream": "^4.0.1", + "npm-run-path": "^6.0.0", + "pretty-ms": "^9.2.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^4.0.0", + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": "^18.19.0 || >=20.5.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/figures": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", + "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-unicode-supported": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-stream": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", + "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sec-ant/readable-stream": "^0.4.1", + "is-stream": "^4.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.7", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", + "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/human-signals": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", + "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/npm-run-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", + "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-ms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", + "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/prettier": { + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.2.tgz", + "integrity": "sha512-8c3mgTe0ASwWAJK+78dpviD+A8EqhndQPUBpNUIPt6+xWlIigCwfN01lWr9MAede4uqXGTEKeQWTvzb3vjia0Q==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/pretty-ms": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", + "integrity": "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse-ms": "^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/strip-final-newline": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", + "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/yoctocolors": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", + "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/packages/ytl-linux-digabi2-examnet/package.json b/packages/ytl-linux-digabi2-examnet/package.json new file mode 100644 index 00000000..7383ac9e --- /dev/null +++ b/packages/ytl-linux-digabi2-examnet/package.json @@ -0,0 +1,17 @@ +{ + "name": "ytl-linux-digabi2-examnet", + "private": true, + "type": "module", + "engines": { + "node": ">=24" + }, + "scripts": { + "test": "node --import tsx --test \"test/**/*.test.ts\"" + }, + "devDependencies": { + "execa": "^9.6.1", + "prettier": "^3.8.1", + "@types/node": "^24.10.9", + "tsx": "^4.21.0" + } +} \ No newline at end of file diff --git a/packages/ytl-linux-digabi2-examnet/test/examnet.test.ts b/packages/ytl-linux-digabi2-examnet/test/examnet.test.ts new file mode 100644 index 00000000..3962102d --- /dev/null +++ b/packages/ytl-linux-digabi2-examnet/test/examnet.test.ts @@ -0,0 +1,541 @@ +import assert from 'node:assert/strict' +import { test, describe, beforeEach } from 'node:test' +import { execa } from 'execa' +import { join } from 'node:path' +import { mkdtemp, writeFile, chmod, readFile, mkdir, truncate, access } from 'node:fs/promises' +import { tmpdir } from 'node:os' + +describe('examnet', async () => { + let callsLog + let mockBinDir + let mockConfigDir + let mockTemplatesDir + let mockResolvedDir + let mockDockerDir + let mockDnsmasqDir + let mockNaksu2WorkDir + let mockNaksu2CertsDir + + beforeEach(async () => { + await truncateCallsLog() + ;({ + mockBinDir, + mockConfigDir, + mockTemplatesDir, + mockResolvedDir, + mockDockerDir, + mockDnsmasqDir, + mockNaksu2WorkDir, + mockNaksu2CertsDir + } = await initTempDir()) + }) + + function runExamnet(netDeviceWan: string, netDeviceLan: string, serverNumber: number, serverFriendlyName?: string) { + return execa( + './ytl-linux-digabi2-examnet', + [netDeviceWan, netDeviceLan, `${serverNumber}`, serverFriendlyName, '--not-root'].filter(Boolean), + { + env: { + ...process.env, + PATH: `${mockBinDir}:${process.env.PATH}`, + CALLS_LOG: callsLog, + PATH_EXAMNET_CONFIG: mockConfigDir, + PATH_TEMPLATES: mockTemplatesDir, + PATH_RESOLVED: mockResolvedDir, + PATH_DOCKER: mockDockerDir, + PATH_DNSMASQ: mockDnsmasqDir, + NAKSU2_WORKDIR: mockNaksu2WorkDir + }, + detached: true + } + ) + } + + describe('bouncer', async () => { + test('daemon starts when correct parameters are given', async () => { + await writeToTempDir(mockConfigDir, 'net-device-lan', 'eth0') + await writeToTempDir(mockConfigDir, 'server-friendly-name', 'foobar') + + // do not await runExamnet, as it stays running in daemon mode + const subprocess = runExamnet('eth0', 'eth1', 1, '--daemon') + await waitForLogEntry(callsLog, '"ytl-linux-digabi2-bouncer"') + await assertCalls([ + { cmd: 'stat', argv: ['-c', '%U:%G', mockNaksu2WorkDir] }, + { cmd: 'ip', argv: ['-oneline', '-4', 'addr', 'show', 'scope', 'global', 'eth0'] }, + { + cmd: 'ytl-linux-digabi2-bouncer', + argv: [ + ` { "config": { "friendlyName": "foobar", "canonicalHostname": "kamreeri-kelvokas.koe.abitti.net", "ncsiHostnames": ["example.com"], "searchDomain": "internal", "serverOwnIp": "127.0.0.1", "ports": {"discovery": 26464, "bouncer": 80} }, "secrets": {"cert":"${mockNaksu2CertsDir}/fullchain.pem","key":"${mockNaksu2CertsDir}/key.pem"} }` + ] + } + ]) + await killSubprocess(subprocess) + }) + }) + + describe('restart-bouncer', () => { + test('runs when correct parameters are given', async () => { + await runExamnet('eth0', 'eth1', 1, '--restart-daemon') + await assertCalls([ + { cmd: 'stat', argv: ['-c', '%U:%G', mockNaksu2WorkDir] }, + { cmd: 'systemctl', argv: ['is-enabled', 'ytl-linux-digabi2-examnet.service'] }, + { cmd: 'systemctl', argv: ['is-enabled', 'ytl-linux-digabi2-examnet-discovery.timer'] }, + { cmd: 'systemctl', argv: ['is-enabled', 'ytl-linux-digabi2-examnet-discovery.service'] } + ]) + }) + }) + + describe('discovery', () => { + test('runs when correct parameters are given', async () => { + await runExamnet('eth0', 'eth1', 1, '--discovery') + await assertCalls([ + { cmd: 'stat', argv: ['-c', '%U:%G', mockNaksu2WorkDir] }, + { + cmd: 'ytl-linux-digabi2-discovery', + argv: [ + ` {\n "config": {\n "isProd": true,\n "ktpDomains": [],\n "dnsmasqConfigOutputFile": "${mockDnsmasqDir}/ytl-linux-ktp-aliases.conf",\n "ports": {"discovery": 26464}\n }\n }` + ] + } + ]) + }) + }) + + describe('setup', () => { + test('runs when correct parameters are given', async () => { + await runExamnet('eth0', 'eth1', 1) + await assertCalls([ + { cmd: 'stat', argv: ['-c', '%U:%G', mockNaksu2WorkDir] }, + { cmd: 'ip', argv: ['link', 'show', 'eth0'] }, + { cmd: 'ip', argv: ['link', 'show', 'eth1'] }, + { cmd: 'ip', argv: ['-oneline', '-4', 'addr', 'show', 'scope', 'global', 'eth0'] }, + { cmd: 'ip', argv: ['-oneline', '-4', 'addr', 'show', 'scope', 'global', 'eth1'] }, + { cmd: 'nmcli', argv: ['connection', 'delete', 'yo-eth1'] }, + { + cmd: 'nmcli', + argv: [ + 'connection', + 'add', + 'type', + 'ethernet', + 'ifname', + 'eth1', + 'con-name', + 'yo-eth1', + 'ip4', + '192.168.10.1/16', + 'autoconnect', + 'yes', + 'save', + 'yes' + ] + }, + { cmd: 'nmcli', argv: ['connection', 'modify', 'yo-eth1', 'ipv6.method', 'disabled'] }, + { cmd: 'nmcli', argv: ['connection', 'up', 'yo-eth1'] }, + { cmd: 'systemctl', argv: ['restart', 'NetworkManager.service'] }, + { cmd: 'nm-online', argv: ['-s', '-q', '--timeout=30'] }, + { cmd: 'ipset', argv: ['create', 'ytl_internet_allowlist', 'hash:ip', 'timeout', '3600', '-exist'] }, + { cmd: 'iptables', argv: ['-t', 'filter', '-N', 'YTL_LAN_WAN_IPSET_LOG'] }, + { cmd: 'iptables', argv: ['-t', 'filter', '-F', 'YTL_LAN_WAN_IPSET_LOG'] }, + { + cmd: 'iptables', + argv: [ + '-t', + 'filter', + '-A', + 'YTL_LAN_WAN_IPSET_LOG', + '-m', + 'conntrack', + '--ctstate', + 'NEW', + '-m', + 'set', + '--match-set', + 'ytl_internet_allowlist', + 'dst', + '-m', + 'limit', + '--limit', + '10/second', + '--limit-burst', + '30', + '-j', + 'LOG', + '--log-prefix', + 'YTL ALLOW NEW ', + '--log-level', + '6' + ] + }, + { cmd: 'iptables', argv: ['-t', 'filter', '-A', 'YTL_LAN_WAN_IPSET_LOG', '-j', 'RETURN'] }, + { cmd: 'iptables', argv: ['-t', 'filter', '-N', 'YTL_LAN_WAN_IPSET'] }, + { cmd: 'iptables', argv: ['-t', 'filter', '-F', 'YTL_LAN_WAN_IPSET'] }, + { + cmd: 'iptables', + argv: [ + '-t', + 'filter', + '-A', + 'YTL_LAN_WAN_IPSET', + '-m', + 'set', + '--match-set', + 'ytl_internet_allowlist', + 'dst', + '-j', + 'ACCEPT' + ] + }, + { cmd: 'iptables', argv: ['-t', 'filter', '-A', 'YTL_LAN_WAN_IPSET', '-j', 'DROP'] }, + { + cmd: 'iptables', + argv: [ + '-t', + 'filter', + '-C', + 'FORWARD', + '-i', + 'eth0', + '-o', + 'eth1', + '-m', + 'conntrack', + '--ctstate', + 'RELATED,ESTABLISHED', + '-j', + 'ACCEPT' + ] + }, + { + cmd: 'iptables', + argv: ['-t', 'filter', '-C', 'FORWARD', '-i', 'eth1', '-o', 'eth0', '-j', 'YTL_LAN_WAN_IPSET_LOG'] + }, + { + cmd: 'iptables', + argv: ['-t', 'filter', '-C', 'FORWARD', '-i', 'eth1', '-o', 'eth0', '-j', 'YTL_LAN_WAN_IPSET'] + }, + { + cmd: 'iptables', + argv: [ + '-t', + 'nat', + '-C', + 'POSTROUTING', + '-o', + 'eth0', + '-m', + 'set', + '--match-set', + 'ytl_internet_allowlist', + 'dst', + '-j', + 'MASQUERADE' + ] + }, + { cmd: 'systemctl', argv: ['is-enabled', 'dnsmasq.service'] }, + { cmd: 'dig', argv: ['+time=1', '+tries=1', '@127.0.0.1', 'endpoint.security.microsoft.com', 'A'] }, + { cmd: 'dig', argv: ['+time=1', '+tries=1', '@127.0.0.1', 'smartscreen-prod.microsoft.com', 'A'] }, + { cmd: 'dig', argv: ['+time=1', '+tries=1', '@127.0.0.1', 'smartscreen.microsoft.com', 'A'] }, + { cmd: 'systemctl', argv: ['enable', 'ytl-linux-digabi2-examnet.service'] }, + { cmd: 'systemctl', argv: ['enable', 'dnsmasq.service'] }, + { cmd: 'systemctl', argv: ['enable', 'ytl-linux-digabi2-examnet-discovery.service'] }, + { cmd: 'systemctl', argv: ['enable', 'ytl-linux-digabi2-examnet-discovery.timer'] }, + { cmd: 'systemctl', argv: ['restart', 'systemd-resolved'] }, + { cmd: 'systemctl', argv: ['is-enabled', 'dnsmasq.service'] }, + { cmd: 'systemctl', argv: ['is-enabled', 'ytl-linux-digabi2-examnet.service'] }, + { cmd: 'systemctl', argv: ['is-enabled', 'ytl-linux-digabi2-examnet-discovery.timer'] }, + { cmd: 'systemctl', argv: ['is-enabled', 'ytl-linux-digabi2-examnet-discovery.service'] }, + { cmd: 'systemctl', argv: ['restart', 'docker'] } + ]) + }) + }) + + describe('destroy', () => { + test('runs when correct parameters are given', async () => { + await runExamnet('eth0', 'eth1', 1) + await assertCalls([ + { cmd: 'stat', argv: ['-c', '%U:%G', mockNaksu2WorkDir] }, + { cmd: 'ip', argv: ['link', 'show', 'eth0'] }, + { cmd: 'ip', argv: ['link', 'show', 'eth1'] }, + { cmd: 'ip', argv: ['-oneline', '-4', 'addr', 'show', 'scope', 'global', 'eth0'] }, + { cmd: 'ip', argv: ['-oneline', '-4', 'addr', 'show', 'scope', 'global', 'eth1'] }, + { cmd: 'nmcli', argv: ['connection', 'delete', 'yo-eth1'] }, + { + cmd: 'nmcli', + argv: [ + 'connection', + 'add', + 'type', + 'ethernet', + 'ifname', + 'eth1', + 'con-name', + 'yo-eth1', + 'ip4', + '192.168.10.1/16', + 'autoconnect', + 'yes', + 'save', + 'yes' + ] + }, + { cmd: 'nmcli', argv: ['connection', 'modify', 'yo-eth1', 'ipv6.method', 'disabled'] }, + { cmd: 'nmcli', argv: ['connection', 'up', 'yo-eth1'] }, + { cmd: 'systemctl', argv: ['restart', 'NetworkManager.service'] }, + { cmd: 'nm-online', argv: ['-s', '-q', '--timeout=30'] }, + { cmd: 'ipset', argv: ['create', 'ytl_internet_allowlist', 'hash:ip', 'timeout', '3600', '-exist'] }, + { cmd: 'iptables', argv: ['-t', 'filter', '-N', 'YTL_LAN_WAN_IPSET_LOG'] }, + { cmd: 'iptables', argv: ['-t', 'filter', '-F', 'YTL_LAN_WAN_IPSET_LOG'] }, + { + cmd: 'iptables', + argv: [ + '-t', + 'filter', + '-A', + 'YTL_LAN_WAN_IPSET_LOG', + '-m', + 'conntrack', + '--ctstate', + 'NEW', + '-m', + 'set', + '--match-set', + 'ytl_internet_allowlist', + 'dst', + '-m', + 'limit', + '--limit', + '10/second', + '--limit-burst', + '30', + '-j', + 'LOG', + '--log-prefix', + 'YTL ALLOW NEW ', + '--log-level', + '6' + ] + }, + { cmd: 'iptables', argv: ['-t', 'filter', '-A', 'YTL_LAN_WAN_IPSET_LOG', '-j', 'RETURN'] }, + { cmd: 'iptables', argv: ['-t', 'filter', '-N', 'YTL_LAN_WAN_IPSET'] }, + { cmd: 'iptables', argv: ['-t', 'filter', '-F', 'YTL_LAN_WAN_IPSET'] }, + { + cmd: 'iptables', + argv: [ + '-t', + 'filter', + '-A', + 'YTL_LAN_WAN_IPSET', + '-m', + 'set', + '--match-set', + 'ytl_internet_allowlist', + 'dst', + '-j', + 'ACCEPT' + ] + }, + { cmd: 'iptables', argv: ['-t', 'filter', '-A', 'YTL_LAN_WAN_IPSET', '-j', 'DROP'] }, + { + cmd: 'iptables', + argv: [ + '-t', + 'filter', + '-C', + 'FORWARD', + '-i', + 'eth0', + '-o', + 'eth1', + '-m', + 'conntrack', + '--ctstate', + 'RELATED,ESTABLISHED', + '-j', + 'ACCEPT' + ] + }, + { + cmd: 'iptables', + argv: ['-t', 'filter', '-C', 'FORWARD', '-i', 'eth1', '-o', 'eth0', '-j', 'YTL_LAN_WAN_IPSET_LOG'] + }, + { + cmd: 'iptables', + argv: ['-t', 'filter', '-C', 'FORWARD', '-i', 'eth1', '-o', 'eth0', '-j', 'YTL_LAN_WAN_IPSET'] + }, + { + cmd: 'iptables', + argv: [ + '-t', + 'nat', + '-C', + 'POSTROUTING', + '-o', + 'eth0', + '-m', + 'set', + '--match-set', + 'ytl_internet_allowlist', + 'dst', + '-j', + 'MASQUERADE' + ] + }, + { cmd: 'systemctl', argv: ['is-enabled', 'dnsmasq.service'] }, + { cmd: 'dig', argv: ['+time=1', '+tries=1', '@127.0.0.1', 'endpoint.security.microsoft.com', 'A'] }, + { cmd: 'dig', argv: ['+time=1', '+tries=1', '@127.0.0.1', 'smartscreen-prod.microsoft.com', 'A'] }, + { cmd: 'dig', argv: ['+time=1', '+tries=1', '@127.0.0.1', 'smartscreen.microsoft.com', 'A'] }, + { cmd: 'systemctl', argv: ['enable', 'ytl-linux-digabi2-examnet.service'] }, + { cmd: 'systemctl', argv: ['enable', 'dnsmasq.service'] }, + { cmd: 'systemctl', argv: ['enable', 'ytl-linux-digabi2-examnet-discovery.service'] }, + { cmd: 'systemctl', argv: ['enable', 'ytl-linux-digabi2-examnet-discovery.timer'] }, + { cmd: 'systemctl', argv: ['restart', 'systemd-resolved'] }, + { cmd: 'systemctl', argv: ['is-enabled', 'dnsmasq.service'] }, + { cmd: 'systemctl', argv: ['is-enabled', 'ytl-linux-digabi2-examnet.service'] }, + { cmd: 'systemctl', argv: ['is-enabled', 'ytl-linux-digabi2-examnet-discovery.timer'] }, + { cmd: 'systemctl', argv: ['is-enabled', 'ytl-linux-digabi2-examnet-discovery.service'] }, + { cmd: 'systemctl', argv: ['restart', 'docker'] } + ]) + }) + }) + + async function truncateCallsLog() { + try { + await access(callsLog) + await truncate(callsLog) + } catch { + // callsLog doesn't exist yet + } + } + + async function initTempDir() { + const root = await mkdtemp(join(tmpdir(), 'just-test-')) + + callsLog = join(root, 'calls.log') + + const mockBinDir = await makeTempDir(root, 'mock-bin-dir') + const mockConfigDir = await makeTempDir(root, 'mock-config-dir') + const mockTemplatesDir = await makeTempDir(root, 'mock-templates-dir') + const mockResolvedDir = await makeTempDir(root, 'mock-resolved-dir') + const mockDockerDir = await makeTempDir(root, 'mock-docker-dir') + const mockDnsmasqDir = await makeTempDir(root, 'mock-dnsmasq-dir') + const mockNaksu2WorkDir = await makeTempDir(root, 'naksu2-work-dir') + const mockNaksu2CertsDir = await makeTempDir(mockNaksu2WorkDir, 'certs') + + await writeToTempDir(mockNaksu2CertsDir, 'domain.txt', 'kamreeri-kelvokas.koe.abitti.net') + await writeToTempDir(mockBinDir, 'echo', mockScript) + await writeToTempDir(mockBinDir, 'nmcli', mockScript) + await writeToTempDir(mockBinDir, 'systemctl', mockScript) + await writeToTempDir(mockBinDir, 'nm-online', mockScript) + await writeToTempDir(mockBinDir, 'ip', mockScript) + await writeToTempDir(mockBinDir, 'iptables', mockScript) + await writeToTempDir(mockBinDir, 'ipset', mockScript) + await writeToTempDir(mockBinDir, 'dig', mockScript) + await writeToTempDir(mockBinDir, 'stat', mockScript) + await writeToTempDir(mockBinDir, 'ytl-linux-digabi2-bouncer', mockBouncer) + await writeToTempDir(mockBinDir, 'ytl-linux-digabi2-discovery', mockScript) + + await writeToTempDir(mockConfigDir, 'ncsi-hostnames', 'example.com') + await writeToTempDir(mockConfigDir, 'server-own-ip', '127.0.0.1') + await writeToTempDir(mockTemplatesDir, 'resolved.conf.template', 'foobar') + await writeToTempDir(mockTemplatesDir, 'docker-daemon.json.template', 'foobar') + await writeToTempDir(mockTemplatesDir, 'dnsmasq.conf.template', 'foobar') + await writeToTempDir(mockDnsmasqDir, 'ytl-linux-static-dns-records.conf', 'xyzzy') + return { + mockBinDir, + mockConfigDir, + mockTemplatesDir, + mockResolvedDir, + mockDockerDir, + mockDnsmasqDir, + mockNaksu2WorkDir, + mockNaksu2CertsDir + } + } + + async function assertCalls(expectedCalls: Object[]) { + const calls = (await readFile(callsLog, 'utf8')).trim() + const callsLines = calls.split('\n') + // console.log(`expecting ${expectedCalls.length} calls to external programs`) + const callsArray = callsLines.map(line => { + // console.log(`parsing line ${line}`) + return JSON.parse(line) + }) + assert.deepEqual(callsArray, expectedCalls) + } +}) + +const mockScript = `#!/usr/bin/env bash +node -e ' + const fs = require("node:fs"); + const path = require("node:path"); + const entry = { + cmd: path.basename(process.argv[1]), + argv: process.argv.slice(2), + }; + // console.error(process.argv, "writing to", process.env.CALLS_LOG) + if (process.argv[8] === "eth0") { + console.log("127.0.0.1") + } else { + console.log("") + } + fs.appendFileSync(process.env.CALLS_LOG, JSON.stringify(entry) + "\\n"); +' "$0" "$@" +exit 0 +` + +const mockBouncer = `#!/usr/bin/env bash +node -e ' + const fs = require("node:fs"); + fs.appendFileSync(process.env.CALLS_LOG, JSON.stringify({ + cmd: "ytl-linux-digabi2-bouncer", + argv: process.argv.slice(1), + }) + "\\n"); +' "$@" +while true; do + sleep 1 +done +` + +async function writeToTempDir(dir: string, name: string, script: string) { + const file = join(dir, name) + await writeFile(file, script, 'utf8') + await chmod(file, 0o755) + return file +} + +async function makeTempDir(root: string, name: string) { + const dir = join(root, name) + await mkdir(dir, { recursive: true }) + return dir +} + +async function waitForLogEntry(callsLog: string, msg: string) { + const start = Date.now() + while (Date.now() - start < 5000) { + try { + const contents = await readFile(callsLog, 'utf8') + if (contents.includes(msg)) { + break + } + } catch { + // file may not exist yet + } + await new Promise(r => setTimeout(r, 100)) + } +} + +async function killSubprocess(subprocess: any) { + let killTimer + try { + process.kill(-subprocess.pid!, 'SIGTERM') + + killTimer = setTimeout(() => { + try { + process.kill(-subprocess.pid!, 'SIGKILL') + } catch {} + }, 1000) + await Promise.race([subprocess.catch(() => {}), new Promise(r => setTimeout(r, 3000))]) + } finally { + if (killTimer) clearTimeout(killTimer) + } +} diff --git a/packages/ytl-linux-digabi2-examnet/tsconfig.json b/packages/ytl-linux-digabi2-examnet/tsconfig.json new file mode 100644 index 00000000..a786b939 --- /dev/null +++ b/packages/ytl-linux-digabi2-examnet/tsconfig.json @@ -0,0 +1,5 @@ +{ + "compilerOptions": { + "esModuleInterop": true + } +} \ No newline at end of file diff --git a/packages/ytl-linux-digabi2-examnet/ytl-linux-digabi2-examnet b/packages/ytl-linux-digabi2-examnet/ytl-linux-digabi2-examnet index fc53a4ad..41092ee0 100755 --- a/packages/ytl-linux-digabi2-examnet/ytl-linux-digabi2-examnet +++ b/packages/ytl-linux-digabi2-examnet/ytl-linux-digabi2-examnet @@ -49,51 +49,31 @@ readonly FRIENDLY_NAME_SEARCH_DOMAIN="internal" readonly DISCOVERY_PORT=26464 readonly BOUNCER_PORT=80 -readonly PATH_TEMPLATES=/etc/ytl-linux-digabi2-examnet/templates -readonly PATH_RESOLVED=/etc/systemd/resolved.conf.d +readonly PATH_TEMPLATES=${PATH_TEMPLATES:-/etc/ytl-linux-digabi2-examnet/templates} +readonly PATH_RESOLVED=${PATH_RESOLVED:-/etc/systemd/resolved.conf.d} readonly PATH_RESOLVED_CONF=$PATH_RESOLVED/ytl-linux.conf readonly PATH_RESOLVED_CONF_TEMPLATE=$PATH_TEMPLATES/resolved.conf.template -readonly PATH_DNSMASQ=/etc/dnsmasq.d +readonly PATH_DNSMASQ=${PATH_DNSMASQ:-/etc/dnsmasq.d} readonly PATH_DNSMASQ_CONF=$PATH_DNSMASQ/ytl-linux.conf readonly PATH_DNSMASQ_CONF_TEMPLATE=$PATH_TEMPLATES/dnsmasq.conf.template readonly PATH_DNSMASQ_STATIC_DNS_CONF=$PATH_DNSMASQ/ytl-linux-static-dns-records.conf readonly PATH_DNSMASQ_KTP_ALIASES_CONF=$PATH_DNSMASQ/ytl-linux-ktp-aliases.conf readonly PATH_DNSMASQ_IPSET_ALLOWLIST_CONF=$PATH_DNSMASQ/ytl-linux-internet-allowlist-ipset.conf readonly PATH_DNSMASQ_SERVER_ALLOWLIST_CONF=$PATH_DNSMASQ/ytl-linux-internet-allowlist-server.conf -readonly PATH_DOCKER=/etc/docker +readonly PATH_DOCKER=${PATH_DOCKER:-/etc/docker} readonly PATH_DOCKER_DAEMON_CONF=$PATH_DOCKER/daemon.json readonly PATH_DOCKER_DAEMON_CONF_TEMPLATE=$PATH_TEMPLATES/docker-daemon.json.template readonly PATH_NAKSU2_WORKDIR="${NAKSU2_WORKDIR:-/home/school/.local/share/digabi/naksu2}" readonly PATH_NAKSU2_CERTS_DIR="$PATH_NAKSU2_WORKDIR/certs" readonly PATH_NAKSU2_CERT="$PATH_NAKSU2_CERTS_DIR/cert.pem" readonly PATH_NAKSU2_DOMAIN="$PATH_NAKSU2_CERTS_DIR/domain.txt" -readonly PATH_EXAMNET_CONFIG=/etc/ytl-linux-digabi2-examnet/config/ +readonly PATH_EXAMNET_CONFIG="${PATH_EXAMNET_CONFIG:-/etc/ytl-linux-digabi2-examnet/config/}" readonly PATH_SERVER_FRIENDLY_NAME_CONF=$PATH_EXAMNET_CONFIG/server-friendly-name readonly PATH_NET_DEVICE_LAN_CONF=$PATH_EXAMNET_CONFIG/net-device-lan readonly PATH_NET_DEVICE_WAN_CONF=$PATH_EXAMNET_CONFIG/net-device-wan readonly PATH_SERVER_OWN_IP=$PATH_EXAMNET_CONFIG/server-own-ip readonly PATH_DISCOVERY_DB=$PATH_EXAMNET_CONFIG/discovery.db -readonly BIN_ECHO=/usr/bin/echo -readonly BIN_GREP=/usr/bin/grep -readonly BIN_TR=/usr/bin/tr -readonly BIN_CUT=/usr/bin/cut -readonly BIN_IP=/usr/sbin/ip -readonly BIN_MKDIR=/usr/bin/mkdir -readonly BIN_SYSTEMCTL=/usr/bin/systemctl -readonly BIN_NMCLI=/usr/bin/nmcli -readonly BIN_NM_ONLINE=/usr/bin/nm-online -readonly BIN_SED=/usr/bin/sed -readonly BIN_XARGS=/usr/bin/xargs -readonly BIN_OPENSSL=/usr/bin/openssl -readonly BIN_IPTABLES=/usr/sbin/iptables -readonly BIN_IPSET=/usr/sbin/ipset -readonly BIN_DIG=/usr/bin/dig -readonly BIN_DIGABI2_EXAMNET_BOUNCER=/usr/local/libexec/ytl-linux-digabi2-examnet/bouncer -readonly BIN_DIGABI2_EXAMNET_DISCOVERY=/usr/local/libexec/ytl-linux-digabi2-examnet/discovery -readonly BIN_STAT=/usr/bin/stat -readonly BIN_CHOWN=/usr/bin/chown - readonly CONST_MIN_SERVER_NUMBER=1 readonly CONST_MAX_SERVER_NUMBER=24 readonly CONST_SUBNETS_PER_SERVER=10 @@ -106,7 +86,7 @@ readonly INTERNET_ALLOWLIST_IPSET_TIMEOUT_SECONDS=3600 function get_dir_owner() { _DIR=$1 if [ -d "$_DIR" ]; then - $BIN_STAT -c '%U:%G' "$_DIR" + stat -c '%U:%G' "$_DIR" fi } @@ -114,9 +94,9 @@ readonly NAKSU2_WORKDIR_OWNER=$(get_dir_owner "$PATH_NAKSU2_WORKDIR") function debug_path() { if [ -z "$DEBUG" ]; then - $BIN_ECHO "/dev/null" + echo "/dev/null" else - $BIN_ECHO "$DEBUG" + echo "$DEBUG" fi } @@ -125,29 +105,29 @@ readonly PATH_DEBUG function debug() { _DEBUG_MESSAGE=$1 - $BIN_ECHO "$SCRIPT_PATH DEBUG: $_DEBUG_MESSAGE" >>"$PATH_DEBUG" + echo "$SCRIPT_PATH DEBUG: $_DEBUG_MESSAGE" #>>"$PATH_DEBUG" } function print_error() { _ERROR_MESSAGE=$1 debug "ERROR: $_ERROR_MESSAGE" - $BIN_ECHO "$SCRIPT_PATH error: $_ERROR_MESSAGE" >&2 + echo "$SCRIPT_PATH error: $_ERROR_MESSAGE" >&2 } function print_info() { _INFO_MESSAGE=$1 debug "INFO: $_INFO_MESSAGE" - $BIN_ECHO "$SCRIPT_PATH info: $_INFO_MESSAGE" + echo "$SCRIPT_PATH info: $_INFO_MESSAGE" } function print_usage() { - $BIN_ECHO "usage: $SCRIPT_PATH wan-network-device local-network-device server-number [custom-server-name] [--use-static-local-dns] [--configure-only-static-local-dns]" >&2 - $BIN_ECHO " $SCRIPT_PATH --daemon" >&2 - $BIN_ECHO " $SCRIPT_PATH --restart-daemon" >&2 - $BIN_ECHO " $SCRIPT_PATH --discover" >&2 - $BIN_ECHO " $SCRIPT_PATH --remove" >&2 - $BIN_ECHO "" >&2 - $BIN_ECHO "example: $SCRIPT_PATH eth0 eth1 1" >&2 + echo "usage: $SCRIPT_PATH wan-network-device local-network-device server-number [custom-server-name] [--use-static-local-dns] [--configure-only-static-local-dns] [--not-root]" >&2 + echo " $SCRIPT_PATH --daemon" >&2 + echo " $SCRIPT_PATH --restart-daemon" >&2 + echo " $SCRIPT_PATH --discover" >&2 + echo " $SCRIPT_PATH --remove" >&2 + echo "" >&2 + echo "example: $SCRIPT_PATH eth0 eth1 1" >&2 } function exit_script() { @@ -185,14 +165,15 @@ function network_enumerate_devices () { function network_device_exists() { _DEVICE=$1 - $BIN_IP link show "$_DEVICE" &> /dev/null && $BIN_ECHO "1" + ip link show "$_DEVICE" &> /dev/null && echo "1" } function get_ipv4_address() { _DEVICE=$1 - _IP=$($BIN_IP -oneline -4 addr show scope global "$_DEVICE" | $BIN_TR -s ' ' | $BIN_TR '/' ' ' | $BIN_CUT -f 4 -d ' ') + _IP=$(ip -oneline -4 addr show scope global "$_DEVICE" | tr -s ' ' | tr '/' ' ' | cut -f 4 -d ' ') + # echo called ip command with $PATH >&2 if [[ ! "$_IP" =~ "does not exist" ]]; then - $BIN_ECHO "$_IP" + echo "$_IP" fi } @@ -236,7 +217,7 @@ function check_network_device_names() { function server_number_is_valid() { _SERVER_NUMBER=$1 if [[ "$_SERVER_NUMBER" =~ ^[0-9]+$ ]] && [ "$_SERVER_NUMBER" -ge $CONST_MIN_SERVER_NUMBER ] && [ "$_SERVER_NUMBER" -le $CONST_MAX_SERVER_NUMBER ]; then - $BIN_ECHO "1" + echo "1" fi } @@ -260,9 +241,9 @@ function get_lan_ip_prefix() { _IP_WAN=$1 if [[ "$_IP_WAN" =~ ^192\.168\. ]]; then - $BIN_ECHO "10.0." + echo "10.0." else - $BIN_ECHO "192.168." + echo "192.168." fi } @@ -274,21 +255,21 @@ function write_file() { _FILE_PATH=$(dirname "$_FILE_FILENAME") if [ ! -d "$_FILE_PATH" ]; then debug "Path $_FILE_PATH is missing, creating" - $BIN_MKDIR -p "$_FILE_PATH" + mkdir -p "$_FILE_PATH" exit_if_error $? $EXIT_CODE_BAD_FILE_PATH "Failed to create directory $_FILE_PATH" if [ -n "$_FILE_OWNER" ]; then - $BIN_CHOWN "$_FILE_OWNER" "$_FILE_PATH" + chown "$_FILE_OWNER" "$_FILE_PATH" fi else debug "Path $_FILE_PATH exists" fi debug "$_FILE_FILENAME: $_FILE_CONTENT" - $BIN_ECHO -e "$_FILE_CONTENT" >"$_FILE_FILENAME" + echo -e "$_FILE_CONTENT" >"$_FILE_FILENAME" exit_if_error $? $EXIT_CODE_CANNOT_WRITE "Failed to write to $_FILE_FILENAME" if [ -n "$_FILE_OWNER" ]; then - $BIN_CHOWN "$_FILE_OWNER" "$_FILE_FILENAME" + chown "$_FILE_OWNER" "$_FILE_FILENAME" fi } @@ -298,12 +279,12 @@ function configure_networkmanager() { _IP_AND_NETMASK=$3 debug "Deleting existing NetworkManager connection '$_CONNECTION_NAME'" - $BIN_NMCLI connection delete "$_CONNECTION_NAME" >>"$PATH_DEBUG" 2>&1 + nmcli connection delete "$_CONNECTION_NAME" >>"$PATH_DEBUG" 2>&1 # We are not checking the exit code as this command fails if the connection does not exist # This is quite normal if the script has been executed before debug "Adding NetworkManager connection '$_CONNECTION_NAME'" - $BIN_NMCLI connection add \ + nmcli connection add \ type ethernet \ ifname "$_INTERFACE_NAME" \ con-name "$_CONNECTION_NAME" \ @@ -313,11 +294,11 @@ function configure_networkmanager() { exit_if_error $? $EXIT_CODE_CANNOT_RESTART_LAN_DEVICE "NetworkManager CLI: connection add $_CONNECTION_NAME failed" debug "Disabling IPv6 in connection '$_CONNECTION_NAME'" - $BIN_NMCLI connection modify "$_CONNECTION_NAME" ipv6.method "disabled" >>"$PATH_DEBUG" 2>&1 + nmcli connection modify "$_CONNECTION_NAME" ipv6.method "disabled" >>"$PATH_DEBUG" 2>&1 exit_if_error $? $EXIT_CODE_CANNOT_RESTART_LAN_DEVICE "NetworkManager CLI: IPv6 disable failed" debug "Starting connection '$_CONNECTION_NAME'" - $BIN_NMCLI connection up "$_CONNECTION_NAME" >>"$PATH_DEBUG" 2>&1 + nmcli connection up "$_CONNECTION_NAME" >>"$PATH_DEBUG" 2>&1 exit_if_error $? $EXIT_CODE_CANNOT_RESTART_LAN_DEVICE "Failed to start NetworkManager connection '$_CONNECTION_NAME'" } @@ -360,77 +341,72 @@ function remove_internet_allowlist() { if [ -z "$NET_DEVICE_WAN" ] || [ -z "$NET_DEVICE_LAN" ]; then # Can't remove interface-specific rules; still flush/destroy ipset. - if [ -x "$BIN_IPSET" ]; then - $BIN_IPSET flush "$INTERNET_ALLOWLIST_IPSET_NAME" 2>/dev/null || true - $BIN_IPSET destroy "$INTERNET_ALLOWLIST_IPSET_NAME" 2>/dev/null || true + if [ -x "ipset" ]; then + ipset flush "$INTERNET_ALLOWLIST_IPSET_NAME" 2>/dev/null || true + ipset destroy "$INTERNET_ALLOWLIST_IPSET_NAME" 2>/dev/null || true fi return 0 fi - # Only attempt if binaries exist. - if [ -x "$BIN_IPTABLES" ]; then - # Remove NAT rule - while $BIN_IPTABLES -t nat -C POSTROUTING -o "$NET_DEVICE_WAN" -m set --match-set "$INTERNET_ALLOWLIST_IPSET_NAME" dst -j MASQUERADE 2>/dev/null; do - $BIN_IPTABLES -t nat -D POSTROUTING -o "$NET_DEVICE_WAN" -m set --match-set "$INTERNET_ALLOWLIST_IPSET_NAME" dst -j MASQUERADE - done + # Remove NAT rule + while iptables -t nat -C POSTROUTING -o "$NET_DEVICE_WAN" -m set --match-set "$INTERNET_ALLOWLIST_IPSET_NAME" dst -j MASQUERADE 2>/dev/null; do + iptables -t nat -D POSTROUTING -o "$NET_DEVICE_WAN" -m set --match-set "$INTERNET_ALLOWLIST_IPSET_NAME" dst -j MASQUERADE + done - # Remove FORWARD jump rules - while $BIN_IPTABLES -t filter -C FORWARD -i "$NET_DEVICE_LAN" -o "$NET_DEVICE_WAN" -j "$INTERNET_ALLOWLIST_LOG_CHAIN" 2>/dev/null; do - $BIN_IPTABLES -t filter -D FORWARD -i "$NET_DEVICE_LAN" -o "$NET_DEVICE_WAN" -j "$INTERNET_ALLOWLIST_LOG_CHAIN" - done + # Remove FORWARD jump rules + while iptables -t filter -C FORWARD -i "$NET_DEVICE_LAN" -o "$NET_DEVICE_WAN" -j "$INTERNET_ALLOWLIST_LOG_CHAIN" 2>/dev/null; do + iptables -t filter -D FORWARD -i "$NET_DEVICE_LAN" -o "$NET_DEVICE_WAN" -j "$INTERNET_ALLOWLIST_LOG_CHAIN" + done - while $BIN_IPTABLES -t filter -C FORWARD -i "$NET_DEVICE_LAN" -o "$NET_DEVICE_WAN" -j "$INTERNET_ALLOWLIST_FILTER_CHAIN" 2>/dev/null; do - $BIN_IPTABLES -t filter -D FORWARD -i "$NET_DEVICE_LAN" -o "$NET_DEVICE_WAN" -j "$INTERNET_ALLOWLIST_FILTER_CHAIN" - done + while iptables -t filter -C FORWARD -i "$NET_DEVICE_LAN" -o "$NET_DEVICE_WAN" -j "$INTERNET_ALLOWLIST_FILTER_CHAIN" 2>/dev/null; do + iptables -t filter -D FORWARD -i "$NET_DEVICE_LAN" -o "$NET_DEVICE_WAN" -j "$INTERNET_ALLOWLIST_FILTER_CHAIN" + done - # Remove established return rule - while $BIN_IPTABLES -t filter -C FORWARD -i "$NET_DEVICE_WAN" -o "$NET_DEVICE_LAN" -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT 2>/dev/null; do - $BIN_IPTABLES -t filter -D FORWARD -i "$NET_DEVICE_WAN" -o "$NET_DEVICE_LAN" -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT - done + # Remove established return rule + while iptables -t filter -C FORWARD -i "$NET_DEVICE_WAN" -o "$NET_DEVICE_LAN" -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT 2>/dev/null; do + iptables -t filter -D FORWARD -i "$NET_DEVICE_WAN" -o "$NET_DEVICE_LAN" -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT + done - # Delete chains - $BIN_IPTABLES -t filter -F "$INTERNET_ALLOWLIST_FILTER_CHAIN" 2>/dev/null || true - $BIN_IPTABLES -t filter -X "$INTERNET_ALLOWLIST_FILTER_CHAIN" 2>/dev/null || true - $BIN_IPTABLES -t filter -F "$INTERNET_ALLOWLIST_LOG_CHAIN" 2>/dev/null || true - $BIN_IPTABLES -t filter -X "$INTERNET_ALLOWLIST_LOG_CHAIN" 2>/dev/null || true - fi + # Delete chains + iptables -t filter -F "$INTERNET_ALLOWLIST_FILTER_CHAIN" 2>/dev/null || true + iptables -t filter -X "$INTERNET_ALLOWLIST_FILTER_CHAIN" 2>/dev/null || true + iptables -t filter -F "$INTERNET_ALLOWLIST_LOG_CHAIN" 2>/dev/null || true + iptables -t filter -X "$INTERNET_ALLOWLIST_LOG_CHAIN" 2>/dev/null || true - if [ -x "$BIN_IPSET" ]; then - $BIN_IPSET flush "$INTERNET_ALLOWLIST_IPSET_NAME" 2>/dev/null || true - $BIN_IPSET destroy "$INTERNET_ALLOWLIST_IPSET_NAME" 2>/dev/null || true - fi + ipset flush "$INTERNET_ALLOWLIST_IPSET_NAME" 2>/dev/null || true + ipset destroy "$INTERNET_ALLOWLIST_IPSET_NAME" 2>/dev/null || true } function enable_systemd_services() { - $BIN_SYSTEMCTL enable ytl-linux-digabi2-examnet.service >>"$PATH_DEBUG" 2>&1 + systemctl enable ytl-linux-digabi2-examnet.service >>"$PATH_DEBUG" 2>&1 exit_if_error $? $EXIT_CODE_CANNOT_CHANGE_SERVICE_STATE "Failed to enable ytl-linux-digabi2-examnet.service" - $BIN_SYSTEMCTL enable dnsmasq.service >>"$PATH_DEBUG" 2>&1 + systemctl enable dnsmasq.service >>"$PATH_DEBUG" 2>&1 exit_if_error $? $EXIT_CODE_CANNOT_CHANGE_SERVICE_STATE "Failed to enable dnsmasq.service" - $BIN_SYSTEMCTL enable ytl-linux-digabi2-examnet-discovery.service >>"$PATH_DEBUG" 2>&1 + systemctl enable ytl-linux-digabi2-examnet-discovery.service >>"$PATH_DEBUG" 2>&1 exit_if_error $? $EXIT_CODE_CANNOT_CHANGE_SERVICE_STATE "Failed to enable ytl-linux-digabi2-examnet-discovery.service" - $BIN_SYSTEMCTL enable ytl-linux-digabi2-examnet-discovery.timer >>"$PATH_DEBUG" 2>&1 + systemctl enable ytl-linux-digabi2-examnet-discovery.timer >>"$PATH_DEBUG" 2>&1 exit_if_error $? $EXIT_CODE_CANNOT_CHANGE_SERVICE_STATE "Failed to enable ytl-linux-digabi2-examnet-discovery.timer" } function disable_systemd_services() { - $BIN_SYSTEMCTL disable --now ytl-linux-digabi2-examnet.service >>"$PATH_DEBUG" 2>&1 + systemctl disable --now ytl-linux-digabi2-examnet.service >>"$PATH_DEBUG" 2>&1 exit_if_error $? $EXIT_CODE_CANNOT_CHANGE_SERVICE_STATE "Failed to disable ytl-linux-digabi2-examnet.service" - $BIN_SYSTEMCTL disable --now dnsmasq.service >>"$PATH_DEBUG" 2>&1 + systemctl disable --now dnsmasq.service >>"$PATH_DEBUG" 2>&1 exit_if_error $? $EXIT_CODE_CANNOT_CHANGE_SERVICE_STATE "Failed to disable dnsmasq.service" - $BIN_SYSTEMCTL disable --now ytl-linux-digabi2-examnet-discovery.timer >>"$PATH_DEBUG" 2>&1 + systemctl disable --now ytl-linux-digabi2-examnet-discovery.timer >>"$PATH_DEBUG" 2>&1 exit_if_error $? $EXIT_CODE_CANNOT_CHANGE_SERVICE_STATE "Failed to disable ytl-linux-digabi2-examnet-discovery.timer" - $BIN_SYSTEMCTL disable --now ytl-linux-digabi2-examnet-discovery.service >>"$PATH_DEBUG" 2>&1 + systemctl disable --now ytl-linux-digabi2-examnet-discovery.service >>"$PATH_DEBUG" 2>&1 exit_if_error $? $EXIT_CODE_CANNOT_CHANGE_SERVICE_STATE "Failed to disable ytl-linux-digabi2-examnet-discovery.service" } function remove_school_host_entries() { - $BIN_SED -i '/^# BEGIN SCHOOL DOMAIN ENTRIES$/,/^# END SCHOOL DOMAIN ENTRIES$/d' /etc/hosts + sed -i '/^# BEGIN SCHOOL DOMAIN ENTRIES$/,/^# END SCHOOL DOMAIN ENTRIES$/d' /etc/hosts } function remove_all_settings() { @@ -475,25 +451,25 @@ function remove_all_settings() { remove_internet_allowlist remove_school_host_entries - $BIN_NMCLI -f UUID,NAME connection show | $BIN_GREP -P -v "^UUID" | $BIN_TR -s ' ' | while read -r line ; do - connection_uuid=$(echo "$line" | $BIN_CUT -d ' ' -f 1) - connection_name=$(echo "$line" | $BIN_CUT -d ' ' -f 2) + nmcli -f UUID,NAME connection show | grep -P -v "^UUID" | tr -s ' ' | while read -r line ; do + connection_uuid=$(echo "$line" | cut -d ' ' -f 1) + connection_name=$(echo "$line" | cut -d ' ' -f 2) if [[ "$connection_name" =~ ^yo- ]]; then debug "Removing connection '$connection_name', uuid: $connection_uuid" - $BIN_NMCLI connection delete "$connection_uuid" + nmcli connection delete "$connection_uuid" exit_if_error $? $EXIT_CODE_CANNOT_REMOVE_CONNECTION "Failed to remove connection '$connection_name'" fi done } function restart_systemd_resolved() { - $BIN_SYSTEMCTL restart systemd-resolved + systemctl restart systemd-resolved exit_if_error $? $EXIT_CODE_CANNOT_RESTART_RESOLVED "Failed to restart systemd-resolved" } function restart_dnsmasq() { if [[ "$(systemctl is-enabled dnsmasq.service)" == "enabled" ]]; then - $BIN_SYSTEMCTL restart dnsmasq.service >>"$PATH_DEBUG" 2>&1 + systemctl restart dnsmasq.service >>"$PATH_DEBUG" 2>&1 exit_if_error $? $EXIT_CODE_CANNOT_RESTART_DNSMASQ "Failed to restart dnsmasq.service" fi } @@ -504,18 +480,18 @@ function configure_internet_allowlist() { exit_script $EXIT_CODE_CANNOT_START_DAEMON fi - if [ ! -x "$BIN_IPTABLES" ]; then - print_error "Internet allowlist: iptables not found ($BIN_IPTABLES)" + if ! command -v "iptables" >/dev/null 2>&1; then + print_error "Internet allowlist: iptables not found (iptables)" exit_script $EXIT_CODE_CANNOT_START_DAEMON fi - if [ ! -x "$BIN_IPSET" ]; then - print_error "Internet allowlist: ipset not found ($BIN_IPSET)" + if ! command -v "ipset" >/dev/null 2>&1; then + print_error "Internet allowlist: ipset not found (ipset)" exit_script $EXIT_CODE_CANNOT_START_DAEMON fi - if [ ! -x "$BIN_DIG" ]; then - print_error "Internet allowlist: dig not found ($BIN_DIG); install dnsutils" + if ! command -v "dig" >/dev/null 2>&1; then + print_error "Internet allowlist: dig not found (dig); install dnsutils" exit_script $EXIT_CODE_CANNOT_START_DAEMON fi @@ -563,7 +539,7 @@ function configure_internet_allowlist() { # 3) Ensure ipset exists (with timeout). # Note: -exist prevents failure if it already exists. - $BIN_IPSET create "$INTERNET_ALLOWLIST_IPSET_NAME" hash:ip timeout "$INTERNET_ALLOWLIST_IPSET_TIMEOUT_SECONDS" -exist + ipset create "$INTERNET_ALLOWLIST_IPSET_NAME" hash:ip timeout "$INTERNET_ALLOWLIST_IPSET_TIMEOUT_SECONDS" -exist # 4) Install iptables rules. # Strategy: @@ -574,44 +550,44 @@ function configure_internet_allowlist() { # (b) LAN->WAN jump to LOG and FILTER chain # - NAT POSTROUTING masquerade only when dst in ipset - $BIN_IPTABLES -t filter -N "$INTERNET_ALLOWLIST_LOG_CHAIN" 2>/dev/null || true - $BIN_IPTABLES -t filter -F "$INTERNET_ALLOWLIST_LOG_CHAIN" 2>/dev/null || true - $BIN_IPTABLES -t filter -A "$INTERNET_ALLOWLIST_LOG_CHAIN" \ + iptables -t filter -N "$INTERNET_ALLOWLIST_LOG_CHAIN" 2>/dev/null || true + iptables -t filter -F "$INTERNET_ALLOWLIST_LOG_CHAIN" 2>/dev/null || true + iptables -t filter -A "$INTERNET_ALLOWLIST_LOG_CHAIN" \ -m conntrack --ctstate NEW \ -m set --match-set "$INTERNET_ALLOWLIST_IPSET_NAME" dst \ -m limit --limit 10/second --limit-burst 30 \ -j LOG --log-prefix "YTL ALLOW NEW " --log-level 6 - $BIN_IPTABLES -t filter -A "$INTERNET_ALLOWLIST_LOG_CHAIN" -j RETURN + iptables -t filter -A "$INTERNET_ALLOWLIST_LOG_CHAIN" -j RETURN - $BIN_IPTABLES -t filter -N "$INTERNET_ALLOWLIST_FILTER_CHAIN" 2>/dev/null || true - $BIN_IPTABLES -t filter -F "$INTERNET_ALLOWLIST_FILTER_CHAIN" 2>/dev/null || true - $BIN_IPTABLES -t filter -A "$INTERNET_ALLOWLIST_FILTER_CHAIN" -m set --match-set "$INTERNET_ALLOWLIST_IPSET_NAME" dst -j ACCEPT - $BIN_IPTABLES -t filter -A "$INTERNET_ALLOWLIST_FILTER_CHAIN" -j DROP + iptables -t filter -N "$INTERNET_ALLOWLIST_FILTER_CHAIN" 2>/dev/null || true + iptables -t filter -F "$INTERNET_ALLOWLIST_FILTER_CHAIN" 2>/dev/null || true + iptables -t filter -A "$INTERNET_ALLOWLIST_FILTER_CHAIN" -m set --match-set "$INTERNET_ALLOWLIST_IPSET_NAME" dst -j ACCEPT + iptables -t filter -A "$INTERNET_ALLOWLIST_FILTER_CHAIN" -j DROP # Established/related return traffic from WAN->LAN - $BIN_IPTABLES -t filter -C FORWARD -i "$NET_DEVICE_WAN" -o "$NET_DEVICE_LAN" -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT 2>/dev/null || \ - $BIN_IPTABLES -t filter -A FORWARD -i "$NET_DEVICE_WAN" -o "$NET_DEVICE_LAN" -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT + iptables -t filter -C FORWARD -i "$NET_DEVICE_WAN" -o "$NET_DEVICE_LAN" -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT 2>/dev/null || \ + iptables -t filter -A FORWARD -i "$NET_DEVICE_WAN" -o "$NET_DEVICE_LAN" -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT # LAN->WAN logging (NEW allowed only) + enforcement - $BIN_IPTABLES -t filter -C FORWARD -i "$NET_DEVICE_LAN" -o "$NET_DEVICE_WAN" -j "$INTERNET_ALLOWLIST_LOG_CHAIN" 2>/dev/null || \ - $BIN_IPTABLES -t filter -I FORWARD 1 -i "$NET_DEVICE_LAN" -o "$NET_DEVICE_WAN" -j "$INTERNET_ALLOWLIST_LOG_CHAIN" + iptables -t filter -C FORWARD -i "$NET_DEVICE_LAN" -o "$NET_DEVICE_WAN" -j "$INTERNET_ALLOWLIST_LOG_CHAIN" 2>/dev/null || \ + iptables -t filter -I FORWARD 1 -i "$NET_DEVICE_LAN" -o "$NET_DEVICE_WAN" -j "$INTERNET_ALLOWLIST_LOG_CHAIN" - $BIN_IPTABLES -t filter -C FORWARD -i "$NET_DEVICE_LAN" -o "$NET_DEVICE_WAN" -j "$INTERNET_ALLOWLIST_FILTER_CHAIN" 2>/dev/null || \ - $BIN_IPTABLES -t filter -I FORWARD 2 -i "$NET_DEVICE_LAN" -o "$NET_DEVICE_WAN" -j "$INTERNET_ALLOWLIST_FILTER_CHAIN" + iptables -t filter -C FORWARD -i "$NET_DEVICE_LAN" -o "$NET_DEVICE_WAN" -j "$INTERNET_ALLOWLIST_FILTER_CHAIN" 2>/dev/null || \ + iptables -t filter -I FORWARD 2 -i "$NET_DEVICE_LAN" -o "$NET_DEVICE_WAN" -j "$INTERNET_ALLOWLIST_FILTER_CHAIN" # NAT only for allowed destinations - $BIN_IPTABLES -t nat -C POSTROUTING -o "$NET_DEVICE_WAN" -m set --match-set "$INTERNET_ALLOWLIST_IPSET_NAME" dst -j MASQUERADE 2>/dev/null || \ - $BIN_IPTABLES -t nat -A POSTROUTING -o "$NET_DEVICE_WAN" -m set --match-set "$INTERNET_ALLOWLIST_IPSET_NAME" dst -j MASQUERADE + iptables -t nat -C POSTROUTING -o "$NET_DEVICE_WAN" -m set --match-set "$INTERNET_ALLOWLIST_IPSET_NAME" dst -j MASQUERADE 2>/dev/null || \ + iptables -t nat -A POSTROUTING -o "$NET_DEVICE_WAN" -m set --match-set "$INTERNET_ALLOWLIST_IPSET_NAME" dst -j MASQUERADE # 5) Restart dnsmasq to pick up ipset=/server= rules, then proactively populate ipset via local dnsmasq. restart_dnsmasq for d in "${INTERNET_ALLOWLIST_DOMAINS[@]}"; do - $BIN_DIG +time=1 +tries=1 @127.0.0.1 "$d" A >/dev/null 2>&1 || true + dig +time=1 +tries=1 @127.0.0.1 "$d" A >/dev/null 2>&1 || true done } function restart_networkmanager() { - $BIN_SYSTEMCTL restart NetworkManager.service >>"$PATH_DEBUG" 2>&1 + systemctl restart NetworkManager.service >>"$PATH_DEBUG" 2>&1 exit_if_error $? $EXIT_CODE_CANNOT_RESTART_NM "Failed to restart NetworkManager.service" } @@ -622,24 +598,24 @@ function wait_for_network_online() { _TIMEOUT=30 fi - $BIN_NM_ONLINE -s -q --timeout="$_TIMEOUT" >>"$PATH_DEBUG" 2>&1 + nm-online -s -q --timeout="$_TIMEOUT" >>"$PATH_DEBUG" 2>&1 exit_if_error $? $EXIT_CODE_NETWORK_NOT_ONLINE "Network did not become online within timeout" } function restart_examnet_daemon() { if [[ "$(systemctl is-enabled ytl-linux-digabi2-examnet.service)" == "enabled" ]]; then - $BIN_SYSTEMCTL restart ytl-linux-digabi2-examnet.service >>"$PATH_DEBUG" 2>&1 + systemctl restart ytl-linux-digabi2-examnet.service >>"$PATH_DEBUG" 2>&1 exit_if_error $? $EXIT_CODE_CANNOT_CHANGE_SERVICE_STATE "Failed to restart ytl-linux-digabi2-examnet.service" fi if [[ "$(systemctl is-enabled ytl-linux-digabi2-examnet-discovery.timer)" == "enabled" ]]; then - $BIN_SYSTEMCTL restart ytl-linux-digabi2-examnet-discovery.timer >>"$PATH_DEBUG" 2>&1 + systemctl restart ytl-linux-digabi2-examnet-discovery.timer >>"$PATH_DEBUG" 2>&1 exit_if_error $? $EXIT_CODE_CANNOT_CHANGE_SERVICE_STATE "Failed to restart ytl-linux-digabi2-examnet-discovery.timer" fi if [[ "$(systemctl is-enabled ytl-linux-digabi2-examnet-discovery.service)" == "enabled" ]]; then - $BIN_SYSTEMCTL restart ytl-linux-digabi2-examnet-discovery.service >>"$PATH_DEBUG" 2>&1 + systemctl restart ytl-linux-digabi2-examnet-discovery.service >>"$PATH_DEBUG" 2>&1 exit_if_error $? $EXIT_CODE_CANNOT_CHANGE_SERVICE_STATE "Failed to restart ytl-linux-digabi2-examnet-discovery.service" fi } @@ -652,7 +628,7 @@ function restart_network_services() { } function restart_docker() { - $BIN_SYSTEMCTL restart docker + systemctl restart docker exit_if_error $? $EXIT_CODE_CANNOT_RESTART_DOCKER "Failed to restart docker" } @@ -670,12 +646,12 @@ function configure_static_dns_records() { # Parse out SANs from certificate; only include server subdomains (e.g. ktp1.1000.koe.abitti.net), not the "root subdomain" # (e.g. 1000.koe.abitti.net) since we cannot reasonably ascribe DNS names to these DOMAINS="$( - $BIN_OPENSSL x509 -in "$PATH_NAKSU2_CERT" -text -noout |\ - $BIN_GREP DNS: |\ - $BIN_XARGS |\ - $BIN_SED 's/DNS://g' |\ - $BIN_SED 's/, /\n/g' |\ - $BIN_GREP -E '(.+\..+){4}' + openssl x509 -in "$PATH_NAKSU2_CERT" -text -noout |\ + grep DNS: |\ + xargs |\ + sed 's/DNS://g' |\ + sed 's/, /\n/g' |\ + grep -E '(.+\..+){4}' )" debug "Configuring static DNS records for the following domains:" @@ -688,7 +664,7 @@ function configure_static_dns_records() { for DOMAIN in $DOMAINS; do debug "Generating DNS record for domain: $DOMAIN" - SERVER_NUMBER="$($BIN_ECHO "$DOMAIN" | $BIN_CUT -d '.' -f1 | $BIN_TR -dc '[:digit:]')" + SERVER_NUMBER="$(echo "$DOMAIN" | cut -d '.' -f1 | tr -dc '[:digit:]')" if [ "$SERVER_NUMBER" == "$_SERVER_NUMBER" ]; then THIS_SERVER_DOMAIN=$DOMAIN @@ -706,7 +682,7 @@ function configure_static_dns_records() { fi debug "Generated DNS records: ${DNS_RECORDS[*]}" - write_file $PATH_DNSMASQ_STATIC_DNS_CONF "$(IFS=$'\n'; $BIN_ECHO "${DNS_RECORDS[*]}")" + write_file $PATH_DNSMASQ_STATIC_DNS_CONF "$(IFS=$'\n'; echo "${DNS_RECORDS[*]}")" debug "Generated /etc/hosts entries: ${ETC_HOSTS[*]}" @@ -730,7 +706,7 @@ if [ -z "$PARAM_NET_DEVICE_WAN" ]; then exit_script $EXIT_CODE_NETWORK_DEVICE_NAME_MISSING_WAN fi -if [ ! "$(current_user_is_root)" ]; then +if [[ $* != *--not-root* && ! "$(current_user_is_root)" ]]; then print_error "You're not root" exit_script $EXIT_CODE_MUST_BE_ROOT fi @@ -756,23 +732,23 @@ if [[ $* == *--daemon* ]]; then fi CONFIG=$(cat < Date: Tue, 14 Apr 2026 21:37:15 +0300 Subject: [PATCH 7/9] Fix happy path tests after rebase --- .github/workflows/examnet.yml | 8 +++++--- .nvmrc | 1 + packages/ytl-linux-digabi2-examnet/test/examnet.test.ts | 9 +++++---- 3 files changed, 11 insertions(+), 7 deletions(-) create mode 100644 .nvmrc diff --git a/.github/workflows/examnet.yml b/.github/workflows/examnet.yml index d2063b42..9a1ba19b 100644 --- a/.github/workflows/examnet.yml +++ b/.github/workflows/examnet.yml @@ -19,10 +19,12 @@ jobs: contents: read steps: - uses: actions/checkout@v6 - - uses: digabi/workflows-common/actions/setup-node@main + - uses: actions/setup-node@v4 with: - npm-token: ${{ secrets.NPM_TOKEN }} + node-version-file: .nvmrc + cache: npm + cache-dependency-path: packages/ytl-linux-digabi2-examnet/package-lock.json - name: Install Node.js dependencies - run: npm ci --workspaces --iwr --no-audit --no-fund + run: npm ci --no-audit --no-fund - name: Run unit tests run: npm run test \ No newline at end of file diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..cabf43b5 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24 \ No newline at end of file diff --git a/packages/ytl-linux-digabi2-examnet/test/examnet.test.ts b/packages/ytl-linux-digabi2-examnet/test/examnet.test.ts index 3962102d..e16c8f7e 100644 --- a/packages/ytl-linux-digabi2-examnet/test/examnet.test.ts +++ b/packages/ytl-linux-digabi2-examnet/test/examnet.test.ts @@ -93,7 +93,7 @@ describe('examnet', async () => { { cmd: 'ytl-linux-digabi2-discovery', argv: [ - ` {\n "config": {\n "isProd": true,\n "ktpDomains": [],\n "dnsmasqConfigOutputFile": "${mockDnsmasqDir}/ytl-linux-ktp-aliases.conf",\n "ports": {"discovery": 26464}\n }\n }` + ` {\n "config": {\n "isProd": true,\n "ktpDomains": [],\n "dnsmasqConfigOutputFile": "${mockDnsmasqDir}/ytl-linux-ktp-aliases.conf",\n "ports": {"discovery": 26464},\n "dbPath": "${mockConfigDir}/discovery.db"\n }\n }` ] } ]) @@ -244,7 +244,7 @@ describe('examnet', async () => { { cmd: 'systemctl', argv: ['is-enabled', 'ytl-linux-digabi2-examnet.service'] }, { cmd: 'systemctl', argv: ['is-enabled', 'ytl-linux-digabi2-examnet-discovery.timer'] }, { cmd: 'systemctl', argv: ['is-enabled', 'ytl-linux-digabi2-examnet-discovery.service'] }, - { cmd: 'systemctl', argv: ['restart', 'docker'] } + { cmd: 'ytl-linux-digabi2-docker-configure.sh', argv: ['127.0.0.1', '192.168.10.1'] } ]) }) }) @@ -393,7 +393,7 @@ describe('examnet', async () => { { cmd: 'systemctl', argv: ['is-enabled', 'ytl-linux-digabi2-examnet.service'] }, { cmd: 'systemctl', argv: ['is-enabled', 'ytl-linux-digabi2-examnet-discovery.timer'] }, { cmd: 'systemctl', argv: ['is-enabled', 'ytl-linux-digabi2-examnet-discovery.service'] }, - { cmd: 'systemctl', argv: ['restart', 'docker'] } + { cmd: 'ytl-linux-digabi2-docker-configure.sh', argv: ['127.0.0.1', '192.168.10.1'] } ]) }) }) @@ -433,6 +433,7 @@ describe('examnet', async () => { await writeToTempDir(mockBinDir, 'stat', mockScript) await writeToTempDir(mockBinDir, 'ytl-linux-digabi2-bouncer', mockBouncer) await writeToTempDir(mockBinDir, 'ytl-linux-digabi2-discovery', mockScript) + await writeToTempDir(mockBinDir, 'ytl-linux-digabi2-docker-configure.sh', mockScript) await writeToTempDir(mockConfigDir, 'ncsi-hostnames', 'example.com') await writeToTempDir(mockConfigDir, 'server-own-ip', '127.0.0.1') @@ -457,7 +458,7 @@ describe('examnet', async () => { const callsLines = calls.split('\n') // console.log(`expecting ${expectedCalls.length} calls to external programs`) const callsArray = callsLines.map(line => { - // console.log(`parsing line ${line}`) + console.log(`parsing line ${line}`) return JSON.parse(line) }) assert.deepEqual(callsArray, expectedCalls) From 0a1d99075d10bcf99d5df5e0f2488f10b390a9e9 Mon Sep 17 00:00:00 2001 From: Mikko Reinikainen Date: Tue, 14 Apr 2026 22:29:35 +0300 Subject: [PATCH 8/9] Separate mock script to its own file --- .../test/examnet.test.ts | 53 +++++++------------ .../test/mock-script.ts | 30 +++++++++++ .../ytl-linux-digabi2-examnet | 1 - 3 files changed, 48 insertions(+), 36 deletions(-) create mode 100644 packages/ytl-linux-digabi2-examnet/test/mock-script.ts diff --git a/packages/ytl-linux-digabi2-examnet/test/examnet.test.ts b/packages/ytl-linux-digabi2-examnet/test/examnet.test.ts index e16c8f7e..77728e00 100644 --- a/packages/ytl-linux-digabi2-examnet/test/examnet.test.ts +++ b/packages/ytl-linux-digabi2-examnet/test/examnet.test.ts @@ -19,6 +19,7 @@ describe('examnet', async () => { beforeEach(async () => { await truncateCallsLog() ;({ + callsLog, mockBinDir, mockConfigDir, mockTemplatesDir, @@ -407,10 +408,21 @@ describe('examnet', async () => { } } + async function bashWrapMockScript(mockScriptTSPath: string) { + return `#!/usr/bin/env bash + set -euo pipefail + node --import tsx ${shellQuote(mockScriptTSPath)} "$0" "$@" + ` + } + + function shellQuote(str: string) { + return `'${str.replace(/'/g, `'\\''`)}'` + } + async function initTempDir() { const root = await mkdtemp(join(tmpdir(), 'just-test-')) - callsLog = join(root, 'calls.log') + const callsLog = join(root, 'calls.log') const mockBinDir = await makeTempDir(root, 'mock-bin-dir') const mockConfigDir = await makeTempDir(root, 'mock-config-dir') @@ -422,6 +434,8 @@ describe('examnet', async () => { const mockNaksu2CertsDir = await makeTempDir(mockNaksu2WorkDir, 'certs') await writeToTempDir(mockNaksu2CertsDir, 'domain.txt', 'kamreeri-kelvokas.koe.abitti.net') + const mockTsPath = join(process.cwd(), 'test', 'mock-script.ts') + const mockScript = await bashWrapMockScript(mockTsPath) await writeToTempDir(mockBinDir, 'echo', mockScript) await writeToTempDir(mockBinDir, 'nmcli', mockScript) await writeToTempDir(mockBinDir, 'systemctl', mockScript) @@ -431,7 +445,7 @@ describe('examnet', async () => { await writeToTempDir(mockBinDir, 'ipset', mockScript) await writeToTempDir(mockBinDir, 'dig', mockScript) await writeToTempDir(mockBinDir, 'stat', mockScript) - await writeToTempDir(mockBinDir, 'ytl-linux-digabi2-bouncer', mockBouncer) + await writeToTempDir(mockBinDir, 'ytl-linux-digabi2-bouncer', mockScript) await writeToTempDir(mockBinDir, 'ytl-linux-digabi2-discovery', mockScript) await writeToTempDir(mockBinDir, 'ytl-linux-digabi2-docker-configure.sh', mockScript) @@ -442,6 +456,7 @@ describe('examnet', async () => { await writeToTempDir(mockTemplatesDir, 'dnsmasq.conf.template', 'foobar') await writeToTempDir(mockDnsmasqDir, 'ytl-linux-static-dns-records.conf', 'xyzzy') return { + callsLog, mockBinDir, mockConfigDir, mockTemplatesDir, @@ -458,45 +473,13 @@ describe('examnet', async () => { const callsLines = calls.split('\n') // console.log(`expecting ${expectedCalls.length} calls to external programs`) const callsArray = callsLines.map(line => { - console.log(`parsing line ${line}`) + // console.log(`parsing line ${line}`) return JSON.parse(line) }) assert.deepEqual(callsArray, expectedCalls) } }) -const mockScript = `#!/usr/bin/env bash -node -e ' - const fs = require("node:fs"); - const path = require("node:path"); - const entry = { - cmd: path.basename(process.argv[1]), - argv: process.argv.slice(2), - }; - // console.error(process.argv, "writing to", process.env.CALLS_LOG) - if (process.argv[8] === "eth0") { - console.log("127.0.0.1") - } else { - console.log("") - } - fs.appendFileSync(process.env.CALLS_LOG, JSON.stringify(entry) + "\\n"); -' "$0" "$@" -exit 0 -` - -const mockBouncer = `#!/usr/bin/env bash -node -e ' - const fs = require("node:fs"); - fs.appendFileSync(process.env.CALLS_LOG, JSON.stringify({ - cmd: "ytl-linux-digabi2-bouncer", - argv: process.argv.slice(1), - }) + "\\n"); -' "$@" -while true; do - sleep 1 -done -` - async function writeToTempDir(dir: string, name: string, script: string) { const file = join(dir, name) await writeFile(file, script, 'utf8') diff --git a/packages/ytl-linux-digabi2-examnet/test/mock-script.ts b/packages/ytl-linux-digabi2-examnet/test/mock-script.ts new file mode 100644 index 00000000..863e5e56 --- /dev/null +++ b/packages/ytl-linux-digabi2-examnet/test/mock-script.ts @@ -0,0 +1,30 @@ +// +// This script emulates the behavior of external programs called by the examnet script +// + +import fs from 'node:fs' +import path from 'node:path' + +async function main() { + // process.argv[2] is the name of the program being called + const cmd = path.basename(process.argv[2]) + const entry = { + cmd, + argv: process.argv.slice(3) + } + fs.appendFileSync(process.env.CALLS_LOG, JSON.stringify(entry) + '\n') + switch (cmd) { + case 'ip': + console.log(process.argv[9] === 'eth0' ? '127.0.0.1' : '') + break + case 'stat': + console.log('nobody:nobody') + break + case 'ytl-linux-digabi2-bouncer': + // simulate a daemon that stays running by waiting for two minutes + await new Promise(resolve => setTimeout(resolve, 120_000)) + break + } +} + +main() diff --git a/packages/ytl-linux-digabi2-examnet/ytl-linux-digabi2-examnet b/packages/ytl-linux-digabi2-examnet/ytl-linux-digabi2-examnet index 41092ee0..4aef6648 100755 --- a/packages/ytl-linux-digabi2-examnet/ytl-linux-digabi2-examnet +++ b/packages/ytl-linux-digabi2-examnet/ytl-linux-digabi2-examnet @@ -171,7 +171,6 @@ function network_device_exists() { function get_ipv4_address() { _DEVICE=$1 _IP=$(ip -oneline -4 addr show scope global "$_DEVICE" | tr -s ' ' | tr '/' ' ' | cut -f 4 -d ' ') - # echo called ip command with $PATH >&2 if [[ ! "$_IP" =~ "does not exist" ]]; then echo "$_IP" fi From e8a3ea03f5dcaf516e1e1bd6d2584dc48d09b1e9 Mon Sep 17 00:00:00 2001 From: Mikko Reinikainen Date: Wed, 15 Apr 2026 09:30:19 +0300 Subject: [PATCH 9/9] Make asserting called commands more readable --- .../test/examnet.test.ts | 307 ++++++++++-------- 1 file changed, 170 insertions(+), 137 deletions(-) diff --git a/packages/ytl-linux-digabi2-examnet/test/examnet.test.ts b/packages/ytl-linux-digabi2-examnet/test/examnet.test.ts index 77728e00..629acfc0 100644 --- a/packages/ytl-linux-digabi2-examnet/test/examnet.test.ts +++ b/packages/ytl-linux-digabi2-examnet/test/examnet.test.ts @@ -60,16 +60,7 @@ describe('examnet', async () => { // do not await runExamnet, as it stays running in daemon mode const subprocess = runExamnet('eth0', 'eth1', 1, '--daemon') await waitForLogEntry(callsLog, '"ytl-linux-digabi2-bouncer"') - await assertCalls([ - { cmd: 'stat', argv: ['-c', '%U:%G', mockNaksu2WorkDir] }, - { cmd: 'ip', argv: ['-oneline', '-4', 'addr', 'show', 'scope', 'global', 'eth0'] }, - { - cmd: 'ytl-linux-digabi2-bouncer', - argv: [ - ` { "config": { "friendlyName": "foobar", "canonicalHostname": "kamreeri-kelvokas.koe.abitti.net", "ncsiHostnames": ["example.com"], "searchDomain": "internal", "serverOwnIp": "127.0.0.1", "ports": {"discovery": 26464, "bouncer": 80} }, "secrets": {"cert":"${mockNaksu2CertsDir}/fullchain.pem","key":"${mockNaksu2CertsDir}/key.pem"} }` - ] - } - ]) + await assertCalls([callStat(mockNaksu2WorkDir), callIpAddrShow('eth0'), callBouncer(mockNaksu2CertsDir)]) await killSubprocess(subprocess) }) }) @@ -78,10 +69,10 @@ describe('examnet', async () => { test('runs when correct parameters are given', async () => { await runExamnet('eth0', 'eth1', 1, '--restart-daemon') await assertCalls([ - { cmd: 'stat', argv: ['-c', '%U:%G', mockNaksu2WorkDir] }, - { cmd: 'systemctl', argv: ['is-enabled', 'ytl-linux-digabi2-examnet.service'] }, - { cmd: 'systemctl', argv: ['is-enabled', 'ytl-linux-digabi2-examnet-discovery.timer'] }, - { cmd: 'systemctl', argv: ['is-enabled', 'ytl-linux-digabi2-examnet-discovery.service'] } + callStat(mockNaksu2WorkDir), + callSystemctl('is-enabled', 'ytl-linux-digabi2-examnet.service'), + callSystemctl('is-enabled', 'ytl-linux-digabi2-examnet-discovery.timer'), + callSystemctl('is-enabled', 'ytl-linux-digabi2-examnet-discovery.service') ]) }) }) @@ -89,15 +80,7 @@ describe('examnet', async () => { describe('discovery', () => { test('runs when correct parameters are given', async () => { await runExamnet('eth0', 'eth1', 1, '--discovery') - await assertCalls([ - { cmd: 'stat', argv: ['-c', '%U:%G', mockNaksu2WorkDir] }, - { - cmd: 'ytl-linux-digabi2-discovery', - argv: [ - ` {\n "config": {\n "isProd": true,\n "ktpDomains": [],\n "dnsmasqConfigOutputFile": "${mockDnsmasqDir}/ytl-linux-ktp-aliases.conf",\n "ports": {"discovery": 26464},\n "dbPath": "${mockConfigDir}/discovery.db"\n }\n }` - ] - } - ]) + await assertCalls([callStat(mockNaksu2WorkDir), callDiscovery(mockDnsmasqDir, mockConfigDir)]) }) }) @@ -105,38 +88,20 @@ describe('examnet', async () => { test('runs when correct parameters are given', async () => { await runExamnet('eth0', 'eth1', 1) await assertCalls([ - { cmd: 'stat', argv: ['-c', '%U:%G', mockNaksu2WorkDir] }, - { cmd: 'ip', argv: ['link', 'show', 'eth0'] }, - { cmd: 'ip', argv: ['link', 'show', 'eth1'] }, - { cmd: 'ip', argv: ['-oneline', '-4', 'addr', 'show', 'scope', 'global', 'eth0'] }, - { cmd: 'ip', argv: ['-oneline', '-4', 'addr', 'show', 'scope', 'global', 'eth1'] }, - { cmd: 'nmcli', argv: ['connection', 'delete', 'yo-eth1'] }, - { - cmd: 'nmcli', - argv: [ - 'connection', - 'add', - 'type', - 'ethernet', - 'ifname', - 'eth1', - 'con-name', - 'yo-eth1', - 'ip4', - '192.168.10.1/16', - 'autoconnect', - 'yes', - 'save', - 'yes' - ] - }, - { cmd: 'nmcli', argv: ['connection', 'modify', 'yo-eth1', 'ipv6.method', 'disabled'] }, - { cmd: 'nmcli', argv: ['connection', 'up', 'yo-eth1'] }, - { cmd: 'systemctl', argv: ['restart', 'NetworkManager.service'] }, - { cmd: 'nm-online', argv: ['-s', '-q', '--timeout=30'] }, - { cmd: 'ipset', argv: ['create', 'ytl_internet_allowlist', 'hash:ip', 'timeout', '3600', '-exist'] }, - { cmd: 'iptables', argv: ['-t', 'filter', '-N', 'YTL_LAN_WAN_IPSET_LOG'] }, - { cmd: 'iptables', argv: ['-t', 'filter', '-F', 'YTL_LAN_WAN_IPSET_LOG'] }, + callStat(mockNaksu2WorkDir), + callIpLinkShow('eth0'), + callIpLinkShow('eth1'), + callIpAddrShow('eth0'), + callIpAddrShow('eth1'), + callNmicliConnectionDelete('yo-eth1'), + callNmicliConnectionAdd('yo-eth1', '192.168.10.1/16'), + callNmicliConnectionModify('yo-eth1'), + callNmicliConnectionUp('yo-eth1'), + callSystemctl('restart', 'NetworkManager.service'), + callNmonline(), + callIpsetCreate('ytl_internet_allowlist'), + callIptablesNewChain('YTL_LAN_WAN_IPSET_LOG'), + callIptablesFlushChain('YTL_LAN_WAN_IPSET_LOG'), { cmd: 'iptables', argv: [ @@ -167,9 +132,9 @@ describe('examnet', async () => { '6' ] }, - { cmd: 'iptables', argv: ['-t', 'filter', '-A', 'YTL_LAN_WAN_IPSET_LOG', '-j', 'RETURN'] }, - { cmd: 'iptables', argv: ['-t', 'filter', '-N', 'YTL_LAN_WAN_IPSET'] }, - { cmd: 'iptables', argv: ['-t', 'filter', '-F', 'YTL_LAN_WAN_IPSET'] }, + callIptablesAppendRule('YTL_LAN_WAN_IPSET_LOG', 'RETURN'), + callIptablesNewChain('YTL_LAN_WAN_IPSET'), + callIptablesFlushChain('YTL_LAN_WAN_IPSET'), { cmd: 'iptables', argv: [ @@ -186,7 +151,7 @@ describe('examnet', async () => { 'ACCEPT' ] }, - { cmd: 'iptables', argv: ['-t', 'filter', '-A', 'YTL_LAN_WAN_IPSET', '-j', 'DROP'] }, + callIptablesAppendRule('YTL_LAN_WAN_IPSET', 'DROP'), { cmd: 'iptables', argv: [ @@ -206,14 +171,8 @@ describe('examnet', async () => { 'ACCEPT' ] }, - { - cmd: 'iptables', - argv: ['-t', 'filter', '-C', 'FORWARD', '-i', 'eth1', '-o', 'eth0', '-j', 'YTL_LAN_WAN_IPSET_LOG'] - }, - { - cmd: 'iptables', - argv: ['-t', 'filter', '-C', 'FORWARD', '-i', 'eth1', '-o', 'eth0', '-j', 'YTL_LAN_WAN_IPSET'] - }, + callIptablesCheckChain('FORWARD', 'eth1', 'YTL_LAN_WAN_IPSET_LOG'), + callIptablesCheckChain('FORWARD', 'eth1', 'YTL_LAN_WAN_IPSET'), { cmd: 'iptables', argv: [ @@ -232,19 +191,19 @@ describe('examnet', async () => { 'MASQUERADE' ] }, - { cmd: 'systemctl', argv: ['is-enabled', 'dnsmasq.service'] }, - { cmd: 'dig', argv: ['+time=1', '+tries=1', '@127.0.0.1', 'endpoint.security.microsoft.com', 'A'] }, - { cmd: 'dig', argv: ['+time=1', '+tries=1', '@127.0.0.1', 'smartscreen-prod.microsoft.com', 'A'] }, - { cmd: 'dig', argv: ['+time=1', '+tries=1', '@127.0.0.1', 'smartscreen.microsoft.com', 'A'] }, - { cmd: 'systemctl', argv: ['enable', 'ytl-linux-digabi2-examnet.service'] }, - { cmd: 'systemctl', argv: ['enable', 'dnsmasq.service'] }, - { cmd: 'systemctl', argv: ['enable', 'ytl-linux-digabi2-examnet-discovery.service'] }, - { cmd: 'systemctl', argv: ['enable', 'ytl-linux-digabi2-examnet-discovery.timer'] }, - { cmd: 'systemctl', argv: ['restart', 'systemd-resolved'] }, - { cmd: 'systemctl', argv: ['is-enabled', 'dnsmasq.service'] }, - { cmd: 'systemctl', argv: ['is-enabled', 'ytl-linux-digabi2-examnet.service'] }, - { cmd: 'systemctl', argv: ['is-enabled', 'ytl-linux-digabi2-examnet-discovery.timer'] }, - { cmd: 'systemctl', argv: ['is-enabled', 'ytl-linux-digabi2-examnet-discovery.service'] }, + callSystemctl('is-enabled', 'dnsmasq.service'), + callDig('endpoint.security.microsoft.com'), + callDig('smartscreen-prod.microsoft.com'), + callDig('smartscreen.microsoft.com'), + callSystemctl('enable', 'ytl-linux-digabi2-examnet.service'), + callSystemctl('enable', 'dnsmasq.service'), + callSystemctl('enable', 'ytl-linux-digabi2-examnet-discovery.service'), + callSystemctl('enable', 'ytl-linux-digabi2-examnet-discovery.timer'), + callSystemctl('restart', 'systemd-resolved'), + callSystemctl('is-enabled', 'dnsmasq.service'), + callSystemctl('is-enabled', 'ytl-linux-digabi2-examnet.service'), + callSystemctl('is-enabled', 'ytl-linux-digabi2-examnet-discovery.timer'), + callSystemctl('is-enabled', 'ytl-linux-digabi2-examnet-discovery.service'), { cmd: 'ytl-linux-digabi2-docker-configure.sh', argv: ['127.0.0.1', '192.168.10.1'] } ]) }) @@ -254,38 +213,20 @@ describe('examnet', async () => { test('runs when correct parameters are given', async () => { await runExamnet('eth0', 'eth1', 1) await assertCalls([ - { cmd: 'stat', argv: ['-c', '%U:%G', mockNaksu2WorkDir] }, - { cmd: 'ip', argv: ['link', 'show', 'eth0'] }, - { cmd: 'ip', argv: ['link', 'show', 'eth1'] }, - { cmd: 'ip', argv: ['-oneline', '-4', 'addr', 'show', 'scope', 'global', 'eth0'] }, - { cmd: 'ip', argv: ['-oneline', '-4', 'addr', 'show', 'scope', 'global', 'eth1'] }, - { cmd: 'nmcli', argv: ['connection', 'delete', 'yo-eth1'] }, - { - cmd: 'nmcli', - argv: [ - 'connection', - 'add', - 'type', - 'ethernet', - 'ifname', - 'eth1', - 'con-name', - 'yo-eth1', - 'ip4', - '192.168.10.1/16', - 'autoconnect', - 'yes', - 'save', - 'yes' - ] - }, - { cmd: 'nmcli', argv: ['connection', 'modify', 'yo-eth1', 'ipv6.method', 'disabled'] }, - { cmd: 'nmcli', argv: ['connection', 'up', 'yo-eth1'] }, - { cmd: 'systemctl', argv: ['restart', 'NetworkManager.service'] }, - { cmd: 'nm-online', argv: ['-s', '-q', '--timeout=30'] }, - { cmd: 'ipset', argv: ['create', 'ytl_internet_allowlist', 'hash:ip', 'timeout', '3600', '-exist'] }, - { cmd: 'iptables', argv: ['-t', 'filter', '-N', 'YTL_LAN_WAN_IPSET_LOG'] }, - { cmd: 'iptables', argv: ['-t', 'filter', '-F', 'YTL_LAN_WAN_IPSET_LOG'] }, + callStat(mockNaksu2WorkDir), + callIpLinkShow('eth0'), + callIpLinkShow('eth1'), + callIpAddrShow('eth0'), + callIpAddrShow('eth1'), + callNmicliConnectionDelete('yo-eth1'), + callNmicliConnectionAdd('yo-eth1', '192.168.10.1/16'), + callNmicliConnectionModify('yo-eth1'), + callNmicliConnectionUp('yo-eth1'), + callSystemctl('restart', 'NetworkManager.service'), + callNmonline(), + callIpsetCreate('ytl_internet_allowlist'), + callIptablesNewChain('YTL_LAN_WAN_IPSET_LOG'), + callIptablesFlushChain('YTL_LAN_WAN_IPSET_LOG'), { cmd: 'iptables', argv: [ @@ -316,9 +257,9 @@ describe('examnet', async () => { '6' ] }, - { cmd: 'iptables', argv: ['-t', 'filter', '-A', 'YTL_LAN_WAN_IPSET_LOG', '-j', 'RETURN'] }, - { cmd: 'iptables', argv: ['-t', 'filter', '-N', 'YTL_LAN_WAN_IPSET'] }, - { cmd: 'iptables', argv: ['-t', 'filter', '-F', 'YTL_LAN_WAN_IPSET'] }, + callIptablesAppendRule('YTL_LAN_WAN_IPSET_LOG', 'RETURN'), + callIptablesNewChain('YTL_LAN_WAN_IPSET'), + callIptablesFlushChain('YTL_LAN_WAN_IPSET'), { cmd: 'iptables', argv: [ @@ -335,7 +276,7 @@ describe('examnet', async () => { 'ACCEPT' ] }, - { cmd: 'iptables', argv: ['-t', 'filter', '-A', 'YTL_LAN_WAN_IPSET', '-j', 'DROP'] }, + callIptablesAppendRule('YTL_LAN_WAN_IPSET', 'DROP'), { cmd: 'iptables', argv: [ @@ -355,14 +296,8 @@ describe('examnet', async () => { 'ACCEPT' ] }, - { - cmd: 'iptables', - argv: ['-t', 'filter', '-C', 'FORWARD', '-i', 'eth1', '-o', 'eth0', '-j', 'YTL_LAN_WAN_IPSET_LOG'] - }, - { - cmd: 'iptables', - argv: ['-t', 'filter', '-C', 'FORWARD', '-i', 'eth1', '-o', 'eth0', '-j', 'YTL_LAN_WAN_IPSET'] - }, + callIptablesCheckChain('FORWARD', 'eth1', 'YTL_LAN_WAN_IPSET_LOG'), + callIptablesCheckChain('FORWARD', 'eth1', 'YTL_LAN_WAN_IPSET'), { cmd: 'iptables', argv: [ @@ -381,19 +316,19 @@ describe('examnet', async () => { 'MASQUERADE' ] }, - { cmd: 'systemctl', argv: ['is-enabled', 'dnsmasq.service'] }, - { cmd: 'dig', argv: ['+time=1', '+tries=1', '@127.0.0.1', 'endpoint.security.microsoft.com', 'A'] }, - { cmd: 'dig', argv: ['+time=1', '+tries=1', '@127.0.0.1', 'smartscreen-prod.microsoft.com', 'A'] }, - { cmd: 'dig', argv: ['+time=1', '+tries=1', '@127.0.0.1', 'smartscreen.microsoft.com', 'A'] }, - { cmd: 'systemctl', argv: ['enable', 'ytl-linux-digabi2-examnet.service'] }, - { cmd: 'systemctl', argv: ['enable', 'dnsmasq.service'] }, - { cmd: 'systemctl', argv: ['enable', 'ytl-linux-digabi2-examnet-discovery.service'] }, - { cmd: 'systemctl', argv: ['enable', 'ytl-linux-digabi2-examnet-discovery.timer'] }, - { cmd: 'systemctl', argv: ['restart', 'systemd-resolved'] }, - { cmd: 'systemctl', argv: ['is-enabled', 'dnsmasq.service'] }, - { cmd: 'systemctl', argv: ['is-enabled', 'ytl-linux-digabi2-examnet.service'] }, - { cmd: 'systemctl', argv: ['is-enabled', 'ytl-linux-digabi2-examnet-discovery.timer'] }, - { cmd: 'systemctl', argv: ['is-enabled', 'ytl-linux-digabi2-examnet-discovery.service'] }, + callSystemctl('is-enabled', 'dnsmasq.service'), + callDig('endpoint.security.microsoft.com'), + callDig('smartscreen-prod.microsoft.com'), + callDig('smartscreen.microsoft.com'), + callSystemctl('enable', 'ytl-linux-digabi2-examnet.service'), + callSystemctl('enable', 'dnsmasq.service'), + callSystemctl('enable', 'ytl-linux-digabi2-examnet-discovery.service'), + callSystemctl('enable', 'ytl-linux-digabi2-examnet-discovery.timer'), + callSystemctl('restart', 'systemd-resolved'), + callSystemctl('is-enabled', 'dnsmasq.service'), + callSystemctl('is-enabled', 'ytl-linux-digabi2-examnet.service'), + callSystemctl('is-enabled', 'ytl-linux-digabi2-examnet-discovery.timer'), + callSystemctl('is-enabled', 'ytl-linux-digabi2-examnet-discovery.service'), { cmd: 'ytl-linux-digabi2-docker-configure.sh', argv: ['127.0.0.1', '192.168.10.1'] } ]) }) @@ -523,3 +458,101 @@ async function killSubprocess(subprocess: any) { if (killTimer) clearTimeout(killTimer) } } + +function callStat(mockNaksu2WorkDir) { + return { cmd: 'stat', argv: ['-c', '%U:%G', mockNaksu2WorkDir] } +} + +function callBouncer(mockNaksu2CertsDir: string) { + return { + cmd: 'ytl-linux-digabi2-bouncer', + argv: [ + ` { "config": { "friendlyName": "foobar", "canonicalHostname": "kamreeri-kelvokas.koe.abitti.net", "ncsiHostnames": ["example.com"], "searchDomain": "internal", "serverOwnIp": "127.0.0.1", "ports": {"discovery": 26464, "bouncer": 80} }, "secrets": {"cert":"${mockNaksu2CertsDir}/fullchain.pem","key":"${mockNaksu2CertsDir}/key.pem"} }` + ] + } +} + +function callDiscovery(mockDnsmasqDir: string, mockConfigDir: string) { + return { + cmd: 'ytl-linux-digabi2-discovery', + argv: [ + ` {\n "config": {\n "isProd": true,\n "ktpDomains": [],\n "dnsmasqConfigOutputFile": "${mockDnsmasqDir}/ytl-linux-ktp-aliases.conf",\n "ports": {"discovery": 26464},\n "dbPath": "${mockConfigDir}/discovery.db"\n }\n }` + ] + } +} + +function callIpLinkShow(networkDevice: string) { + return { cmd: 'ip', argv: ['link', 'show', networkDevice] } +} +function callIpAddrShow(networkDevice: string) { + return { cmd: 'ip', argv: ['-oneline', '-4', 'addr', 'show', 'scope', 'global', networkDevice] } +} + +function callSystemctl(cmd: string, service: string) { + return { cmd: 'systemctl', argv: [cmd, service] } +} + +function callDig(host: string) { + return { cmd: 'dig', argv: ['+time=1', '+tries=1', '@127.0.0.1', host, 'A'] } +} + +function callNmicliConnectionDelete(connectionName: string) { + return { cmd: 'nmcli', argv: ['connection', 'delete', connectionName] } +} + +function callNmicliConnectionModify(connectionName: string) { + return { cmd: 'nmcli', argv: ['connection', 'modify', connectionName, 'ipv6.method', 'disabled'] } +} + +function callNmicliConnectionAdd(connectionName: string, ipRange: string) { + return { + cmd: 'nmcli', + argv: [ + 'connection', + 'add', + 'type', + 'ethernet', + 'ifname', + 'eth1', + 'con-name', + connectionName, + 'ip4', + ipRange, + 'autoconnect', + 'yes', + 'save', + 'yes' + ] + } +} + +function callNmicliConnectionUp(deviceName: string) { + return { cmd: 'nmcli', argv: ['connection', 'up', deviceName] } +} + +function callNmonline() { + return { cmd: 'nm-online', argv: ['-s', '-q', '--timeout=30'] } +} + +function callIpsetCreate(listName: string) { + return { cmd: 'ipset', argv: ['create', listName, 'hash:ip', 'timeout', '3600', '-exist'] } +} + +function callIptablesNewChain(chainName: string) { + return { cmd: 'iptables', argv: ['-t', 'filter', '-N', chainName] } +} + +function callIptablesFlushChain(chainName: string) { + return { cmd: 'iptables', argv: ['-t', 'filter', '-F', chainName] } +} + +function callIptablesCheckChain(chainName: string, networkDevice: string, jumpTarget: string) { + return { + cmd: 'iptables', + argv: ['-t', 'filter', '-C', chainName, '-i', networkDevice, '-o', 'eth0', '-j', jumpTarget] + } +} + +function callIptablesAppendRule(chainName: string, jumpTarget: string) { + return { cmd: 'iptables', argv: ['-t', 'filter', '-A', chainName, '-j', jumpTarget] } +}