From 69b38917b3a77feb087026bfff569c0fc2721bd9 Mon Sep 17 00:00:00 2001 From: Kevin Stenzel Date: Fri, 30 Oct 2026 15:01:44 +0100 Subject: [PATCH 01/24] test(e2e): run the Docker suites as a user of the docker group run.sh e2e built the broken image and ran the docker-py integration tests through sudo unconditionally, so on a host where the user may use Docker but sudo needs a password (a developer box) cp-docker-it failed before any test ran. It now uses sudo only when `docker info` fails, as e2e_kerberos.sh and crabbox_bootstrap.sh already do. The integration tests created their lists under the service default /etc/linuxmuster-squid/blocklists, which only root may write; they now use a directory under pytest's tmp_path like the secrets. --- controlplane/tests/test_docker_integration.py | 4 ++-- scripts/tests/run.sh | 8 +++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/controlplane/tests/test_docker_integration.py b/controlplane/tests/test_docker_integration.py index 7d128e4..8f1b6eb 100644 --- a/controlplane/tests/test_docker_integration.py +++ b/controlplane/tests/test_docker_integration.py @@ -32,7 +32,7 @@ def test_real_container_lifecycle(tmp_path: Path) -> None: # exercised by the compose E2E, not here — this proves docker-py lifecycle. (secrets / "it.keytab").write_bytes(b"dummy-keytab-not-valid") - svc = DockerService(secrets_dir=str(secrets)) + svc = DockerService(secrets_dir=str(secrets), blocklists_dir=str(tmp_path / "lists")) inst = Instance( school="it", role="teachers", @@ -78,7 +78,7 @@ def test_real_update_auto_rollback(tmp_path: Path) -> None: (secrets / "it.keytab").write_bytes(b"dummy-keytab-not-valid") store = Store(str(tmp_path / "instances")) - docker = DockerService(secrets_dir=str(secrets)) + docker = DockerService(secrets_dir=str(secrets), blocklists_dir=str(tmp_path / "lists")) reconciler = Reconciler(store, docker) updater = Updater(store, docker, reconciler, health_timeout=45.0, poll_interval=2.0) diff --git a/scripts/tests/run.sh b/scripts/tests/run.sh index 76650e9..739cea0 100755 --- a/scripts/tests/run.sh +++ b/scripts/tests/run.sh @@ -124,14 +124,16 @@ e2e(){ else skip "kerberos-e2e" "scripts/tests/e2e_kerberos.sh missing (comes in P1)" fi + # Docker directly, or via sudo when the user is not (yet) in the docker group (crabbox). + local asroot=(); docker info >/dev/null 2>&1 || asroot=(sudo) # Build broken image (FROM :dev) for the auto-rollback test. if have docker; then - sudo docker build -q -t linuxmuster-squid:broken deploy/e2e/broken-image >/dev/null 2>&1 || true + "${asroot[@]}" docker build -q -t linuxmuster-squid:broken deploy/e2e/broken-image >/dev/null 2>&1 || true fi # Control-plane Docker integration: real DockerService creates a container - # + update/auto-rollback; via sudo, since the crabbox user may not be in the docker group. + # + update/auto-rollback. if [ -x .venv/bin/pytest ]; then - if sudo env LMNSQUID_DOCKER_IT=1 ./.venv/bin/pytest -q controlplane/tests/test_docker_integration.py; then + if "${asroot[@]}" env LMNSQUID_DOCKER_IT=1 ./.venv/bin/pytest -q controlplane/tests/test_docker_integration.py; then pass "cp-docker-it" else fail "cp-docker-it" From e00cc009cdffb10d3c244395340e8cd788462376 Mon Sep 17 00:00:00 2001 From: Kevin Stenzel Date: Fri, 30 Oct 2026 15:01:59 +0100 Subject: [PATCH 02/24] feat(noauth): per-instance domains reachable without proxy authentication Some services break behind proxy authentication; Microsoft lists it among the configurations known to cause Microsoft 365 connection issues. A client-side bypass was not wanted, so an instance can now let listed domains through the proxy without a ticket (ADR-016): lmnsquid noauth list | add | remove | reload GET/POST/DELETE /v1/instances/{name}/noauth, POST .../noauth/reload The list is //noauth.domains, in the directory that is already mounted at /etc/squid/lists, so the container spec (and its fingerprint) does not change and an upgrade replaces no container for it. Entries are normalized like the blocklist (.example.org = domain and subdomains). squid.conf (data-plane image): - after the existing denies (Safe_ports, CONNECT only to 443, no IP literals): `deny noauth_domains blocked_domains` (the blocklist wins, 403), then `allow noauth_domains school_net`, then the unchanged chain; - the list ACL is `dstdomain -n`: without it an IP-literal URL whose address maps back to a listed name matched (measured: 200 without a ticket); - a CONNECT to a listed domain is terminated at SslBump2 when its SNI is not on the list (ssl::server_name --client-requested) or when there is no SNI. ssl::server_name falls back to the CONNECT host without SNI, so a four-line external ACL helper (sni-sent.sh, %ssl::>sni) tells; squid sends it " <%DATA>", i.e. "- -" without SNI. No decryption: peek, splice and terminate only (ADR-002). - entrypoint: a list directory without noauth.domains (an older control plane) gets an empty stand-in under /run and a notice. Control plane: both list files are created before a container starts. `noauth reload` first checks in the container that the rendered squid.conf reads /etc/squid/lists/noauth.domains and answers 409 otherwise: the pinned DEFAULT_IMAGE does not know the list yet, and a reload would report success while changing nothing. Tests: unit (normalization, unsafe names, API 401/403/404/409/422, CLI, --help warnings, both files before create, noauth_active); Docker integration with the real DockerService (new image reads and reloads the list; the pinned image runs healthy next to it and is reported inactive); Kerberos E2E with three new instances (squid-noauth, the pinned image, a list directory without the file) covering listed/unlisted/blocked, foreign SNI, blocked SNI, no SNI, IP literal, outside school_net and the ticket matrix. Counter-runs without `-n` and without the SNI rule fail. Docs: ADR-016, threat model T16 (exam mode, Internetsperre, no user in the log), architecture, operations (commands, warnings, recommended Microsoft 365 list from endpoints.office.com version 2026103000), install.md optional step 8, README, test strategy, references. debian/changelog: new 7.3.8 stanza. --- README.md | 6 +- controlplane/lmnsquid/api.py | 62 ++++- controlplane/lmnsquid/blocklist.py | 36 +-- controlplane/lmnsquid/cli.py | 47 +++- controlplane/lmnsquid/docker_service.py | 65 ++++-- controlplane/lmnsquid/models.py | 2 +- controlplane/tests/conftest.py | 14 +- controlplane/tests/test_docker_integration.py | 46 +++- controlplane/tests/test_docker_service.py | 63 +++++- controlplane/tests/test_noauth.py | 211 ++++++++++++++++++ debian/changelog | 21 ++ deploy/e2e/client/Dockerfile | 2 +- deploy/e2e/client/assert.sh | 64 ++++++ deploy/e2e/docker-compose.yml | 66 +++++- deploy/e2e/lists-legacy/blocked.domains | 7 + deploy/e2e/lists-noauth/blocked.domains | 8 + deploy/e2e/lists-noauth/noauth.domains | 7 + docs/architecture.md | 26 ++- docs/decisions.md | 32 +++ docs/install.md | 34 ++- docs/operations.md | 91 +++++++- docs/references.md | 25 +++ docs/test-strategy.md | 21 +- docs/threat-model.md | 3 +- image/Dockerfile | 6 +- image/entrypoint.sh | 14 +- image/sni-sent.sh | 18 ++ image/templates/squid.conf.template | 18 ++ scripts/tests/e2e_kerberos.sh | 46 +++- 29 files changed, 997 insertions(+), 64 deletions(-) create mode 100644 controlplane/tests/test_noauth.py create mode 100644 deploy/e2e/lists-legacy/blocked.domains create mode 100644 deploy/e2e/lists-noauth/blocked.domains create mode 100644 deploy/e2e/lists-noauth/noauth.domains create mode 100755 image/sni-sent.sh diff --git a/README.md b/README.md index 2fd3d3e..a6c816c 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,8 @@ the same AD/Kerberos infrastructure the school already operates. `ext_kerberos_ldap_group_acl` against the instance's AD group. HTTPS is **not decrypted** (SNI peek/splice or CONNECT `dstdomain`). Per-instance domain blocklist: HTTP → 403, HTTPS → the TLS handshake is cut (no block page without decryption). + Optional per-instance list of domains reachable without proxy login from the school subnets + (e.g. Microsoft 365; the blocklist still wins). - **Control Plane:** hardened systemd service with **REST API** (FastAPI) and **CLI** (Typer, thin client) — creates instances, configures policies, and **updates digest-pinned with health-check auto-rollback**. Shipped as a @@ -92,7 +94,9 @@ lmnsquid rollback all-teachers # to the last known-good lmnsquid reconcile # re-apply stored state (restore / after a crash) lmnsquid blocklist all-students add example.org # block a domain + subdomains (HTTP 403, HTTPS TLS abort) lmnsquid blocklist all-students reload # apply without restart; list / remove likewise -lmnsquid rm all-students # container + cache/log volumes + blocklist (--keep-logs keeps the logs) +lmnsquid noauth all-students add sharepoint.com # reachable WITHOUT proxy login from the school subnets +lmnsquid noauth all-students reload # (exam mode and Internetsperre do not apply: docs/operations.md) +lmnsquid rm all-students # container + cache/log volumes + both lists (--keep-logs keeps the logs) lmnsquid version ``` diff --git a/controlplane/lmnsquid/api.py b/controlplane/lmnsquid/api.py index b910e51..a417be5 100644 --- a/controlplane/lmnsquid/api.py +++ b/controlplane/lmnsquid/api.py @@ -13,7 +13,7 @@ from fastapi import Depends, FastAPI, HTTPException, Request, status from fastapi.responses import JSONResponse -from .blocklist import Blocklist +from .blocklist import NOAUTH_FILE_NAME, Blocklist from .config import Settings from .docker_service import DockerService from .models import DEFAULT_IMAGE, BlocklistEntry, Instance, InstancePatch, UpdateRequest @@ -38,6 +38,7 @@ def create_app( """Build the FastAPI app wiring routes to the store, reconciler and docker service.""" verify = make_verify_token(settings) blocklist = Blocklist(settings.blocklists_dir) + noauth = Blocklist(settings.blocklists_dir, NOAUTH_FILE_NAME) app = FastAPI(title="linuxmuster-squid control plane") @app.exception_handler(DockerException) @@ -218,6 +219,65 @@ def reload_blocklist(name: str) -> dict[str, Any]: status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc) ) from exc + # ------------------------------------------- domains without proxy auth (ADR-016) + @app.get("/v1/instances/{name}/noauth", dependencies=auth) + async def get_noauth(name: str) -> dict[str, Any]: + _require(name) + return {"name": name, "domains": noauth.read(name)} + + @app.post("/v1/instances/{name}/noauth", dependencies=auth) + async def add_noauth_domain(name: str, entry: BlocklistEntry) -> dict[str, Any]: + """Let ``entry.domain`` and its subdomains through without proxy auth (after ``reload``).""" + _require(name) + domains = noauth.add(name, entry.domain) + audit.info("noauth add name=%s domain=%s", name, entry.domain) + return {"name": name, "domains": domains} + + @app.delete("/v1/instances/{name}/noauth/{domain}", dependencies=auth) + async def remove_noauth_domain(name: str, domain: str) -> dict[str, Any]: + _require(name) + try: + domains = noauth.remove(name, domain) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc) + ) from exc + except KeyError as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"{domain!r} is not on the noauth list of {name!r}", + ) from exc + audit.info("noauth remove name=%s domain=%s", name, domain) + return {"name": name, "domains": domains} + + @app.post("/v1/instances/{name}/noauth/reload", dependencies=auth) + def reload_noauth(name: str) -> dict[str, Any]: + """Re-read the lists (``squid -k reconfigure``), refused if squid ignores the noauth list. + + An image from before ADR-016, or a container started without ``noauth.domains`` in + its list directory, never reads the list: answer 409 instead of a silent "reloaded". + """ + _require(name) + audit.info("noauth reload name=%s", name) + try: + if not docker.noauth_active(name): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=( + f"the running container of {name!r} does not read the noauth list " + "(its image predates the list, or it started without noauth.domains): " + f"update it to an image that supports the list, or `lmnsquid restart " + f"{name}` when it already runs one" + ), + ) + return docker.reload(name) + except LookupError as exc: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc + except RuntimeError as exc: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc) + ) from exc + # ----------------------------------------------------------- lifecycle ops @app.post("/v1/instances/{name}/start", dependencies=auth) async def start_instance(name: str) -> dict[str, Any]: diff --git a/controlplane/lmnsquid/blocklist.py b/controlplane/lmnsquid/blocklist.py index 6d4c081..9a80f6d 100644 --- a/controlplane/lmnsquid/blocklist.py +++ b/controlplane/lmnsquid/blocklist.py @@ -1,13 +1,15 @@ # SPDX-FileCopyrightText: Kevin Stenzel # SPDX-License-Identifier: GPL-3.0-or-later -"""Per-instance domain blocklist: a file on the host, mounted read-only into the container. - -Every instance owns ``//blocked.domains``. The DIRECTORY is -bind-mounted read-only at ``/etc/squid/lists`` -- the path the rendered ``squid.conf`` -already reads its ``dstdomain`` / ``ssl::server_name`` ACL from -- so the image needs no -change and the read-only rootfs stays read-only. The directory rather than the file is -mounted on purpose: a single-file bind mount pins the inode, so an atomic replace on the -host (an editor, ``blocklist-refresh.sh``, this module) would never reach the container. +"""Per-instance domain lists: files on the host, mounted read-only into the container. + +Every instance owns the directory ``//`` with two lists: +``blocked.domains`` (the blocklist) and ``noauth.domains`` (domains reachable without proxy +auth, ADR-016). The DIRECTORY is bind-mounted read-only at ``/etc/squid/lists`` -- the path +the rendered ``squid.conf`` reads its ``dstdomain`` / ``ssl::server_name`` ACLs from -- so the +read-only rootfs stays read-only, and the second list needs no mount of its own (the container +spec, and with it every running container, stays as it was). The directory rather than the +file is mounted on purpose: a single-file bind mount pins the inode, so an atomic replace on +the host (an editor, ``blocklist-refresh.sh``, this module) would never reach the container. """ from __future__ import annotations @@ -19,6 +21,7 @@ from pathlib import Path FILE_NAME = "blocked.domains" +NOAUTH_FILE_NAME = "noauth.domains" CONTAINER_DIR = "/etc/squid/lists" # squid.conf: acl blocked_domains dstdomain "/etc/squid/lists/blocked.domains" # Instance name = - (models._NAME_RE per part): the directory name under root. _INSTANCE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9-]{1,62}$") @@ -48,10 +51,15 @@ def normalize_domain(value: str) -> str: class Blocklist: - """Read and modify the ``blocked.domains`` files under ``root`` (one dir per instance).""" + """Read and modify one list file (``blocked.domains`` by default) under ``root``. - def __init__(self, root: str) -> None: + One directory per instance; ``Blocklist(root, NOAUTH_FILE_NAME)`` handles the noauth list + in the same directories. + """ + + def __init__(self, root: str, file_name: str = FILE_NAME) -> None: self.root = Path(root) + self.file_name = file_name def dir_for(self, name: str) -> Path: """Host directory mounted into the instance ``name`` (rejects anything but a name).""" @@ -60,7 +68,7 @@ def dir_for(self, name: str) -> Path: return self.root / name def file_for(self, name: str) -> Path: - return self.dir_for(name) / FILE_NAME + return self.dir_for(name) / self.file_name def ensure(self, name: str) -> Path: """Create the directory and an empty list if absent; return the directory. @@ -70,7 +78,7 @@ def ensure(self, name: str) -> Path: """ directory = self.dir_for(name) directory.mkdir(mode=0o755, parents=True, exist_ok=True) - file = directory / FILE_NAME + file = directory / self.file_name if not file.exists(): self._write(file, []) return directory @@ -107,7 +115,7 @@ def remove(self, name: str, domain: str) -> list[str]: return remaining def delete(self, name: str) -> None: - """Remove the instance's directory (called when the instance is removed).""" + """Remove the instance's directory with every list in it (instance removed).""" directory = self.dir_for(name) if directory.is_dir(): shutil.rmtree(directory) @@ -117,7 +125,7 @@ def _write(file: Path, entries: list[str]) -> None: # Atomic replace inside the directory: squid (on reload) or an editor never sees a # half-written list, and a root-owned file left behind by blocklist-refresh.sh # does not block the control plane (only the directory must be writable). - fd, tmp = tempfile.mkstemp(dir=file.parent, prefix=f".{FILE_NAME}.", suffix=".tmp") + fd, tmp = tempfile.mkstemp(dir=file.parent, prefix=f".{file.name}.", suffix=".tmp") try: with os.fdopen(fd, "w", encoding="utf-8") as fh: fh.write("".join(f"{entry}\n" for entry in entries)) diff --git a/controlplane/lmnsquid/cli.py b/controlplane/lmnsquid/cli.py index cd16405..21cbcc0 100644 --- a/controlplane/lmnsquid/cli.py +++ b/controlplane/lmnsquid/cli.py @@ -122,7 +122,7 @@ def rm( "default removes it together with the cache volume", ), ) -> None: - """Remove an instance: container, cache + log volumes, blocklist and definition.""" + """Remove an instance: container, cache + log volumes, both lists and definition.""" with _get_client() as c: _emit( c.delete(f"/v1/instances/{name}", params={"keep_logs": "true"} if keep_logs else None) @@ -171,6 +171,51 @@ def blocklist_reload(ctx: typer.Context) -> None: _emit(c.post(f"/v1/instances/{ctx.obj}/blocklist/reload")) +noauth_app = typer.Typer( + help="Per-instance domains reachable WITHOUT proxy authentication, from --school-subnets " + "only (e.g. Microsoft 365). The blocklist still wins; HTTPS only when the TLS SNI is on " + "the list too. WARNING: anyone in the school subnets reaches these domains without a " + "ticket, so exam accounts reach them in exam mode (OneDrive, Teams, Outlook ...) and the " + "Internetsperre does not apply to them. Changes take effect after `reload`.", + no_args_is_help=True, +) +app.add_typer(noauth_app, name="noauth") + + +@noauth_app.callback() +def _noauth(ctx: typer.Context, name: str = typer.Argument(..., help="instance name")) -> None: + """Manage the noauth list of one instance: list | add | remove | reload.""" + ctx.obj = name + + +@noauth_app.command("list") +def noauth_list(ctx: typer.Context) -> None: + """Show the domains reachable without proxy authentication.""" + with _get_client() as c: + _emit(c.get(f"/v1/instances/{ctx.obj}/noauth")) + + +@noauth_app.command("add") +def noauth_add(ctx: typer.Context, domain: str) -> None: + """Let a domain and all its subdomains through without authentication (then `reload`).""" + with _get_client() as c: + _emit(c.post(f"/v1/instances/{ctx.obj}/noauth", json={"domain": domain})) + + +@noauth_app.command("remove") +def noauth_remove(ctx: typer.Context, domain: str) -> None: + """Require authentication for a domain again (then `reload`).""" + with _get_client() as c: + _emit(c.delete(f"/v1/instances/{ctx.obj}/noauth/{quote(domain, safe='')}")) + + +@noauth_app.command("reload") +def noauth_reload(ctx: typer.Context) -> None: + """Apply the list without a restart; fails if the instance's image ignores the list.""" + with _get_client() as c: + _emit(c.post(f"/v1/instances/{ctx.obj}/noauth/reload")) + + @app.command() def start(name: str) -> None: """Start the instance container.""" diff --git a/controlplane/lmnsquid/docker_service.py b/controlplane/lmnsquid/docker_service.py index f934d15..d43d63c 100644 --- a/controlplane/lmnsquid/docker_service.py +++ b/controlplane/lmnsquid/docker_service.py @@ -16,7 +16,7 @@ from docker.models.containers import Container from docker.types import LogConfig -from .blocklist import CONTAINER_DIR, Blocklist +from .blocklist import CONTAINER_DIR, NOAUTH_FILE_NAME, Blocklist from .models import Instance logger = logging.getLogger("lmnsquid.docker") @@ -55,6 +55,7 @@ def __init__( self.log_max_size: str = log_max_size self.log_max_file: int = log_max_file self.blocklist = Blocklist(blocklists_dir) + self.noauth = Blocklist(blocklists_dir, NOAUTH_FILE_NAME) self.health_timeout = health_timeout self.poll_interval = poll_interval self.client: docker.DockerClient = ( @@ -183,9 +184,12 @@ def ensure_running(self, inst: Instance) -> dict[str, Any]: spec = self.spec_for(inst) digest = self.fingerprint(spec) - # Blocklist: created empty if absent (create, reconcile, update all pass through + # Lists: created empty if absent (create, reconcile, update all pass through # here, so instances from before 7.3.1 get the mount on their next replacement). + # noauth.domains must exist before a container starts: the entrypoint only reads + # it from the mount when it is there at start (ADR-016). self.blocklist.ensure(inst.name) + self.noauth.ensure(inst.name) cname = inst.container_name existing = self._get(inst.name) @@ -323,19 +327,8 @@ def remove(self, name: str, keep_logs: bool = False) -> None: logger.warning("volume %s not removed: %s", volume, exc) self.blocklist.delete(name) - def reload(self, name: str) -> dict[str, Any]: - """Make the running squid re-read squid.conf and its ACL list files (the blocklist). - - Runs ``squid -k reconfigure`` inside the container: squid parses the config and - signals its running copy (SIGHUP). No restart -- the cache and open client - connections survive, the auth/group helpers are restarted. Deliberately NOT - ``container.kill(signal="SIGHUP")``: Docker records every kill(), whatever the - signal, as a manual stop, and an ``unless-stopped`` container is then no longer - started after a reboot (seen in the lab). Like ``access_logs`` this needs - ``docker exec`` (not available behind the socket proxy with ``EXEC: 0``). - Raises ``LookupError`` when there is no running container, ``RuntimeError`` - when the exec is refused or squid does. - """ + def _running(self, name: str) -> Container: + """The running container of ``name``; ``LookupError`` when there is none.""" container = self._get(name) if container is None: raise LookupError(f"instance {name!r} has no container") @@ -343,19 +336,53 @@ def reload(self, name: str) -> dict[str, Any]: state: dict[str, Any] = container.attrs.get("State", {}) or {} if not state.get("Running", False): raise LookupError(f"instance {name!r} is not running") + return container + + def _exec(self, container: Container, cmd: list[str]) -> tuple[int, str]: try: - exit_code, output = container.exec_run( - ["squid", "-k", "reconfigure", "-f", _SQUID_CONF] - ) + exit_code, output = container.exec_run(cmd) except APIError as exc: # The daemon is alive; exec itself was refused (socket proxy with EXEC: 0). raise RuntimeError( - f"docker exec refused ({exc.explanation or exc}); blocklist reload needs " + f"docker exec refused ({exc.explanation or exc}); list reload needs " "EXEC at the docker socket proxy (ADR-012/ADR-014)" ) from exc text = ( output.decode("utf-8", errors="replace") if isinstance(output, bytes) else str(output) ) + return exit_code, text + + def noauth_active(self, name: str) -> bool: + """Whether the running squid reads ``noauth.domains`` from the list directory. + + Not so for an image from before ADR-016 (its template has no such ACL) or for a + container started while the directory held no ``noauth.domains`` (the entrypoint + then stands in an empty file); a reload cannot change either, only a new container + of a supporting image can. Same preconditions and errors as :meth:`reload`. + """ + container = self._running(name) + exit_code, text = self._exec( + container, ["grep", "-qF", f"{CONTAINER_DIR}/{NOAUTH_FILE_NAME}", _SQUID_CONF] + ) + if exit_code not in (0, 1): + raise RuntimeError(f"cannot read {_SQUID_CONF} (exit {exit_code}): {text.strip()}") + return exit_code == 0 + + def reload(self, name: str) -> dict[str, Any]: + """Make the running squid re-read squid.conf and its ACL list files (both lists). + + Runs ``squid -k reconfigure`` inside the container: squid parses the config and + signals its running copy (SIGHUP). No restart -- the cache and open client + connections survive, the auth/group helpers are restarted. Deliberately NOT + ``container.kill(signal="SIGHUP")``: Docker records every kill(), whatever the + signal, as a manual stop, and an ``unless-stopped`` container is then no longer + started after a reboot (seen in the lab). Like ``access_logs`` this needs + ``docker exec`` (not available behind the socket proxy with ``EXEC: 0``). + Raises ``LookupError`` when there is no running container, ``RuntimeError`` + when the exec is refused or squid does. + """ + container = self._running(name) + exit_code, text = self._exec(container, ["squid", "-k", "reconfigure", "-f", _SQUID_CONF]) if exit_code != 0: raise RuntimeError(f"squid -k reconfigure exited {exit_code}: {text.strip()}") return {"name": name, "reloaded": True} diff --git a/controlplane/lmnsquid/models.py b/controlplane/lmnsquid/models.py index c0ea4c5..2c62539 100644 --- a/controlplane/lmnsquid/models.py +++ b/controlplane/lmnsquid/models.py @@ -203,7 +203,7 @@ def _v_image(cls, v: str) -> str: class BlocklistEntry(BaseModel): - """Body for ``POST /v1/instances/{name}/blocklist``: one domain to block.""" + """Body for ``POST /v1/instances/{name}/blocklist`` and ``…/noauth``: one domain.""" domain: str diff --git a/controlplane/tests/conftest.py b/controlplane/tests/conftest.py index 0566f87..2c34b82 100644 --- a/controlplane/tests/conftest.py +++ b/controlplane/tests/conftest.py @@ -15,7 +15,7 @@ from starlette.testclient import TestClient from lmnsquid.api import create_app -from lmnsquid.blocklist import Blocklist +from lmnsquid.blocklist import NOAUTH_FILE_NAME, Blocklist from lmnsquid.config import Settings from lmnsquid.models import Instance from lmnsquid.reconciler import Reconciler @@ -42,6 +42,7 @@ def __init__( self.docker_host = docker_host self.secrets_dir = secrets_dir self.blocklist = Blocklist(blocklists_dir) + self.noauth = Blocklist(blocklists_dir, NOAUTH_FILE_NAME) self.containers: dict[str, dict[str, Any]] = {} self.volumes: set[str] = set() # Test-observability hooks. @@ -70,14 +71,17 @@ def ensure_running(self, inst: Instance) -> dict[str, Any]: # Mirror the real service: the replacement is created first, so a failure # leaves the previous container exactly as it was and raises. raise RuntimeError("simulated pull failure") - # Mirror the real service: the blocklist is created (empty) and mounted, and the + # Mirror the real service: both lists are created (empty) and mounted, and the # cache/log volumes come into existence with the container. self.blocklist.ensure(inst.name) + self.noauth.ensure(inst.name) self.volumes.update({f"lmnsquid-cache-{inst.name}", f"lmnsquid-logs-{inst.name}"}) self.containers[inst.name] = { "running": True, "image": inst.image, "health": "unhealthy" if "bad" in inst.image else "healthy", + # An image tagged "old" stands for one from before ADR-016 (ignores noauth). + "noauth_active": "old" not in inst.image, "env": self.env_for(inst), "logs": f"started {inst.container_name}\n", "access_logs": ( @@ -113,6 +117,12 @@ def remove(self, name: str, keep_logs: bool = False) -> None: self.volumes.discard(f"lmnsquid-logs-{name}") self.blocklist.delete(name) + def noauth_active(self, name: str) -> bool: + container = self.containers.get(name) + if container is None or not container["running"]: + raise LookupError(f"instance {name!r} is not running") + return bool(container["noauth_active"]) + def reload(self, name: str) -> dict[str, Any]: container = self.containers.get(name) if container is None or not container["running"]: diff --git a/controlplane/tests/test_docker_integration.py b/controlplane/tests/test_docker_integration.py index 8f1b6eb..3474e31 100644 --- a/controlplane/tests/test_docker_integration.py +++ b/controlplane/tests/test_docker_integration.py @@ -32,7 +32,8 @@ def test_real_container_lifecycle(tmp_path: Path) -> None: # exercised by the compose E2E, not here — this proves docker-py lifecycle. (secrets / "it.keytab").write_bytes(b"dummy-keytab-not-valid") - svc = DockerService(secrets_dir=str(secrets), blocklists_dir=str(tmp_path / "lists")) + lists = tmp_path / "lists" + svc = DockerService(secrets_dir=str(secrets), blocklists_dir=str(lists)) inst = Instance( school="it", role="teachers", @@ -56,6 +57,13 @@ def test_real_container_lifecycle(tmp_path: Path) -> None: time.sleep(5) assert svc.status(inst.name)["running"] is True + # ADR-016: noauth.domains exists before the start, so this image reads it, and a + # reload picks up an entry added afterwards. + assert (lists / inst.name / "noauth.domains").read_text() == "" + assert svc.noauth_active(inst.name) is True + svc.noauth.add(inst.name, "office.com") + assert svc.reload(inst.name) == {"name": inst.name, "reloaded": True} + assert svc.stop(inst.name)["running"] is False assert svc.start(inst.name)["running"] is True finally: @@ -106,3 +114,39 @@ def test_real_update_auto_rollback(tmp_path: Path) -> None: assert store.get(inst.name).image == IMAGE # type: ignore[union-attr] finally: docker.remove(inst.name) + + +def test_real_pinned_image_ignores_the_noauth_list(tmp_path: Path) -> None: + """The image installed systems run (DEFAULT_IMAGE) next to a list directory with + noauth.domains: it starts healthy, and the noauth reload is refused instead of + reporting a reload that changes nothing (ADR-016).""" + from lmnsquid.docker_service import DockerService + from lmnsquid.models import DEFAULT_IMAGE, Instance + + old_image = os.environ.get("LMNSQUID_IT_OLD_IMAGE", DEFAULT_IMAGE) + secrets = tmp_path / "secrets" + secrets.mkdir() + (secrets / "it.keytab").write_bytes(b"dummy-keytab-not-valid") + lists = tmp_path / "lists" + svc = DockerService(secrets_dir=str(secrets), blocklists_dir=str(lists)) + inst = Instance( + school="ito", + role="teachers", + ad_group="teachers", + realm="EXAMPLE.INTERNAL", + visible_hostname="squid-ito.example.internal", + http_port=3197, + school_subnets="0.0.0.0/0", + keytab_secret="it.keytab", + cache_size_mb=100, + image=old_image, + ) + svc.remove(inst.name) + svc.noauth.add(inst.name, "office.com") # a list that is there before the container + try: + status = svc.ensure_running(inst) # health-gated: raises if it does not come up + assert status["running"] is True and status["health"] == "healthy" + assert svc.noauth_active(inst.name) is False + assert svc.reload(inst.name)["reloaded"] is True # the blocklist reload still works + finally: + svc.remove(inst.name) diff --git a/controlplane/tests/test_docker_service.py b/controlplane/tests/test_docker_service.py index c1a29c1..526d7e9 100644 --- a/controlplane/tests/test_docker_service.py +++ b/controlplane/tests/test_docker_service.py @@ -12,7 +12,7 @@ import pytest from docker.errors import APIError, NotFound -from lmnsquid.blocklist import Blocklist +from lmnsquid.blocklist import NOAUTH_FILE_NAME, Blocklist from lmnsquid.docker_service import SPEC_LABEL, DockerService from lmnsquid.models import Instance @@ -22,6 +22,7 @@ def _service(tmp_path: Path) -> DockerService: ds.client = MagicMock() ds.secrets_dir = str(tmp_path / "secrets") ds.blocklist = Blocklist(str(tmp_path / "blocklists")) + ds.noauth = Blocklist(str(tmp_path / "blocklists"), NOAUTH_FILE_NAME) ds.container_bind_ip = "0.0.0.0" ds.log_max_size = "20m" ds.log_max_file = 5 @@ -71,6 +72,66 @@ def test_fingerprint_is_stable_and_sensitive(tmp_path: Path) -> None: spec = ds.spec_for(_inst()) blocklist_dir = str(tmp_path / "blocklists" / "s-teachers") assert spec["volumes"][blocklist_dir]["bind"] == "/etc/squid/lists" + # The noauth list lives in the same mounted directory: no mount of its own, so the + # spec (and with it every container an upgrade would otherwise replace) is unchanged. + assert len(spec["volumes"]) == 4 + assert not any("noauth" in str(k) or "noauth" in str(v) for k, v in spec.items()) + + +def test_ensure_running_creates_both_lists_before_the_container(tmp_path: Path) -> None: + ds = _service(tmp_path) + _registry(ds, {}) + seen: list[bool] = [] + lists = tmp_path / "blocklists" / "s-teachers" + + def create(*_a: Any, **_k: Any) -> MagicMock: + # The entrypoint reads noauth.domains only when it is there at container start. + seen.append((lists / "noauth.domains").is_file() and (lists / "blocked.domains").is_file()) + return _container("lmnsquid-s-teachers.new") + + ds.client.containers.create.side_effect = create + ds.ensure_running(_inst()) + assert seen == [True] + assert (lists / "noauth.domains").read_text() == "" + + +@pytest.mark.parametrize(("grep_exit", "expected"), [(0, True), (1, False)]) +def test_noauth_active_reads_the_rendered_config( + tmp_path: Path, grep_exit: int, expected: bool +) -> None: + ds = _service(tmp_path) + c = _container("lmnsquid-s-teachers") + c.exec_run.return_value = (grep_exit, b"") + _registry(ds, {"lmnsquid-s-teachers": c}) + assert ds.noauth_active("s-teachers") is expected + cmd = c.exec_run.call_args.args[0] + assert cmd == [ + "grep", + "-qF", + "/etc/squid/lists/noauth.domains", + "/run/lmnsquid/squid.conf", + ] + + +def test_noauth_active_errors(tmp_path: Path) -> None: + ds = _service(tmp_path) + stopped = _container("lmnsquid-s-teachers", running=False) + _registry(ds, {"lmnsquid-s-teachers": stopped}) + with pytest.raises(LookupError): + ds.noauth_active("s-teachers") + _registry(ds, {}) + with pytest.raises(LookupError): + ds.noauth_active("s-teachers") + broken = _container("lmnsquid-s-teachers") + broken.exec_run.return_value = (2, b"grep: /run/lmnsquid/squid.conf: No such file") + _registry(ds, {"lmnsquid-s-teachers": broken}) + with pytest.raises(RuntimeError, match="cannot read"): + ds.noauth_active("s-teachers") + refused = _container("lmnsquid-s-teachers") + refused.exec_run.side_effect = APIError("exec disabled") + _registry(ds, {"lmnsquid-s-teachers": refused}) + with pytest.raises(RuntimeError, match="EXEC"): + ds.noauth_active("s-teachers") def test_spec_rejects_a_keytab_outside_secrets_dir(tmp_path: Path) -> None: diff --git a/controlplane/tests/test_noauth.py b/controlplane/tests/test_noauth.py new file mode 100644 index 0000000..e636b99 --- /dev/null +++ b/controlplane/tests/test_noauth.py @@ -0,0 +1,211 @@ +# SPDX-FileCopyrightText: Kevin Stenzel +# SPDX-License-Identifier: GPL-3.0-or-later +"""Domains without proxy auth (ADR-016): list file, API endpoints, CLI, and `rm`.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import pytest +from starlette.testclient import TestClient +from typer.testing import CliRunner + +from lmnsquid import cli +from lmnsquid.blocklist import NOAUTH_FILE_NAME, Blocklist +from lmnsquid.config import Settings + +runner = CliRunner() +NAME = "default-school-teachers" + + +def _create(client: TestClient, auth: dict[str, str], data: dict[str, Any]) -> str: + assert client.post("/v1/instances", json=data, headers=auth).status_code == 201 + return NAME + + +# ------------------------------------------------------------------ helper + + +@pytest.mark.parametrize( + ("raw", "expected"), + [ + ("office.com", [".office.com"]), + (" Outlook.Office.COM. ", [".outlook.office.com"]), + (".sharepoint.com", [".sharepoint.com"]), + ("bücher.de", [".xn--bcher-kva.de"]), + ], +) +def test_noauth_add_normalizes(tmp_path: Path, raw: str, expected: list[str]) -> None: + na = Blocklist(str(tmp_path), NOAUTH_FILE_NAME) + assert na.add("s-teachers", raw) == expected + assert (tmp_path / "s-teachers" / "noauth.domains").read_text() == f"{expected[0]}\n" + + +@pytest.mark.parametrize( + "bad", ["", ".", "*.office.com", "office.com/x", "a b.com", "-x.com", "x_y.com", "a" * 64] +) +def test_noauth_add_rejects_invalid_names(tmp_path: Path, bad: str) -> None: + na = Blocklist(str(tmp_path), NOAUTH_FILE_NAME) + with pytest.raises(ValueError): + na.add("s-teachers", bad) + assert not (tmp_path / "s-teachers" / "noauth.domains").exists() + + +@pytest.mark.parametrize("bad", ["../x", "a/b", "..", "", "a", "a.b", "/etc"]) +def test_noauth_rejects_unsafe_instance_name(tmp_path: Path, bad: str) -> None: + na = Blocklist(str(tmp_path / "lists"), NOAUTH_FILE_NAME) + with pytest.raises(ValueError): + na.add(bad, "office.com") + assert not (tmp_path / "x").exists() and not (tmp_path / "lists").exists() + + +def test_both_lists_share_the_directory(tmp_path: Path) -> None: + root = str(tmp_path) + bl, na = Blocklist(root), Blocklist(root, NOAUTH_FILE_NAME) + bl.add("s-teachers", "blocked.example") + na.add("s-teachers", "office.com") + directory = tmp_path / "s-teachers" + assert sorted(p.name for p in directory.iterdir()) == ["blocked.domains", "noauth.domains"] + assert bl.read("s-teachers") == [".blocked.example"] + assert na.read("s-teachers") == [".office.com"] + assert (directory / "noauth.domains").stat().st_mode & 0o777 == 0o644 + na.remove("s-teachers", "office.com") + assert bl.read("s-teachers") == [".blocked.example"] # the other list is untouched + na.delete("s-teachers") # rm: the directory goes with both lists + assert not directory.exists() + + +# --------------------------------------------------------------------- API + + +def test_noauth_requires_token(client: TestClient) -> None: + assert client.get(f"/v1/instances/{NAME}/noauth").status_code == 401 + assert client.post(f"/v1/instances/{NAME}/noauth", json={"domain": "x.org"}).status_code == 401 + assert client.delete(f"/v1/instances/{NAME}/noauth/x.org").status_code == 401 + assert client.post(f"/v1/instances/{NAME}/noauth/reload").status_code == 401 + bad = {"Authorization": "Bearer nope"} + assert client.get(f"/v1/instances/{NAME}/noauth", headers=bad).status_code == 403 + + +def test_noauth_unknown_or_unsafe_instance( + client: TestClient, auth_headers: dict[str, str] +) -> None: + assert client.get("/v1/instances/x-y/noauth", headers=auth_headers).status_code == 404 + resp = client.post("/v1/instances/x-y/noauth", json={"domain": "x.org"}, headers=auth_headers) + assert resp.status_code == 404 + assert client.post("/v1/instances/x-y/noauth/reload", headers=auth_headers).status_code == 404 + # a traversal attempt never reaches the file system + resp = client.get("/v1/instances/..%2F..%2Fetc/noauth", headers=auth_headers) + assert resp.status_code in (404, 422) + + +def test_noauth_endpoints( + client: TestClient, + auth_headers: dict[str, str], + instance_data: dict[str, Any], + docker: Any, + settings: Settings, +) -> None: + name = _create(client, auth_headers, instance_data) + file = Path(settings.blocklists_dir) / name / "noauth.domains" + assert file.is_file() and file.read_text() == "" # created empty with the instance + resp = client.get(f"/v1/instances/{name}/noauth", headers=auth_headers) + assert resp.status_code == 200 and resp.json() == {"name": name, "domains": []} + + resp = client.post( + f"/v1/instances/{name}/noauth", json={"domain": "Office.com"}, headers=auth_headers + ) + assert resp.status_code == 200 and resp.json()["domains"] == [".office.com"] + assert file.read_text() == ".office.com\n" + # the blocklist is a separate file in the same directory + assert client.get(f"/v1/instances/{name}/blocklist", headers=auth_headers).json() == { + "name": name, + "domains": [], + } + + resp = client.post( + f"/v1/instances/{name}/noauth", json={"domain": "not a host"}, headers=auth_headers + ) + assert resp.status_code == 422 + + resp = client.post(f"/v1/instances/{name}/noauth/reload", headers=auth_headers) + assert resp.status_code == 200 and resp.json() == {"name": name, "reloaded": True} + assert docker.reloaded == [name] + + resp = client.delete(f"/v1/instances/{name}/noauth/office.com", headers=auth_headers) + assert resp.status_code == 200 and resp.json()["domains"] == [] + resp = client.delete(f"/v1/instances/{name}/noauth/office.com", headers=auth_headers) + assert resp.status_code == 404 + resp = client.delete(f"/v1/instances/{name}/noauth/bad%20host", headers=auth_headers) + assert resp.status_code == 422 + + client.post(f"/v1/instances/{name}/stop", headers=auth_headers) + resp = client.post(f"/v1/instances/{name}/noauth/reload", headers=auth_headers) + assert resp.status_code == 409 + + +def test_noauth_reload_refuses_an_image_that_ignores_the_list( + client: TestClient, auth_headers: dict[str, str], instance_data: dict[str, Any], docker: Any +) -> None: + data = {**instance_data, "image": "ghcr.io/faircomp/linuxmuster-squid:old"} + name = _create(client, auth_headers, data) + client.post(f"/v1/instances/{name}/noauth", json={"domain": "office.com"}, headers=auth_headers) + resp = client.post(f"/v1/instances/{name}/noauth/reload", headers=auth_headers) + assert resp.status_code == 409 + assert "does not read the noauth list" in resp.json()["detail"] + assert docker.reloaded == [] # no "reloaded" that changes nothing + # the blocklist reload is unaffected + resp = client.post(f"/v1/instances/{name}/blocklist/reload", headers=auth_headers) + assert resp.status_code == 200 + + +def test_rm_removes_the_noauth_list( + client: TestClient, + auth_headers: dict[str, str], + instance_data: dict[str, Any], + settings: Settings, +) -> None: + name = _create(client, auth_headers, instance_data) + client.post(f"/v1/instances/{name}/noauth", json={"domain": "office.com"}, headers=auth_headers) + assert client.delete(f"/v1/instances/{name}", headers=auth_headers).status_code == 204 + assert not (Path(settings.blocklists_dir) / name).exists() + + +# --------------------------------------------------------------------- CLI + + +@pytest.fixture +def patch_client(monkeypatch: pytest.MonkeyPatch, app: Any, token: str) -> None: + def factory() -> TestClient: + tc = TestClient(app) + tc.headers.update({"Authorization": f"Bearer {token}"}) + return tc + + monkeypatch.setattr(cli, "_get_client", factory) + + +def test_cli_noauth(patch_client: None, instance_data: dict[str, Any], docker: Any) -> None: + args = ["create", "--school", instance_data["school"], "--role", instance_data["role"]] + for key in ("ad_group", "realm", "visible_hostname", "keytab_secret"): + args += [f"--{key.replace('_', '-')}", instance_data[key]] + assert runner.invoke(cli.app, args).exit_code == 0 + + r = runner.invoke(cli.app, ["noauth", NAME, "add", "office.com"]) + assert r.exit_code == 0 and '".office.com"' in r.output, r.output + r = runner.invoke(cli.app, ["noauth", NAME, "list"]) + assert r.exit_code == 0 and '".office.com"' in r.output + r = runner.invoke(cli.app, ["noauth", NAME, "reload"]) + assert r.exit_code == 0 and '"reloaded": true' in r.output + r = runner.invoke(cli.app, ["noauth", NAME, "remove", "office.com"]) + assert r.exit_code == 0 and '".office.com"' not in r.output + assert runner.invoke(cli.app, ["noauth", NAME, "remove", "office.com"]).exit_code == 1 + assert runner.invoke(cli.app, ["noauth", NAME, "add", "bad host"]).exit_code == 1 + + +def test_cli_noauth_help_warns() -> None: + r = runner.invoke(cli.app, ["noauth", "--help"]) + assert r.exit_code == 0 + text = " ".join(r.output.split()) + for needle in ("exam mode", "Internetsperre", "school-subnets", "blocklist still wins"): + assert needle in text, needle diff --git a/debian/changelog b/debian/changelog index 0a095d3..c3c19f3 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,24 @@ +linuxmuster-squid (7.3.8) lmn73; urgency=medium + + * New per-instance list of domains reachable without proxy login, for + services that break behind proxy authentication (Microsoft 365): + "lmnsquid noauth list|add |remove |reload" + (GET/POST/DELETE /v1/instances/{name}/noauth, POST .../noauth/reload), + stored in /etc/linuxmuster-squid/blocklists//noauth.domains next to + the blocklist. A listed domain and its subdomains are reachable without a + ticket from --school-subnets only; the blocklist still wins; HTTPS passes + only when the TLS SNI is on the list too (another SNI or none cuts the + handshake); IP addresses never match. Empty by default, so nothing + changes until you add a domain. Warning: exam accounts reach listed + domains during an exam, the Internetsperre does not apply to them, and + the access log shows no user for them. A recommended Microsoft 365 list + with its source is in docs/operations.md. The list needs a data-plane + image that knows it: until the instances run one, "noauth reload" answers + 409 and the list has no effect. Existing instances are not restarted for + it. + + -- Kevin Stenzel Fri, 30 Oct 2026 14:58:39 +0100 + linuxmuster-squid (7.3.7) lmn73; urgency=medium * Upgrades respect a service the admin switched off. Up to 7.3.6 every diff --git a/deploy/e2e/client/Dockerfile b/deploy/e2e/client/Dockerfile index 08e983f..cac3640 100644 --- a/deploy/e2e/client/Dockerfile +++ b/deploy/e2e/client/Dockerfile @@ -6,7 +6,7 @@ FROM ubuntu:24.04 ENV DEBIAN_FRONTEND=noninteractive RUN apt-get update \ - && apt-get install -y --no-install-recommends krb5-user curl ca-certificates \ + && apt-get install -y --no-install-recommends krb5-user curl ca-certificates openssl \ && rm -rf /var/lib/apt/lists/* COPY krb5.conf /etc/krb5.conf COPY assert.sh /assert.sh diff --git a/deploy/e2e/client/assert.sh b/deploy/e2e/client/assert.sh index f4d427b..c26f5a0 100755 --- a/deploy/e2e/client/assert.sh +++ b/deploy/e2e/client/assert.sh @@ -64,6 +64,32 @@ check_via() { if [ "$code" = "$exp" ]; then echo " [PASS] $desc ($code)"; else echo " [FAIL] $desc: expected $exp, got $code"; fail=1; fi } +# HTTPS through a proxy: " " must equal +# (e.g. "200 200" = tunnel up and TLS done; "200 000" = tunnel up, TLS cut; "403 000"). +check_tunnel() { + local desc="$1" user="$2" proxy="$3" url="$4" exp="$5" got + kdestroy -q 2>/dev/null || true + if [ -n "$user" ]; then + printf '%s' "$PW" | kinit "$user@$REALM" >/dev/null 2>&1 || { echo " [FAIL] $desc: kinit $user"; fail=1; return; } + fi + got=$(curl -sk -o /dev/null -w '%{http_connect} %{http_code}' --max-time 15 \ + --proxy "$proxy" --proxy-negotiate -U : "$url" 2>/dev/null) + if [ "$got" = "$exp" ]; then echo " [PASS] $desc ($got)"; else echo " [FAIL] $desc: expected '$exp', got '$got'"; fail=1; fi +} + +# A TLS handshake through the proxy WITHOUT a ticket, with a chosen SNI (or none: "-"): +# = ok (server certificate received) or cut (no certificate: terminated). +check_tls_sni() { + local desc="$1" proxy="$2" target="$3" sni="$4" exp="$5" got=cut + local args=(-proxy "$proxy" -connect "$target") + if [ "$sni" = "-" ]; then args+=(-noservername); else args+=(-servername "$sni"); fi + kdestroy -q 2>/dev/null || true + if timeout 20 openssl s_client "${args[@]}" /dev/null | grep -q 'BEGIN CERTIFICATE'; then + got=ok + fi + if [ "$got" = "$exp" ]; then echo " [PASS] $desc ($got)"; else echo " [FAIL] $desc: expected $exp, got $got"; fail=1; fi +} + echo "== Kerberos E2E assertions ==" check "Teacher allowed" teacher1 "$ALLOWED" 200 check "Student denied" student1 "$ALLOWED" 403 @@ -81,6 +107,44 @@ check_via "Internet-gate: teacher in 'internet' -> ok" inetok "$PROXY3" check_via "Internet-gate OR: teacher in 'schule2-internet' -> ok" inetor "$PROXY3" "$ALLOWED" 200 check_via "Internet-gate: teacher in NO internet group -> deny" inetno "$PROXY3" "$ALLOWED" 403 +# Domains without proxy auth (ADR-016). squid-noauth lists secure.example.internal and +# both.example.internal (the latter also blocked); school_net = this client (172.28.0.30). +NA="http://squid4.example.internal:3128" +NA_HOST="squid4.example.internal:3128" +echo "-- noauth (client $(hostname -I 2>/dev/null | tr -d ' '), school_net 172.28.0.30/32) --" +check_via "noauth: listed HTTP, no ticket -> 200" "" "$NA" "http://secure.example.internal/" 200 +check_via "noauth: listed subdomain, no ticket -> 200" "" "$NA" "http://www.secure.example.internal/" 200 +check_via "noauth: not listed, no ticket -> 407" "" "$NA" "$ALLOWED" 407 +# squid-noauth knows 172.28.0.21 -> secure.example.internal (extra_hosts = its reverse +# cache): without dstdomain -n this IP-literal URL would match the list (200, measured). +check_via "noauth: IP literal of a listed host -> 407" "" "$NA" "http://172.28.0.21/" 407 +check_via "noauth: listed AND blocked HTTP, no ticket -> 403" "" "$NA" "http://both.example.internal/" 403 +check_tunnel "noauth: listed HTTPS, no ticket -> tunnel + TLS" "" "$NA" "https://secure.example.internal/" "200 200" +check_tunnel "noauth: not listed HTTPS, no ticket -> 407" "" "$NA" "https://secure2.example.internal/" "407 000" +# A denied CONNECT on an ssl-bump port: squid answers the CONNECT and delivers its 403 page +# inside TLS with its throwaway certificate (browsers reject it: TLS error); curl -k shows it. +check_tunnel "noauth: listed AND blocked HTTPS -> denied (403)" "" "$NA" "https://both.example.internal/" "200 403" +check_tls_sni "noauth: CONNECT listed, SNI listed -> handshake" "$NA_HOST" secure.example.internal:443 secure.example.internal ok +check_tls_sni "noauth: CONNECT listed, foreign SNI -> cut" "$NA_HOST" secure.example.internal:443 origin.example.internal cut +check_tls_sni "noauth: CONNECT listed, blocked SNI -> cut" "$NA_HOST" secure.example.internal:443 blocked.example.com cut +check_tls_sni "noauth: CONNECT listed, no SNI -> cut" "$NA_HOST" secure.example.internal:443 - cut +# With a ticket the instance behaves as before (the 200/403/407 matrix of install.md §6). +check_via "noauth instance: teacher -> 200" teacher1 "$NA" "$ALLOWED" 200 +check_via "noauth instance: student -> 403" student1 "$NA" "$ALLOWED" 403 +check_via "noauth instance: blocked (teacher) -> 403" teacher1 "$NA" "http://blocked.example.com/" 403 +check_via "noauth instance: listed+blocked (teacher) -> 403" teacher1 "$NA" "http://both.example.internal/" 403 +check_via "noauth instance: listed (teacher) -> 200" teacher1 "$NA" "http://secure.example.internal/" 200 +check_tunnel "noauth instance: HTTPS not listed (teacher) -> 200" teacher1 "$NA" "https://secure2.example.internal/" "200 200" +# An empty list (squid: the image's empty file) and a directory without the file (squid2, +# as an older control plane mounts it) change nothing: +check_via "empty list: listed elsewhere, no ticket -> 407" "" "$PROXY" "http://secure.example.internal/" 407 +check_via "no list file: no ticket -> 407" "" "$PROXY2" "http://secure.example.internal/" 407 +# The pinned image of installed systems with a directory that holds noauth.domains: +PROXY5="http://squid5.example.internal:3128" +check_via "pinned image + noauth.domains: no ticket -> 407" "" "$PROXY5" "http://secure.example.internal/" 407 +check_via "pinned image + noauth.domains: teacher -> 200" teacher1 "$PROXY5" "http://secure.example.internal/" 200 +check_via "pinned image + noauth.domains: student -> 403" student1 "$PROXY5" "$ALLOWED" 403 + echo if [ "$fail" -eq 0 ]; then echo "E2E: ALL ASSERTIONS OK"; else echo "E2E: FAILED"; fi exit "$fail" diff --git a/deploy/e2e/docker-compose.yml b/deploy/e2e/docker-compose.yml index 609f05d..0e6abec 100644 --- a/deploy/e2e/docker-compose.yml +++ b/deploy/e2e/docker-compose.yml @@ -3,7 +3,8 @@ # SPDX-License-Identifier: GPL-3.0-or-later # Kerberos E2E stack for linuxmuster-squid (heavy tier / crabbox). -# Proves: teacher->200, student->403, blocked domain->403, no ticket->407. +# Proves: teacher->200, student->403, blocked domain->403, no ticket->407, and the +# domains without proxy auth (ADR-016) incl. their negative cases. # Orchestrated by scripts/tests/e2e_kerberos.sh (brings services up in order, # creates fixtures and runs the assertions). Realm/domain: EXAMPLE.INTERNAL. name: lmnsquid-e2e @@ -83,7 +84,8 @@ services: ipv4_address: 172.28.0.11 volumes: - shared:/shared:ro - - ./blocked.domains:/etc/squid/lists/blocked.domains:ro + # The list directory of a control plane without ADR-016 (no noauth.domains). + - ./lists-legacy:/etc/squid/lists:ro read_only: true tmpfs: ["/run", "/tmp", "/var/log/squid", "/var/spool/squid"] cap_drop: ["ALL"] @@ -119,6 +121,66 @@ services: cap_add: ["SETUID", "SETGID", "DAC_OVERRIDE", "CHOWN"] security_opt: ["no-new-privileges:true"] + # Domains without proxy auth (ADR-016): the list directory mounted like the control plane + # does it; school_net is the test client alone, so a request from any other container is + # "outside the school subnets". + squid-noauth: + image: linuxmuster-squid:dev + hostname: squid4.example.internal + depends_on: [samba-dc, squid] + environment: + INSTANCE: "e2e-noauth" + VISIBLE_HOSTNAME: "squid4.example.internal" + REALM: "EXAMPLE.INTERNAL" + AD_GROUP: "teachers" + SCHOOL_SUBNETS: "172.28.0.30/32" + KEYTAB: "/shared/squid.keytab" + CACHE_SIZE_MB: "200" + dns: ["172.28.0.2"] + # squid loads /etc/hosts into its reverse cache too: the IP-literal test needs the + # address to map back to a listed name (proves dstdomain -n). + extra_hosts: ["secure.example.internal:172.28.0.21"] + networks: + e2e: + ipv4_address: 172.28.0.13 + volumes: + - shared:/shared:ro + - ./lists-noauth:/etc/squid/lists:ro + read_only: true + tmpfs: ["/run", "/tmp", "/var/log/squid", "/var/spool/squid"] + cap_drop: ["ALL"] + cap_add: ["SETUID", "SETGID", "DAC_OVERRIDE", "CHOWN"] + security_opt: ["no-new-privileges:true"] + + # The image installed systems run (DEFAULT_IMAGE of the control plane, set by + # e2e_kerberos.sh) with a list directory that holds noauth.domains: an image that does not + # know the list must ignore it and keep asking for a ticket. + squid-pinned: + # (default only for the other smokes that start the DC from this file) + image: ${LMNSQUID_PINNED_IMAGE:-linuxmuster-squid:dev} + hostname: squid5.example.internal + depends_on: [samba-dc, squid] + environment: + INSTANCE: "e2e-pinned" + VISIBLE_HOSTNAME: "squid5.example.internal" + REALM: "EXAMPLE.INTERNAL" + AD_GROUP: "teachers" + SCHOOL_SUBNETS: "0.0.0.0/0" + KEYTAB: "/shared/squid.keytab" + CACHE_SIZE_MB: "200" + dns: ["172.28.0.2"] + networks: + e2e: + ipv4_address: 172.28.0.14 + volumes: + - shared:/shared:ro + - ./lists-noauth:/etc/squid/lists:ro + read_only: true + tmpfs: ["/run", "/tmp", "/var/log/squid", "/var/spool/squid"] + cap_drop: ["ALL"] + cap_add: ["SETUID", "SETGID", "DAC_OVERRIDE", "CHOWN"] + security_opt: ["no-new-privileges:true"] + # Allowed target (returns 200). origin: image: nginx:alpine diff --git a/deploy/e2e/lists-legacy/blocked.domains b/deploy/e2e/lists-legacy/blocked.domains new file mode 100644 index 0000000..1966405 --- /dev/null +++ b/deploy/e2e/lists-legacy/blocked.domains @@ -0,0 +1,7 @@ +# SPDX-FileCopyrightText: Kevin Stenzel +# +# SPDX-License-Identifier: GPL-3.0-or-later + +# A list directory as a control plane without ADR-016 mounts it: blocked.domains only, +# no noauth.domains. The image must start and behave as before (no domain without auth). +blocked.example.com diff --git a/deploy/e2e/lists-noauth/blocked.domains b/deploy/e2e/lists-noauth/blocked.domains new file mode 100644 index 0000000..f64da8c --- /dev/null +++ b/deploy/e2e/lists-noauth/blocked.domains @@ -0,0 +1,8 @@ +# SPDX-FileCopyrightText: Kevin Stenzel +# +# SPDX-License-Identifier: GPL-3.0-or-later + +# Blocklist of the squid-noauth E2E instance (the directory is mounted at /etc/squid/lists, +# as the control plane does). both.example.internal is on both lists: the blocklist wins. +.blocked.example.com +.both.example.internal diff --git a/deploy/e2e/lists-noauth/noauth.domains b/deploy/e2e/lists-noauth/noauth.domains new file mode 100644 index 0000000..f112f15 --- /dev/null +++ b/deploy/e2e/lists-noauth/noauth.domains @@ -0,0 +1,7 @@ +# SPDX-FileCopyrightText: Kevin Stenzel +# +# SPDX-License-Identifier: GPL-3.0-or-later + +# Domains reachable without proxy auth for the squid-noauth E2E instance (ADR-016). +.secure.example.internal +.both.example.internal diff --git a/docs/architecture.md b/docs/architecture.md index e5f4b86..f911e96 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -74,6 +74,16 @@ Status document. Keep it up to date with every substantive change (see (the API endpoints below); `reload` = `squid -k reconfigure` inside the container (docker exec), no restart. Entries are `.example.org` = domain + subdomains. UT Capitole/Toulouse category lists via `blocklist-refresh.sh` (host cron) into the same file. +- **Domains without proxy authentication per instance (ADR-016, optional):** + `//noauth.domains` in the same mounted directory (no extra mount, the + container spec is unchanged), managed via `lmnsquid noauth add|remove|list|reload`. + A listed domain is reachable from `school_net` without a ticket; the blocklist wins; a + CONNECT on this path must carry a listed SNI (no SNI → terminated); the list ACL is + `dstdomain -n` (no reverse DNS). Empty by default (= the old behaviour). The entrypoint uses + an empty stand-in when the mounted directory has no `noauth.domains` (older control plane); + `noauth reload` refuses (409) when the running container does not read the list (an image + from before ADR-016). Price: no identity for these requests (exam mode, Internetsperre; + threat model T16). - **Hardening:** `cache_effective_user proxy`; Keytab as a secret (tmpfs, readable by `proxy`); `KRB5CCNAME=FILE:` (the kernel-keyring ccache fails when unprivileged); `read_only` rootfs + tmpfs; `cap_drop: ALL` (+ minimal SETUID/SETGID/DAC_OVERRIDE); @@ -84,9 +94,10 @@ Status document. Keep it up to date with every substantive change (see - **REST-API (FastAPI/uvicorn):** CRUD over instance definitions + lifecycle actions (`start/stop/restart/update/rollback/status/logs`) + the - per-instance blocklist (`GET/POST/DELETE …/blocklist`, `POST …/blocklist/reload`). - `DELETE /v1/instances/{name}` removes container, cache + log volumes and the - blocklist (`?keep_logs=true` keeps the log volume). + per-instance blocklist (`GET/POST/DELETE …/blocklist`, `POST …/blocklist/reload`) and + noauth list (`GET/POST/DELETE …/noauth`, `POST …/noauth/reload`). + `DELETE /v1/instances/{name}` removes container, cache + log volumes and both + lists (`?keep_logs=true` keeps the log volume). `HTTPBearer(auto_error=False)` + `hmac.compare_digest`; app-wide dependency; bind **127.0.0.1 only** (in-app TLS is NOT implemented; off-host only via an operator-owned TLS reverse proxy — on a non-loopback bind `main.py` warns, because the @@ -119,5 +130,10 @@ Status document. Keep it up to date with every substantive change (see 2. Squid `407 Proxy-Authenticate: Negotiate` → client sends a Kerberos ticket (SSO). 3. `negotiate_kerberos_auth` checks against the Keytab → username (`%LOGIN`). 4. `ext_kerberos_ldap_group_acl` checks group membership (ticket/LDAP, SRV). -5. `http_access` (first-match-wins): `deny !authenticated` → `deny blocked` → - `allow authenticated role_group school_net` → `deny all`. Result 200 / 403 / 407. +5. `http_access` (first-match-wins): the port/CONNECT/IP-literal denies → + `deny noauth+blocked` → `allow noauth school_net` (no ticket asked; empty list = never) → + `deny !authenticated` → `deny blocked` → `allow authenticated role_group school_net` → + `deny all`. Result 200 / 403 / 407. +6. HTTPS (CONNECT, no decryption): `ssl_bump` peeks at the ClientHello (step 1) and at step 2 + terminates a blocked SNI, and for a CONNECT to a noauth domain an SNI that is not on the + noauth list or a missing SNI; everything else is spliced. diff --git a/docs/decisions.md b/docs/decisions.md index 15d1688..2005735 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -205,6 +205,38 @@ rejects `--python-platform`, so the lock is compiled on linux/x86_64 (CI, Renova with `--python-version=3.12`; environment markers then evaluate as on the target. **Price:** dependency updates need a merged PR; the lock is regenerated, never hand-edited. +### ADR-016 — Domains without proxy authentication, per instance, beside the blocklist +**Status:** Proposed (2026-10-30, Kevin's request: Microsoft 365 through the proxy, without a +login, no client-side bypass). **Decision:** each instance may list domains in +`//noauth.domains`, managed like the blocklist (`lmnsquid noauth +list|add|remove|reload`, entries `.example.org` = domain + subdomains). A request to a listed +domain from `school_net` is allowed **without** proxy authentication; the rules, in order: +the existing denies (Safe_ports, CONNECT only to 443, no IP literals on CONNECT) stay first; +`deny noauth_domains blocked_domains` (the blocklist wins, 403); `allow noauth_domains +school_net`; then the unchanged chain (`deny !authenticated`, …). The list ACL is `dstdomain +-n`: no reverse DNS for IP-literal URLs, otherwise whoever controls a PTR record could put an +address on the list (measured: without `-n`, `http:///` with a PTR to a listed name got +200 without a ticket). A CONNECT that came through this way may not hand its tunnel to another +name: at `SslBump2` it is terminated when the SNI is not on the list (`ssl::server_name +--client-requested`) or when the ClientHello carries no SNI (`external_acl_type … %ssl::>sni` +with a four-line helper, because `ssl::server_name` falls back to the CONNECT host without +SNI). Still no decryption (peek, splice, terminate only; ADR-002). The empty list is the +default and there is no built-in content; a recommended Microsoft 365 list lives in +`docs/operations.md` with its source. The file sits in the directory already mounted at +`/etc/squid/lists` (ADR-014), so the container spec does not change: an upgrade replaces no +container for it. +**Why:** Microsoft lists proxy authentication among the techniques that break Microsoft 365 +connections and asks to exempt its required endpoints; a client-side bypass (proxy exceptions ++ firewall rules) was not wanted, the traffic should still pass the proxy's domain filter. +**Price (threat model T16):** a listed domain is reachable by everyone in the school subnets +without identity: exam accounts reach it in exam mode, the Internetsperre does not apply to it, +and the access log shows `-` instead of a user for it. **Rollout:** the template is in the +data-plane image; until `DEFAULT_IMAGE` points at an image with this template, the list is +ignored and `lmnsquid noauth reload` answers 409 instead of reporting a reload that +changes nothing (it checks the rendered `squid.conf` in the container). An image with the +template next to a directory without `noauth.domains` (an older control plane) uses an empty +list. + --- ## Site facts to be verified (P0, enter with source/date) diff --git a/docs/install.md b/docs/install.md index cb335c9..ed64f77 100644 --- a/docs/install.md +++ b/docs/install.md @@ -156,12 +156,38 @@ HTTP to a blocked domain → **403**. HTTPS to a blocked domain is **cut at the connection/TLS error, not a block page. Details, the file path and UT-Capitole category lists: [`operations.md`](operations.md#blocklist-per-instance). -## 8. Clients +## 8. Domains without proxy authentication (optional) + +Some services break behind proxy authentication; Microsoft names it among the techniques that +cause Microsoft 365 connection problems. Instead of a bypass on the clients, an instance can let +listed domains through **without a ticket**, still through the proxy (domain filter, no +decryption): + +```bash +lmnsquid noauth all-students add sharepoint.com # -> [".sharepoint.com"]: domain + subdomains +lmnsquid noauth all-students list +lmnsquid noauth all-students reload # squid re-reads the lists (no restart) +``` + +> **Warning:** a listed domain is open to **everyone in `--school-subnets`** without identity: +> exam accounts reach it during an **exam** (OneDrive, Teams, Outlook …), the **Internetsperre** +> does not apply to it, and the access log shows `-` instead of a user. Keep `--school-subnets` +> tight (the default `0.0.0.0/0` means everyone who reaches the port) and the list short. + +The blocklist still wins (403 / TLS cut). HTTPS passes only when the TLS SNI is on the list +as well; a different SNI or none cuts the handshake. Check from a client in `` without +a ticket: `kdestroy; req $P:3129 https:///` gives 200, +`req $P:3129 http://example.com/` still 407. The recommended Microsoft 365 list with its source +and the details: [`operations.md`](operations.md#domains-without-proxy-authentication-per-instance). +`reload` answers 409 while the instance still runs an image that does not know the list (the +data-plane image must be updated first). + +## 9. Clients Assign the proxy per role by GPO (silent Kerberos SSO) and force the proxy at the firewall: [`deployment-gpo.md`](deployment-gpo.md). Clients must use the **FQDN**, never the IP. -## 9. Updates +## 10. Updates ```bash apt-get install -y ./linuxmuster-squid__amd64.deb # restarts the service, then update-all (*) @@ -182,10 +208,10 @@ Verify with `docker inspect -f '{{range .Mounts}}{{.Destination}} {{end}}' lmnsq `journalctl -t linuxmuster-squid` and print a `WARNING:` in the apt output naming any instance that could not be brought up; the apt transaction itself never fails over them. -## 10. Removing +## 11. Removing ```bash -lmnsquid rm all-students # container, cache + log volumes, blocklist, definition +lmnsquid rm all-students # container, cache + log volumes, both lists, definition lmnsquid rm all-students --keep-logs # same, but keeps the access-log volume lmnsquid-logs-all-students ``` diff --git a/docs/operations.md b/docs/operations.md index 49a2cfb..0095d0f 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -38,7 +38,7 @@ lmnsquid list lmnsquid status default-school-teachers lmnsquid stop|start|restart default-school-teachers lmnsquid logs default-school-teachers --tail 100 -lmnsquid rm default-school-teachers # + cache/log volumes, blocklist, .prev; --keep-logs keeps the log volume +lmnsquid rm default-school-teachers # + cache/log volumes, both lists, .prev; --keep-logs keeps the log volume ``` The keytab must already be present as secret `` in `secrets_dir` (`/etc/linuxmuster-squid/secrets`) — see `keytab-and-dns.md`. @@ -53,7 +53,8 @@ The keytab must already be present as secret `` in `secrets_dir` msg-internet`) so it covers **visitors** too and works with global `role-teacher`/`role-student` proxies — a user passes if in **any** listed group. Omit to enforce the role group only. - **`lmnsquid rm `** removes the container, the cache volume, the **log volume** - (`lmnsquid-logs-` = access-log history, personal data), the blocklist directory and + (`lmnsquid-logs-` = access-log history, personal data), the list directory (blocklist + and noauth list) and the definition (+ its `.prev` rollback note). `--keep-logs` (API: `?keep_logs=true`) keeps the log volume, e.g. for a retention obligation; delete it later with `docker volume rm lmnsquid-logs-`. The keytab in `secrets/` stays (operator-managed). @@ -93,6 +94,90 @@ lmnsquid blocklist default-school-students reload # squid re-reads - **Upgraded from 7.3.0?** Containers created before 7.3.1 run without the mount until recreated once; the postinst runs `lmnsquid reconcile` for that (see Updates). +## Domains without proxy authentication (per instance) + +Each instance can let a list of domains through **without proxy authentication** (ADR-016), +`/etc/linuxmuster-squid/blocklists//noauth.domains` (created empty with the instance), +seen by the container at `/etc/squid/lists/noauth.domains` — the same mounted directory as the +blocklist. Empty = every request needs a ticket, as before. + +``` +lmnsquid noauth all-students add sharepoint.com # -> [".sharepoint.com"]: the domain and all subdomains +lmnsquid noauth all-students list +lmnsquid noauth all-students remove sharepoint.com +lmnsquid noauth all-students reload # squid re-reads both lists: no restart +``` + +> **⚠️ Warning — read before adding anything:** +> - **Exam mode:** exam accounts (and every other device in the school subnets) reach a listed +> domain without a ticket during an exam, e.g. OneDrive, Teams and Outlook with the +> Microsoft 365 list below, although the proxy refuses them everything else. +> - **Internetsperre:** does not apply to listed domains (without identity there is no group +> check); a user removed from the `internet` group still reaches them. +> - **No user in the log:** these requests appear as `-` in the access log. +> - **Only from `--school-subnets`:** from anywhere else a ticket is still required. With the +> default `0.0.0.0/0` that is everyone who can reach the proxy port, so set the real client +> subnets (`lmnsquid edit --school-subnets …`) before using the list. + +- **Rules:** the blocklist wins (a domain on both lists gets **403**; HTTPS is cut). The + existing rules stay in front (Safe_ports, CONNECT only to 443, no IP literals on CONNECT); an + IP-literal URL never matches the list (`dstdomain -n`, no reverse DNS). HTTPS through the + list passes only when the TLS **SNI** is on the list too; a different SNI or a ClientHello + without SNI is **terminated** (no domain fronting into other names; no decryption, ADR-002). + With a ticket nothing changes (200 / 403 / 407 as before). +- `add`/`remove` write the file (same format and rules as the blocklist, one `.domain` per + line, `#` comments allowed when hand-edited) and take effect on the next **`reload`**. API: + `GET/POST /v1/instances/{name}/noauth`, `DELETE …/noauth/{domain}`, `POST …/noauth/reload`. +- **`reload` answers 409** when the running container does not read the list: its image + predates the list (update the instance to a data-plane image that has it, `lmnsquid update + `), or it was started while its list directory had no `noauth.domains` (only possible + with an older control plane; `lmnsquid restart `). Without that check a reload would + report success and change nothing. `blocklist reload` is not affected. +- **What `lmnsquid rm` removes:** the directory with both lists. + +### Recommended list: Microsoft 365 + +Microsoft asks to exempt the **required** Microsoft 365 endpoints from proxy authentication and +inspection and does not support allowing only some of them ("Routing connections through +network infrastructure applying its own authentication such as proxy authentication" is listed +among the configurations "known to cause connectivity issues"; Microsoft 365 network +connectivity principles, learn.microsoft.com, updated 2026-08-31). Source of the entries: +the Microsoft 365 endpoints web service, `https://endpoints.office.com/endpoints/worldwide` +(service areas Common, Exchange, SharePoint, Skype/Teams; version `2026103000`, fetched +2026-10-30): every **required** item of category **Optimize** or **Allow** with TCP ports +80/443 (ids 1, 9, 12, 31, 46, 56, 64). `*.x` and `x` are both one entry `x` here (the list +always covers the domain and its subdomains, which is slightly wider than `*.x`); names under +an entry already listed are left out (`ccs.` and `device.login.microsoftonline.com`, +`api.passwordreset.microsoftonline.com`), squid only warns about them. + +``` +for d in outlook.cloud.microsoft outlook.office.com outlook.office365.com protection.outlook.com \ + lync.com teams.cloud.microsoft teams.microsoft.com sharepoint.com \ + officeapps.live.com online.office.com office.live.com \ + auth.microsoft.com msftidentity.com account.activedirectory.windowsazure.com \ + accounts.accesscontrol.windows.net adminwebservice.microsoftonline.com \ + autologon.microsoftazuread-sso.com clientconfig.microsoftonline-p.net \ + companymanager.microsoftonline.com graph.microsoft.com \ + graph.windows.net login-us.microsoftonline.com login.microsoft.com \ + login.microsoftonline-p.com login.microsoftonline.com login.windows.net \ + logincert.microsoftonline.com loginex.microsoftonline.com nexus.microsoftonline-p.com \ + passwordreset.microsoftonline.com protection.office.com security.microsoft.com \ + compliance.microsoft.com defender.microsoft.com purview.microsoft.com; do + lmnsquid noauth all-students add "$d" >/dev/null +done +lmnsquid noauth all-students list && lmnsquid noauth all-students reload +``` + +- **Not on the list, on purpose:** items for mail protocols and SMTP (ids 2 and 10: TCP 25, + 143, 587, 993, 995 — not HTTP proxy traffic) and Teams media (id 11: UDP 3478–3481, no URLs; + a forward proxy cannot carry UDP — allow it at the firewall if Teams calls are needed). + Endpoints of category **Default** keep going through the proxy with authentication. +- **The list changes** (Microsoft recommends updating it automatically). Compare from time to + time with `curl -s 'https://endpoints.office.com/version/worldwide?clientrequestid='` + and the endpoints call above; for a client that still fails, look for `TCP_DENIED/407` lines + of Microsoft names in `lmnsquid logs ` before adding anything: every entry widens what + is reachable without identity. + ## Updates (digest-pinned, health auto-rollback) ``` @@ -235,7 +320,7 @@ Access logs show **who visited/was blocked from which site** = personal data ## Backup - `/etc/linuxmuster-squid/config.yml` (API token!), `/etc/linuxmuster-squid/secrets/` (keytabs), - `/etc/linuxmuster-squid/blocklists/` (per-instance blocklists), + `/etc/linuxmuster-squid/blocklists/` (per-instance blocklists and noauth lists), - `instances_dir` (`/var/lib/linuxmuster-squid/instances/*.yaml` — git-versioned = change log; the postinst creates and configures the repo as its owner `lmnsquid`, with git's background maintenance off; every create/edit/update/rm is one commit: diff --git a/docs/references.md b/docs/references.md index 564a16a..0345e39 100644 --- a/docs/references.md +++ b/docs/references.md @@ -106,3 +106,28 @@ research workflows on **2026-07-02** (confidence in parentheses). — https://github.com/containrrr/watchtower · https://docs.renovatebot.com/docker/ - `.deb` reproducible via dh-virtualenv (no pip-in-postinst). (high) — https://dh-virtualenv.readthedocs.io/en/latest/ + +## Domains without proxy authentication (ADR-016, checked 2026-10-30) + +- `dstdomain`: "a reverse lookup is tried if a IP based URL is used and no match is found"; + `-n` disables lookups (mismatch instead). `ssl::server_name`: no DNS lookups; without SNI the + CONNECT target is used instead; `--client-requested` = the SNI. `at_step SslBump2` = after the + ClientHello. Quoted `"file"` = one entry per line. (high) + — https://www.squid-cache.org/Doc/config/acl/ +- `external_acl_type`: FORMAT takes logformat codes, unavailable values expand to `-`, `%DATA` + (the ACL arguments) is `-` without any; replies `OK`/`ERR`/`BH`. `%ssl::>sni` = "SSL client + SNI sent to Squid". (high) — https://www.squid-cache.org/Doc/config/external_acl_type/ · + https://www.squid-cache.org/Doc/config/logformat/ +- `ssl_bump` takes fast and slow ACLs; first match wins; actions peek/splice/terminate. (high) + — https://www.squid-cache.org/Doc/config/ssl_bump/ +- Measured with squid-openssl 6.14 (the image): the helper line is ` `, i.e. `- -` + without SNI; a missing ACL file is an `ERROR` plus an empty ACL, not fatal; without `-n` an + IP-literal URL whose address maps back to a listed name (squid's reverse cache) matched + `dstdomain`; a denied CONNECT on an ssl-bump port gets `200` and squid's 403 page inside TLS + with the throwaway certificate. (measured, E2E) +- Microsoft 365: proxy authentication is among the configurations "known to cause + connectivity issues"; exempt the full set of required endpoints, selective allow-listing is + not supported. (high) — + https://learn.microsoft.com/en-us/microsoft-365/enterprise/microsoft-365-network-connectivity-principles + (updated 2026-08-31) · endpoints web service https://endpoints.office.com/endpoints/worldwide + (version 2026103000). diff --git a/docs/test-strategy.md b/docs/test-strategy.md index cf9938c..a419c16 100644 --- a/docs/test-strategy.md +++ b/docs/test-strategy.md @@ -89,7 +89,8 @@ the `/test` skill). **Core proof** (P1), assertions on `%{http_code}`: | No ticket | `kdestroy`; request | **407** | The 403-vs-407 split is the actual proof (authenticated-but-unauthorized -vs. not-authenticated). The compose stack bind-mounts `deploy/e2e/blocked.domains`; the +vs. not-authenticated). The compose stack bind-mounts `deploy/e2e/blocked.domains` (and the +directories `deploy/e2e/lists-noauth/`, `deploy/e2e/lists-legacy/` for the noauth cases); the **product path** (`lmnsquid blocklist add … && … reload` on a managed instance → 403) is proven against a real domain in the lab (7.3.1 campaign fix) and is part of the acceptance list in `deployment-gpo.md`. @@ -118,6 +119,24 @@ acceptance list in `deployment-gpo.md`. back to `lmnsquid`, history kept; a service the admin disabled (`systemctl disable --now`) before the upgrade from the release stays disabled and stopped across two upgrades in a row, with the "instances not updated" line in the apt output (CI upgrade-smoke). +- **Domains without proxy auth (ADR-016)** (E2E, instance `squid-noauth` with the list + directory mounted like the control plane, `school_net` = the test client only): listed + domain and subdomain without ticket → 200, HTTPS → tunnel + TLS; unlisted → 407 (HTTP and + CONNECT); listed **and** blocked → 403 (HTTP; HTTPS: squid's 403 inside TLS); CONNECT to a + listed name with a foreign SNI, a blocked SNI or **no** SNI → TLS cut (openssl s_client); + IP-literal URL whose address maps back to a listed name → 407 (`dstdomain -n`); request from + outside `school_net` → 407 (HTTP and CONNECT, squidclient from another container); with a + ticket the 200/403/407 matrix is unchanged on that instance. Empty list (`squid`) and a list + directory without `noauth.domains` (`squid2`, as an older control plane mounts it) → 407 as + before, the latter logs a notice. The pinned image of installed systems (`squid-pinned`, + `DEFAULT_IMAGE` from `models.py`) next to a directory with `noauth.domains`: unchanged + (407 without ticket, 200/403 with). Counter-runs: without `-n` the IP-literal case and + without the SNI rule the foreign-SNI case fail. `cp-docker-it` (real DockerService): both + list files exist before the container starts, the new image reads the noauth list and + reloads, the pinned image is healthy with it and `noauth_active` is false. Unit: name + normalization (IDNA, leading dot, invalid names), unsafe instance names, API 401/403 without + or with a wrong token, 404/422, reload 409 when the container ignores the list or is stopped, + CLI and `--help` warnings. - **P10:** keytab perms; manager ACL not reachable externally; API bind ≠ 0.0.0.0; bypass/traversal; DC outage does not stall (ttl/grace). diff --git a/docs/threat-model.md b/docs/threat-model.md index 7ae519f..7484b93 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -32,10 +32,11 @@ Risks + countermeasures. Grows per roadmap phase; the negative tests for it live | T9 | **Unattended bad update → dead school** | digest pin, human-merge (no automerge; bumps by hand while Renovate is disabled), health-gated update + auto-rollback, known-good persisted | E2E: bad image → rollback | | T10 | **DC/LDAP outage stalls every request** | external ACL `ttl/negative_ttl/grace` + tune helper concurrency | load test P10 | | T11 | **Container escape/privilege** | `read_only` rootfs + tmpfs, `cap_drop: ALL` (minimal caps), non-root `proxy`, manager ACL localhost | hardening review P10 | -| T12 | **Exam mode** | `-exam` in no teachers/students group → ACL denies; lmn7 disables the proxy in exam mode | docs + optional test | +| T12 | **Exam mode** | `-exam` in no teachers/students group → ACL denies; lmn7 disables the proxy in exam mode. **Not** for domains on the noauth list (T16) | docs + optional test | | T13 | **Access logs = personal data** (browsing behavior, GDPR) | retention = `log_retention_days` (documented deletion period, default 30), gzip-rotated; access logging disableable per instance (`access_log_enabled:false`); log access only via API token, queries into the audit log; no secrets in the log; `lmnsquid rm` deletes the log volume with the instance (`--keep-logs` only on request) | retention/rotation smoke; `access_log none` render test; rm test | | T14 | **SNI filter bypass: ECH / QUIC / DoH** | **ECH** (Encrypted Client Hello) encrypts the SNI → splice filter blind; **QUIC/HTTP3** runs over **UDP 443**, bypassing the TCP forward proxy; **DoH** circumvents the DNS view. Limit of name-based filtering — the proxy alone does not close this. **Mitigate at the network edge:** OPNsense **block UDP 443** (forces TCP/443 through the proxy), block known DoH resolvers + `use-application-dns.net` (canary disables Firefox DoH); observe ECH adoption. For real completeness you would need SSL interception (a deliberate non-goal, ADR-002). | firewall review at the site; documented limit | | T15 | **Poisoned build input** (supply chain of the `.deb`, installed as root) — a compromised or silently changed PyPI release, build image or GitHub Action lands in the package | Python deps (pip included) only from `controlplane/requirements.lock` with SHA-256 hashes (`--require-hashes --no-deps --only-binary :all:`, wheels only), nothing younger than 7 days picked (`--exclude-newer=P7D`), own wheel built without build isolation from a hashed setuptools; build container, data-plane base image and actions pinned by digest/commit SHA; changes only via human-merged PRs (raised by hand while Renovate is disabled); releases published draft-first (immutable releases) with the asset hash checked against the build (ADR-015) | `lock-deps.sh --gate` first in the fast tier AND in every build, before anything from a lock is installed or run (no venv `bin/` on PATH; `/usr/bin/python3 -I`; every entry point restarts itself under `env -i` through `/bin/bash -p` with an allowlist, `packaging/clean-env.sh`: fixed PATH and `LC_ALL`, the caller's `HOME`, `TMPDIR`, proxies, CA bundles (`SSL_CERT_FILE`, `SSL_CERT_DIR`, `REQUESTS_CA_BUNDLE`, `PIP_CERT`) and the repository's own switches, nothing else; not undone, because it ran before the restart: the caller's first bash for `bash