From 1f80268297d13ac80fc08dc206a2c6cb8f5842f4 Mon Sep 17 00:00:00 2001 From: Linus Willner Date: Thu, 12 Mar 2026 16:38:16 +0200 Subject: [PATCH 1/4] Fix VM disk path getting mangled --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index f3e7021..81dd487 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ INSTALL_IMAGE = ytl-install-24.iso IMAGE = ubuntu.iso VM_NAME ?= YTL Linux VM_DISK_SIZE ?= 51200 -VM_DISK_PATH ?= $(shell echo "${HOME}/VirtualBox VMs/$(VM_NAME)" | sed -e 's/ /\\ /g') +VM_DISK_PATH ?= $(shell echo "${HOME}/VirtualBox VMs/$(VM_NAME)") VM_MEMORY_SIZE ?= 4096 VM_CPUS ?= 2 VM_VIDEO_MEMORY_SIZE ?= 16 From bc54103977a121b88247e33fffcc74f7f3d9834f Mon Sep 17 00:00:00 2001 From: Linus Willner Date: Fri, 13 Mar 2026 12:50:36 +0200 Subject: [PATCH 2/4] Enable dnsmasq query logging Should've turned this on long ago --- .../ytl-linux-digabi2-examnet/templates/dnsmasq.conf.template | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/ytl-linux-digabi2-examnet/templates/dnsmasq.conf.template b/packages/ytl-linux-digabi2-examnet/templates/dnsmasq.conf.template index 7c14ab5..66592db 100644 --- a/packages/ytl-linux-digabi2-examnet/templates/dnsmasq.conf.template +++ b/packages/ytl-linux-digabi2-examnet/templates/dnsmasq.conf.template @@ -1,3 +1,6 @@ +# Enable full query logging to assist debugging +log-queries=extra + # Bind to LAN device and Docker bridge interfaces interface=${NET_DEVICE_LAN} interface=docker0 From 5e2a1beaf364e090236359f2602ba20d635eb1b7 Mon Sep 17 00:00:00 2001 From: Linus Willner Date: Thu, 12 Mar 2026 16:33:41 +0200 Subject: [PATCH 3/4] Use ktp as exam network search domain This is to prevent unintentional overlap with other KTP addresses --- .../templates/dnsmasq.conf.template | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/ytl-linux-digabi2-examnet/templates/dnsmasq.conf.template b/packages/ytl-linux-digabi2-examnet/templates/dnsmasq.conf.template index 66592db..8f2fbbe 100644 --- a/packages/ytl-linux-digabi2-examnet/templates/dnsmasq.conf.template +++ b/packages/ytl-linux-digabi2-examnet/templates/dnsmasq.conf.template @@ -6,9 +6,14 @@ interface=${NET_DEVICE_LAN} interface=docker0 interface=ytl0 -# Tell clients to use this server as DHCP and DNS +# Set search domain to ktp to support server aliases +# This is deliberately different from koe.abitti.net to avoid confusion with other DNS names +domain=ktp + +# Tell clients to use this server as DHCP and DNS, also configure its search domain dhcp-range=${DHCP_RANGE_START},${DHCP_RANGE_END},255.255.0.0,1h dhcp-option=6,${SERVER_OWN_IP} +dhcp-option=option:domain-name,ktp # Redirect requests for Windows Network Connection Status Indicator (NCSI) to our local NCSI spoofer (digabi2-examnet-bouncer) host-record=${NCSI_HOSTNAMES_LIST},${SERVER_OWN_IP} @@ -22,11 +27,6 @@ resolv-file=/etc/resolv.conf # need DNS to tell student computers where to go server=/koe.abitti.net/# -# Don't forward friendly name (.local) queries - return NXDOMAIN so Windows falls back to mDNS -# Windows sends unicast DNS queries for .local domains (in addition to mDNS), which would -# otherwise hit the null-route below and return 0.0.0.0. -server=/local/ - # 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 From 29d30e5401ca31198415e7cc90d880f2371ed144 Mon Sep 17 00:00:00 2001 From: Henna Haahti <84767854+aateekoohenna@users.noreply.github.com> Date: Fri, 13 Mar 2026 16:33:32 +0200 Subject: [PATCH 4/4] bouncer 2.0 --- packages/ytl-linux-digabi2-examnet/Makefile | 15 +-- .../digabi2-examnet-bouncer/.gitignore | 4 - .../digabi2-examnet-bouncer/.prettierrc | 7 ++ .../digabi2-examnet-bouncer/Dockerfile.build | 4 - .../digabi2-examnet-bouncer/deno.json | 10 ++ .../digabi2-examnet-bouncer/deno.lock | 38 ++++++++ .../digabi2-examnet-bouncer | 93 ------------------- .../digabi2-examnet-bouncer/pyproject.toml | 28 ------ .../digabi2-examnet-bouncer/src/bouncer.ts | 66 +++++++++++++ .../digabi2-examnet-bouncer/src/config.ts | 43 +++++++++ .../src/digabi2_examnet_bouncer/__init__.py | 1 - .../src/digabi2_examnet_bouncer/announcer.py | 51 ---------- .../src/digabi2_examnet_bouncer/bouncer.py | 79 ---------------- .../src/digabi2_examnet_bouncer/config.py | 40 -------- .../digabi2-examnet-bouncer/src/discovery.ts | 17 ++++ .../digabi2-examnet-bouncer/src/logging.ts | 29 ++++++ .../digabi2-examnet-bouncer/src/main.ts | 50 ++++++++++ .../ytl-linux-digabi2-examnet | 10 +- 18 files changed, 266 insertions(+), 319 deletions(-) delete mode 100644 packages/ytl-linux-digabi2-examnet/digabi2-examnet-bouncer/.gitignore create mode 100644 packages/ytl-linux-digabi2-examnet/digabi2-examnet-bouncer/.prettierrc delete mode 100644 packages/ytl-linux-digabi2-examnet/digabi2-examnet-bouncer/Dockerfile.build create mode 100644 packages/ytl-linux-digabi2-examnet/digabi2-examnet-bouncer/deno.json create mode 100644 packages/ytl-linux-digabi2-examnet/digabi2-examnet-bouncer/deno.lock delete mode 100755 packages/ytl-linux-digabi2-examnet/digabi2-examnet-bouncer/digabi2-examnet-bouncer delete mode 100644 packages/ytl-linux-digabi2-examnet/digabi2-examnet-bouncer/pyproject.toml create mode 100644 packages/ytl-linux-digabi2-examnet/digabi2-examnet-bouncer/src/bouncer.ts create mode 100644 packages/ytl-linux-digabi2-examnet/digabi2-examnet-bouncer/src/config.ts delete mode 100644 packages/ytl-linux-digabi2-examnet/digabi2-examnet-bouncer/src/digabi2_examnet_bouncer/__init__.py delete mode 100644 packages/ytl-linux-digabi2-examnet/digabi2-examnet-bouncer/src/digabi2_examnet_bouncer/announcer.py delete mode 100644 packages/ytl-linux-digabi2-examnet/digabi2-examnet-bouncer/src/digabi2_examnet_bouncer/bouncer.py delete mode 100644 packages/ytl-linux-digabi2-examnet/digabi2-examnet-bouncer/src/digabi2_examnet_bouncer/config.py create mode 100644 packages/ytl-linux-digabi2-examnet/digabi2-examnet-bouncer/src/discovery.ts create mode 100644 packages/ytl-linux-digabi2-examnet/digabi2-examnet-bouncer/src/logging.ts create mode 100644 packages/ytl-linux-digabi2-examnet/digabi2-examnet-bouncer/src/main.ts diff --git a/packages/ytl-linux-digabi2-examnet/Makefile b/packages/ytl-linux-digabi2-examnet/Makefile index c577efa..7571841 100644 --- a/packages/ytl-linux-digabi2-examnet/Makefile +++ b/packages/ytl-linux-digabi2-examnet/Makefile @@ -29,20 +29,7 @@ deb: cp -r NetworkManager/* $(DEB_ROOT)/etc/NetworkManager/conf.d/ # Bouncer - (cd digabi2-examnet-bouncer \ - && \ - docker build \ - -f Dockerfile.build \ - --platform linux/amd64 \ - -t registry.invalid/digabi2-examnet-bouncer-build \ - . \ - && \ - docker run \ - --rm \ - --mount type=bind,src=$(shell pwd)/$(DEB_ROOT)/usr/local/sbin/,dst=/out \ - registry.invalid/digabi2-examnet-bouncer-build \ - install -o $(shell id -u) /dist/digabi2-examnet-bouncer /out \ - ) + (cd digabi2-examnet-bouncer && deno compile --allow-net --allow-read --target x86_64-unknown-linux-gnu --output $(DEB_ROOT)/usr/local/sbin/digabi2-examnet-bouncer) chmod 755 $(DEB_ROOT)/usr/local/sbin/* diff --git a/packages/ytl-linux-digabi2-examnet/digabi2-examnet-bouncer/.gitignore b/packages/ytl-linux-digabi2-examnet/digabi2-examnet-bouncer/.gitignore deleted file mode 100644 index 3bbb5da..0000000 --- a/packages/ytl-linux-digabi2-examnet/digabi2-examnet-bouncer/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -build/ -dist/ -__pycache__/ -*.spec diff --git a/packages/ytl-linux-digabi2-examnet/digabi2-examnet-bouncer/.prettierrc b/packages/ytl-linux-digabi2-examnet/digabi2-examnet-bouncer/.prettierrc new file mode 100644 index 0000000..a01141e --- /dev/null +++ b/packages/ytl-linux-digabi2-examnet/digabi2-examnet-bouncer/.prettierrc @@ -0,0 +1,7 @@ +{ + "printWidth": 120, + "semi": false, + "singleQuote": true, + "trailingComma": "none", + "arrowParens": "avoid" +} diff --git a/packages/ytl-linux-digabi2-examnet/digabi2-examnet-bouncer/Dockerfile.build b/packages/ytl-linux-digabi2-examnet/digabi2-examnet-bouncer/Dockerfile.build deleted file mode 100644 index 8d6ee7d..0000000 --- a/packages/ytl-linux-digabi2-examnet/digabi2-examnet-bouncer/Dockerfile.build +++ /dev/null @@ -1,4 +0,0 @@ -FROM docker.io/python:3 -RUN pip install hatch -COPY . . -RUN hatch --no-interactive --verbose run packaging:pyinstaller --onefile --python-option u digabi2-examnet-bouncer diff --git a/packages/ytl-linux-digabi2-examnet/digabi2-examnet-bouncer/deno.json b/packages/ytl-linux-digabi2-examnet/digabi2-examnet-bouncer/deno.json new file mode 100644 index 0000000..4aa1df4 --- /dev/null +++ b/packages/ytl-linux-digabi2-examnet/digabi2-examnet-bouncer/deno.json @@ -0,0 +1,10 @@ +{ + "tasks": { + "dev": "deno run --allow-net --allow-read --watch src/main.ts" + }, + "imports": { + "@gabriel/ts-pattern": "jsr:@gabriel/ts-pattern@^5.9.0", + "@std/cli": "jsr:@std/cli@^1.0.28", + "@zod/zod": "jsr:@zod/zod@^4.3.6" + } +} diff --git a/packages/ytl-linux-digabi2-examnet/digabi2-examnet-bouncer/deno.lock b/packages/ytl-linux-digabi2-examnet/digabi2-examnet-bouncer/deno.lock new file mode 100644 index 0000000..0d64401 --- /dev/null +++ b/packages/ytl-linux-digabi2-examnet/digabi2-examnet-bouncer/deno.lock @@ -0,0 +1,38 @@ +{ + "version": "5", + "specifiers": { + "jsr:@gabriel/ts-pattern@^5.9.0": "5.9.0", + "jsr:@std/cli@^1.0.28": "1.0.28", + "jsr:@std/fmt@^1.0.9": "1.0.9", + "jsr:@std/internal@^1.0.12": "1.0.12", + "jsr:@zod/zod@^4.3.6": "4.3.6" + }, + "jsr": { + "@gabriel/ts-pattern@5.9.0": { + "integrity": "20a6bd65dc4d00c046013fc562f767b38d59ba467d62053e1a87e9a978f4a36c" + }, + "@std/cli@1.0.28": { + "integrity": "74ef9b976db59ca6b23a5283469c9072be6276853807a83ec6c7ce412135c70a", + "dependencies": [ + "jsr:@std/fmt", + "jsr:@std/internal" + ] + }, + "@std/fmt@1.0.9": { + "integrity": "2487343e8899fb2be5d0e3d35013e54477ada198854e52dd05ed0422eddcabe0" + }, + "@std/internal@1.0.12": { + "integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027" + }, + "@zod/zod@4.3.6": { + "integrity": "7144e5e11f8ffc3cf6e2fca624f6597a8762898aac9868cc8938e9398b96ffe4" + } + }, + "workspace": { + "dependencies": [ + "jsr:@gabriel/ts-pattern@^5.9.0", + "jsr:@std/cli@^1.0.28", + "jsr:@zod/zod@^4.3.6" + ] + } +} diff --git a/packages/ytl-linux-digabi2-examnet/digabi2-examnet-bouncer/digabi2-examnet-bouncer b/packages/ytl-linux-digabi2-examnet/digabi2-examnet-bouncer/digabi2-examnet-bouncer deleted file mode 100755 index fbf0ea7..0000000 --- a/packages/ytl-linux-digabi2-examnet/digabi2-examnet-bouncer/digabi2-examnet-bouncer +++ /dev/null @@ -1,93 +0,0 @@ -#!/usr/bin/env python3 - -import argparse -import pathlib - -from waitress import serve -from paste.translogger import TransLogger - -from digabi2_examnet_bouncer.announcer import Announcer -from digabi2_examnet_bouncer.bouncer import Bouncer -from digabi2_examnet_bouncer.config import Config - -parser = argparse.ArgumentParser( - pathlib.Path(__file__).stem, - """ - --friendly-name[-file] ... - --dns-hostname[-file] ... - --dns-hostname[-file] ... - --bind-addr ... - --bind-port ... - [--ncsi-hostnames[-file] ...] - [--mdns-hostname-output-file ...] - """, - description="Network services for the digabi2 exam system", -) - -mdns_group = parser.add_argument_group( - "mDNS", - "Server name for the candidate app landing page.") -friendly_name = mdns_group.add_mutually_exclusive_group() -_ = friendly_name.add_argument("--friendly-name-file", type=pathlib.Path) -_ = friendly_name.add_argument("--friendly-name", type=str) - -dns_group = parser.add_argument_group( - "DNS", - "DNS name of the exam server.") -dns_hostname = dns_group.add_mutually_exclusive_group(required=True) -_ = dns_hostname.add_argument("--dns-hostname-file", type=pathlib.Path) -_ = dns_hostname.add_argument("--dns-hostname", type=str) - -ncsi_group = parser.add_argument_group( - "NCSI", - "Tricks Windows clients into believing they are connected to the internet.") -ncsi_hostnames = ncsi_group.add_mutually_exclusive_group() -_ = ncsi_hostnames.add_argument("--ncsi-hostnames-file", type=pathlib.Path) -_ = ncsi_hostnames.add_argument("--ncsi-hostnames", type=str) - -_ = parser.add_argument("--bind-addr", type=str, required=True) -_ = parser.add_argument("--bind-port", type=int, default=80) - -output_group = parser.add_argument_group("output") -_ = output_group.add_argument("--mdns-hostname-output-file", type=pathlib.Path) - - -def path_or_string(path: pathlib.Path | None, string: str | None): - if path is not None: - return path.read_text().strip() - if string is not None: - return string - - -def main(): - args = parser.parse_args() - friendly_name = path_or_string(args.friendly_name_file, args.friendly_name) - dns_hostname = path_or_string(args.dns_hostname_file, args.dns_hostname) - ncsi_hostnames = path_or_string(args.ncsi_hostnames_file, args.ncsi_hostnames) - ncsi_hostnames = [x.strip() for x in (ncsi_hostnames or "").split(",") if x] - - output_file: pathlib.Path | None = args.mdns_hostname_output_file - - bind_addr: str = args.bind_addr - bind_port: int = args.bind_port - - config = Config( - canonical_host=dns_hostname, # pyright: ignore[reportArgumentType] - friendly_name=friendly_name, # pyright: ignore[reportArgumentType] - ipv4_address=bind_addr, - bouncer_port=bind_port, - ncsi_hosts=ncsi_hostnames, - ) - - print("Starting digabi2-examnet-bouncer with config:", config) - - with Announcer(config).start() as info: - assert info.server is not None - if output_file is not None: - _ = output_file.write_text(info.server) - bouncer = Bouncer(config, info.server) - serve(TransLogger(bouncer), host=config.ipv4_address, port=config.bouncer_port) - - -if __name__ == "__main__": - main() diff --git a/packages/ytl-linux-digabi2-examnet/digabi2-examnet-bouncer/pyproject.toml b/packages/ytl-linux-digabi2-examnet/digabi2-examnet-bouncer/pyproject.toml deleted file mode 100644 index efeab13..0000000 --- a/packages/ytl-linux-digabi2-examnet/digabi2-examnet-bouncer/pyproject.toml +++ /dev/null @@ -1,28 +0,0 @@ -[project] -name = "digabi2-examnet-bouncer" -dynamic = ["version"] -description = "" -requires-python = ">=3.12" -dependencies = [ - "Paste==3.10.1", - "validators==0.35.0", - "waitress>=3.0.0", - "Werkzeug==3.1.3", - "zeroconf==0.148.0", -] - -[project.scripts] -digabi2-examnet-bouncer = "digabi2_examnet_bouncer.main:main" - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.version] -path = "src/digabi2_examnet_bouncer/__init__.py" - -[tool.hatch.envs.packaging] -dependencies = ["pyinstaller==6.16.0"] - -[tool.pyright] -typeCheckingMode = "basic" diff --git a/packages/ytl-linux-digabi2-examnet/digabi2-examnet-bouncer/src/bouncer.ts b/packages/ytl-linux-digabi2-examnet/digabi2-examnet-bouncer/src/bouncer.ts new file mode 100644 index 0000000..a9f5b62 --- /dev/null +++ b/packages/ytl-linux-digabi2-examnet/digabi2-examnet-bouncer/src/bouncer.ts @@ -0,0 +1,66 @@ +import { match } from '@gabriel/ts-pattern' +import { Config } from './config.ts' + +export function bouncerApp(config: Config): (req: Request) => Response { + const { ncsiHostnames, friendlyNames } = config + const friendlyHostnames = friendlyNames.map(x => `${x}.${config.dns.searchDomain}`) + + return function bouncerHandler(req: Request): Response { + const url = new URL(req.url) + return match(url.hostname) + .when( + x => friendlyHostnames.includes(x), + () => redirectHandler(req) + ) + .when( + x => ncsiHostnames.includes(x), + () => ncsiHandler(req) + ) + .otherwise(() => new Response(null, { status: 404 })) + } + + function ncsiHandler(req: Request): Response { + const url = new URL(req.url) + return match(url.pathname) + .with('/connecttest.txt', () => new Response('Microsoft Connect Test')) + .with('/ncsi.txt', () => new Response('Microsoft NCSI')) + .otherwise(() => new Response(null, { status: 404 })) + } + + function redirectHandler(req: Request): Response { + const url = new URL(req.url) + return match([req.method, url.pathname.replace(/\/$/, '')]) + .with(['GET', '/valvoja'], () => { + const targetURL = new URL(url) + targetURL.protocol = 'https' + targetURL.host = config.dns.domain + targetURL.pathname = '/' + return redirect(targetURL) + }) + .with(['GET', '/'], () => { + const targetURL = new URL(url) + targetURL.protocol = 'https' + targetURL.hostname = config.dns.domain + targetURL.port = '8010' + return redirect(targetURL) + }) + .with(['POST', '/ktp/hello'], () => { + const targetURL = new URL(url) + targetURL.protocol = 'https' + targetURL.hostname = config.dns.domain + targetURL.port = '8010' + return redirect(targetURL) + }) + .otherwise(() => new Response(null, { status: 404 })) + } +} + +function redirect(targetURL: URL) { + return new Response(null, { + status: 307, + headers: { + 'Access-Control-Allow-Origin': '*', + Location: targetURL.toString() + } + }) +} diff --git a/packages/ytl-linux-digabi2-examnet/digabi2-examnet-bouncer/src/config.ts b/packages/ytl-linux-digabi2-examnet/digabi2-examnet-bouncer/src/config.ts new file mode 100644 index 0000000..7b074dd --- /dev/null +++ b/packages/ytl-linux-digabi2-examnet/digabi2-examnet-bouncer/src/config.ts @@ -0,0 +1,43 @@ +import { z } from '@zod/zod/v4-mini' + +const TextFileContentSchema = z.pipe( + z.string(), + z.transform(async filename => await Deno.readTextFile(filename)) +) + +const CommaSeparatedTransform = z.transform((x: string) => x.split(',')) + +const DNSSchema = z.object({ + schoolNumber: z.pipe(z.coerce.number(), z.int()), + serverNumber: z.pipe(z.coerce.number(), z.int().check(z.minimum(1), z.maximum(24))), + domain: z.string(), + searchDomain: z.string() +}) + +export const ConfigSchema = z.object({ + friendlyNames: z.pipe(TextFileContentSchema, CommaSeparatedTransform), + ncsiHostnames: z.pipe(TextFileContentSchema, CommaSeparatedTransform), + dns: z.pipe( + z.pipe( + TextFileContentSchema, + z.transform(input => { + const match = input.trim().match(/^ktp(\d+)\.(\d+)\.([a-z.]+)$/) + console.log(input) + const [domain, serverNumber, schoolNumber, searchDomain] = match ?? [] + return { domain, serverNumber, schoolNumber, searchDomain } as unknown + }) + ), + DNSSchema + ), + ports: z.object({ + discovery: z.int().check(z.minimum(1), z.maximum(0xffff)), + bouncer: z.int().check(z.minimum(1), z.maximum(0xffff)) + }) +}) +export type Config = z.output + +export const SecretsSchema = z.object({ + key: TextFileContentSchema, + cert: TextFileContentSchema +}) +export type Secrets = z.output diff --git a/packages/ytl-linux-digabi2-examnet/digabi2-examnet-bouncer/src/digabi2_examnet_bouncer/__init__.py b/packages/ytl-linux-digabi2-examnet/digabi2-examnet-bouncer/src/digabi2_examnet_bouncer/__init__.py deleted file mode 100644 index f102a9c..0000000 --- a/packages/ytl-linux-digabi2-examnet/digabi2-examnet-bouncer/src/digabi2_examnet_bouncer/__init__.py +++ /dev/null @@ -1 +0,0 @@ -__version__ = "0.0.1" diff --git a/packages/ytl-linux-digabi2-examnet/digabi2-examnet-bouncer/src/digabi2_examnet_bouncer/announcer.py b/packages/ytl-linux-digabi2-examnet/digabi2-examnet-bouncer/src/digabi2_examnet_bouncer/announcer.py deleted file mode 100644 index e9d24c2..0000000 --- a/packages/ytl-linux-digabi2-examnet/digabi2-examnet-bouncer/src/digabi2_examnet_bouncer/announcer.py +++ /dev/null @@ -1,51 +0,0 @@ -from __future__ import annotations - -from collections.abc import Generator -from contextlib import contextmanager -import socket - -from zeroconf import IPVersion, NonUniqueNameException, ServiceInfo, Zeroconf - -from .config import Config - - -class AnnouncerGaveUp(Exception): ... - - -class Announcer: - def __init__(self, config: Config): - self.config: Config = config - - def generate_info(self, suffix: str = ""): - config = self.config - friendly_name = f"{self.config.friendly_name}{suffix}" - return ServiceInfo( - "_http._tcp.local.", - f"{friendly_name}._http._tcp.local.", - addresses=[socket.inet_aton(config.ipv4_address)], - port=config.bouncer_port, - server=f"{friendly_name}.local.", - ) - - @contextmanager - def start(self, attempts: int = 9) -> Generator[ServiceInfo]: - zeroconf = Zeroconf(interfaces=[self.config.ipv4_address]) - - def suffixes(): - yield "" - for i in range(attempts): - yield f"-{i + 1}" - - for suffix in suffixes(): - try: - info = self.generate_info(suffix) - zeroconf.register_service(info) - except NonUniqueNameException: - continue - break - else: - raise AnnouncerGaveUp(f"{self.config.friendly_name!r} is taken") - try: - yield info - finally: - zeroconf.close() diff --git a/packages/ytl-linux-digabi2-examnet/digabi2-examnet-bouncer/src/digabi2_examnet_bouncer/bouncer.py b/packages/ytl-linux-digabi2-examnet/digabi2-examnet-bouncer/src/digabi2_examnet_bouncer/bouncer.py deleted file mode 100644 index 0558649..0000000 --- a/packages/ytl-linux-digabi2-examnet/digabi2-examnet-bouncer/src/digabi2_examnet_bouncer/bouncer.py +++ /dev/null @@ -1,79 +0,0 @@ -from collections.abc import Callable, Iterable, Sequence -import functools -from typing import final -from urllib.parse import urljoin -from wsgiref.types import StartResponse, WSGIEnvironment - -from werkzeug import Request, Response -from werkzeug.exceptions import HTTPException, NotFound -from werkzeug.routing import Map, Rule - -from .config import Config - - -NCSI_RESPONSES = { - "connecttest.txt": "Microsoft Connect Test", - "ncsi.txt": "Microsoft NCSI", -} - - -def format_hosts(port: int, hostnames: Sequence[str]): - def acknowledge_trailing_dot(it: Iterable[str]): - for hostname in it: - yield hostname - yield hostname.removesuffix(".") - - def insert_port(it: Iterable[str]): - for hostname in it: - yield hostname if port == 80 else f"{hostname}:{port}" - - return insert_port(acknowledge_trailing_dot(hostnames)) - - -@final -class Bouncer: - def __init__(self, config: Config, friendly_hostname: str): - self.config = config - port = config.bouncer_port - - ncsi_template = functools.partial(Rule, "/", endpoint=self.ncsi, methods=["GET"]) - ncsi_hosts = set(format_hosts(port, self.config.ncsi_hosts)) - ncsi_rules = [ncsi_template(host=host) for host in ncsi_hosts] - - mdns_routes = [("/", ["GET"]), ("/ktp/hello", ["POST"])] - mdns_hosts = set(format_hosts(port, [friendly_hostname])) - mdns_rules = [ - Rule(route, endpoint=self.redirect, methods=methods, host=host) - for route, methods in mdns_routes - for host in mdns_hosts - ] - - self.url_map = Map([*ncsi_rules, *mdns_rules], host_matching=True) - - def dispatch_request(self, request: Request): - adapter = self.url_map.bind_to_environ(request.environ) - try: - rule, values = adapter.match(return_rule=True) - endpoint: Callable[[Request], Response] = rule.endpoint # pyright: ignore[reportAny] - return endpoint(request, **values) - except HTTPException as exc: - return exc - - def __call__(self, environ: WSGIEnvironment, start_response: StartResponse): - request = Request(environ) - response = self.dispatch_request(request) - return response(environ, start_response) - - def ncsi(self, _request: Request, *, name: str) -> Response: - try: - response = NCSI_RESPONSES[name] - except KeyError: - raise NotFound() - return Response(response, 200, content_type="text/plain") - - def redirect(self, request: Request): - headers = { - "Access-Control-Allow-Origin": "*", - "Location": urljoin(self.config.canonical_url, request.full_path), - } - return Response(status=307, headers=headers) diff --git a/packages/ytl-linux-digabi2-examnet/digabi2-examnet-bouncer/src/digabi2_examnet_bouncer/config.py b/packages/ytl-linux-digabi2-examnet/digabi2-examnet-bouncer/src/digabi2_examnet_bouncer/config.py deleted file mode 100644 index a3c84d0..0000000 --- a/packages/ytl-linux-digabi2-examnet/digabi2-examnet-bouncer/src/digabi2_examnet_bouncer/config.py +++ /dev/null @@ -1,40 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -import validators - - -@dataclass(frozen=True) -class Config: - canonical_host: str - friendly_name: str - ipv4_address: str - bouncer_port: int - ncsi_hosts: list[str] - - @property - def canonical_url(self): - port = "" if ":" in self.canonical_host else ":8010" - return f"https://{self.canonical_host}{port}/" - - def __post_init__(self): - validations = [ - validators.hostname( - self.canonical_host, - skip_ipv4_addr=True, - skip_ipv6_addr=True, - may_have_port=True, - maybe_simple=True, - ), - validators.slug(self.friendly_name), - validators.ipv4( - self.ipv4_address, - cidr=False, - private=True, - ), - *(validators.hostname(host) for host in self.ncsi_hosts) - ] - for result in validations: - if isinstance(result, validators.ValidationError): - raise result diff --git a/packages/ytl-linux-digabi2-examnet/digabi2-examnet-bouncer/src/discovery.ts b/packages/ytl-linux-digabi2-examnet/digabi2-examnet-bouncer/src/discovery.ts new file mode 100644 index 0000000..c956b3d --- /dev/null +++ b/packages/ytl-linux-digabi2-examnet/digabi2-examnet-bouncer/src/discovery.ts @@ -0,0 +1,17 @@ +import { Config } from './config.ts' + +export function discoveryApp(config: Config): (req: Request) => Response { + const pattern = new URLPattern({ + pathname: '/.well-known/appspecific/net.abitti.koe.v1/self.json' + }) + + return function discoveryHandler(req: Request): Response { + if (pattern.test(req.url)) { + return Response.json({ + target: config.dns.domain, + aliases: config.friendlyNames.map(x => `${x}.${config.dns.searchDomain}`) + }) + } + return new Response(null, { status: 404 }) + } +} diff --git a/packages/ytl-linux-digabi2-examnet/digabi2-examnet-bouncer/src/logging.ts b/packages/ytl-linux-digabi2-examnet/digabi2-examnet-bouncer/src/logging.ts new file mode 100644 index 0000000..212c1e2 --- /dev/null +++ b/packages/ytl-linux-digabi2-examnet/digabi2-examnet-bouncer/src/logging.ts @@ -0,0 +1,29 @@ +export function loggingMiddleware(internal: Deno.ServeHandler): Deno.ServeHandler { + // not exactly paste.TransLogger but probably Close Enoughâ„¢ + return async (req, info) => { + const response = await internal(req, info) + const authorization = req.headers.get('authorization') + const now = Temporal.Now.instant() + + const remoteAddr = info.remoteAddr.hostname + const remoteUser = authorization?.startsWith('Basic ') + ? (atob(authorization.substring(6)).split(':')[0] ?? '-') + : '-' + const time = now.toLocaleString('en-US', { + dateStyle: 'medium', + timeStyle: 'medium', + hour12: false + }) + const requestMethod = req.method + const requestURI = req.url + const status = response.status + const bytes = (await response.clone().arrayBuffer()).byteLength + const httpReferer = req.headers.get('referer') ?? '-' + const httpUserAgent = req.headers.get('user-agent') ?? '-' + + console.log( + `${remoteAddr} - ${remoteUser} [${time}] "${requestMethod} ${requestURI}" ${status} ${bytes} "${httpReferer}" "${httpUserAgent}"` + ) + return response + } +} diff --git a/packages/ytl-linux-digabi2-examnet/digabi2-examnet-bouncer/src/main.ts b/packages/ytl-linux-digabi2-examnet/digabi2-examnet-bouncer/src/main.ts new file mode 100644 index 0000000..e6df487 --- /dev/null +++ b/packages/ytl-linux-digabi2-examnet/digabi2-examnet-bouncer/src/main.ts @@ -0,0 +1,50 @@ +import { parseArgs } from '@std/cli' +import { bouncerApp } from './bouncer.ts' +import { ConfigSchema, SecretsSchema } from './config.ts' +import { discoveryApp } from './discovery.ts' +import { loggingMiddleware } from './logging.ts' +import { z } from '@zod/zod/v4-mini' + +const ArgsSchema = z.partial( + z.object({ + 'friendly-names-file': z.string(), + 'ncsi-hostnames-file': z.string(), + 'dns-hostname-file': z.string(), + 'discovery-port': z.coerce.number(), + 'bouncer-port': z.coerce.number(), + 'tls-cert-file': z.string(), + 'tls-key-file': z.string() + }) +) +const args = ArgsSchema.parse(parseArgs(Deno.args)) + +const config = await ConfigSchema.parseAsync({ + friendlyNames: args['friendly-names-file'], + ncsiHostnames: args['ncsi-hostnames-file'], + dns: args['dns-hostname-file'], + ports: { + discovery: args['discovery-port'] ?? 26464, + bouncer: args['bouncer-port'] ?? 80 + } +}) +console.log(config) +const secrets = await SecretsSchema.parseAsync({ + key: args['tls-key-file'], + cert: args['tls-cert-file'] +}) + +const _discoveryServer = Deno.serve( + { + port: config.ports.discovery, + cert: secrets.cert, + key: secrets.key + }, + loggingMiddleware(discoveryApp(config)) +) + +const _bouncerServer = Deno.serve( + { + port: config.ports.bouncer + }, + loggingMiddleware(bouncerApp(config)) +) diff --git a/packages/ytl-linux-digabi2-examnet/ytl-linux-digabi2-examnet b/packages/ytl-linux-digabi2-examnet/ytl-linux-digabi2-examnet index bf1449c..0059117 100755 --- a/packages/ytl-linux-digabi2-examnet/ytl-linux-digabi2-examnet +++ b/packages/ytl-linux-digabi2-examnet/ytl-linux-digabi2-examnet @@ -51,6 +51,7 @@ readonly PATH_DOCKER_DAEMON_CONF_TEMPLATE=$PATH_TEMPLATES/docker-daemon.json.tem 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_KEY="$PATH_NAKSU2_CERTS_DIR/key.pem" readonly PATH_NAKSU2_DOMAIN="$PATH_NAKSU2_CERTS_DIR/domain.txt" readonly PATH_EXAMNET_CONFIG=/etc/ytl-linux-digabi2-examnet/config/ readonly PATH_SERVER_FRIENDLY_NAME_CONF=$PATH_EXAMNET_CONFIG/server-friendly-name @@ -577,12 +578,11 @@ if [[ $* == *--daemon* ]]; then fi $BIN_DIGABI2_EXAMNET_BOUNCER \ - --friendly-name-file $PATH_SERVER_FRIENDLY_NAME_CONF \ - --dns-hostname-file $PATH_NAKSU2_DOMAIN \ + --friendly-names-file $PATH_SERVER_FRIENDLY_NAME_CONF \ --ncsi-hostnames-file $PATH_EXAMNET_CONFIG/ncsi-hostnames \ - --mdns-hostname-output-file $PATH_EXAMNET_CONFIG/mdns-hostname-output \ - --bind-addr "$_LAN_DEVICE_IP" \ - --bind-port 80 + --tls-key-file $PATH_NAKSU2_KEY \ + --tls-cert-file $PATH_NAKSU2_CERT \ + --dns-hostname-file $PATH_NAKSU2_DOMAIN # We're not supposed to get here, so this would indicate the daemon process exited exit_script $EXIT_CODE_DAEMON_EXITED_UNEXPECTEDLY