Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
69b3891
test(e2e): run the Docker suites as a user of the docker group
Oct 30, 2026
e00cc00
feat(noauth): per-instance domains reachable without proxy authentica…
Oct 30, 2026
1eac730
fix(provision-keytab): stop when host/ of another account aliases the…
Oct 30, 2026
887b501
docs(install): add the proxy to devices.csv with the role iponly
Oct 30, 2026
ddc3c73
fix(discover-ad-facts): read the realm on the DC, fill in the proxy FQDN
Oct 30, 2026
5a516c8
fix(lists): never let a covered entry stop squid, in either list
Oct 30, 2026
652471c
fix(noauth): refuse IP literals and single labels as noauth entries
Oct 30, 2026
82f79f4
fix(noauth): accurate 409 text; old-image checks no longer follow DEF…
Oct 30, 2026
4a14603
fix(discover-ad-facts): compare REALM case-insensitively, print it up…
Oct 30, 2026
285956a
fix(provision-keytab): say why when ldbsearch fails
Oct 30, 2026
13a9539
test(run.sh): run the provision-keytab and discover-ad-facts smokes i…
Oct 30, 2026
468efe4
fix(noauth): refuse IPv4 in every inet_aton notation, identically in …
Nov 2, 2026
10a4d0b
fix(lists): accept CRLF line endings in a hand-edited noauth.domains
Nov 2, 2026
50b6851
fix(lists): report what lists-sync.sh ignored on reload, in the answe…
Nov 2, 2026
7ae793f
fix(noauth): the 409 also names lmnsquid update for after the release
Nov 2, 2026
70647b5
docs(controlplane): correct two comments that predate lists-sync.sh
Nov 2, 2026
417defe
docs: HTTPS to a blocked name is answered by squid inside TLS, not cut
Nov 2, 2026
549946c
docs(install): keep the proxy's /etc/hosts line on cloud-init hosts
Nov 2, 2026
601d791
docs(noauth): name the known limits: public suffixes, raw list view, …
Nov 2, 2026
fcd3581
test(e2e): move the E2E network out of Docker's default address pools
Nov 2, 2026
b1093b7
test(cli): match the noauth --help warnings independent of the terminal
Nov 2, 2026
00af2c8
ci(upgrade-smoke): expect the empty noauth.domains the upgrade creates
Nov 2, 2026
3a7b33b
ci(upgrade-smoke): expect noauth.domains only in the instance list dirs
Nov 2, 2026
486254e
docs: correct the access-log pattern of a blocked HTTPS host and the …
Nov 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -569,9 +569,25 @@ jobs:
test -s before/sha256
sudo sha256sum -c before/sha256

# 3. modes and ownership in the config tree unchanged
# 3. modes and ownership in the config tree unchanged, with one expected addition
# (since 7.3.8, ADR-016): update-all/reconcile create an EMPTY noauth.domains next
# to every instance's blocklists/<name>/blocked.domains, with that file's mode and
# owner. It is expected only where the previous release had none; any other
# difference (also next to the admin's own /etc/linuxmuster-squid/blocked.domains)
# still fails.
sudo find /etc/linuxmuster-squid -printf '%m %U:%G %p\n' | sort > after/etc-modes
diff -u before/etc-modes after/etc-modes
cp before/etc-modes before/etc-modes.expected
while read -r mode owner path; do
noauth_file="${path%/blocked.domains}/noauth.domains"
if awk -v p="$noauth_file" '$3 == p { found = 1 } END { exit !found }' before/etc-modes; then
continue
fi
echo "$mode $owner $noauth_file" >> before/etc-modes.expected
sudo test -f "$noauth_file" || { echo "missing: $noauth_file"; exit 1; }
if sudo test -s "$noauth_file"; then echo "not empty: $noauth_file"; exit 1; fi
done < <(awk '$3 ~ /^\/etc\/linuxmuster-squid\/blocklists\/[^\/]+\/blocked\.domains$/' before/etc-modes)
sort -o before/etc-modes.expected before/etc-modes.expected
diff -u before/etc-modes.expected after/etc-modes

# 4. the instance record is written by the software, not the admin, and the
# postinst rewrites `image:` on purpose (update-all). Every key the admin
Expand Down
20 changes: 18 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -480,9 +480,25 @@ jobs:
test -s before/sha256
sudo sha256sum -c before/sha256

# 3. modes and ownership in the config tree unchanged
# 3. modes and ownership in the config tree unchanged, with one expected addition
# (since 7.3.8, ADR-016): update-all/reconcile create an EMPTY noauth.domains next
# to every instance's blocklists/<name>/blocked.domains, with that file's mode and
# owner. It is expected only where the previous release had none; any other
# difference (also next to the admin's own /etc/linuxmuster-squid/blocked.domains)
# still fails.
sudo find /etc/linuxmuster-squid -printf '%m %U:%G %p\n' | sort > after/etc-modes
diff -u before/etc-modes after/etc-modes
cp before/etc-modes before/etc-modes.expected
while read -r mode owner path; do
noauth_file="${path%/blocked.domains}/noauth.domains"
if awk -v p="$noauth_file" '$3 == p { found = 1 } END { exit !found }' before/etc-modes; then
continue
fi
echo "$mode $owner $noauth_file" >> before/etc-modes.expected
sudo test -f "$noauth_file" || { echo "missing: $noauth_file"; exit 1; }
if sudo test -s "$noauth_file"; then echo "not empty: $noauth_file"; exit 1; fi
done < <(awk '$3 ~ /^\/etc\/linuxmuster-squid\/blocklists\/[^\/]+\/blocked\.domains$/' before/etc-modes)
sort -o before/etc-modes.expected before/etc-modes.expected
diff -u before/etc-modes.expected after/etc-modes

# 4. the instance record is written by the software, not the admin, and the
# postinst rewrites `image:` on purpose (update-all). Every key the admin
Expand Down
17 changes: 12 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,11 @@ the same AD/Kerberos infrastructure the school already operates.
authentication via `negotiate_kerberos_auth`, authorization via
`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).
blocklist: HTTP → 403, HTTPS → certificate warning in the browser (squid's 403 inside TLS
with an untrusted throwaway certificate; the blocked server is never contacted, allowed traffic
is never decrypted).
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
Expand Down Expand Up @@ -76,8 +80,9 @@ sudo lmnsquid create --school all --role students --ad-group role-student \
--realm EXAMPLE.ORG --visible-hostname proxy.example.org \
--keytab-secret proxy.keytab --http-port 3129 --school-subnets 10.0.0.0/8
```
> `REALM=EXAMPLE.ORG bash /usr/share/linuxmuster-squid/scripts/discover-ad-facts.sh` (on the DC)
> prints the global role groups, the internet groups per school and the two `create` commands.
> `bash /usr/share/linuxmuster-squid/scripts/discover-ad-facts.sh` (on the DC) reads the realm
> there and prints the global role groups, the internet groups per school and the `create`
> commands.
> `--image` defaults to the maintained, digest-pinned image.

**3. Everyday commands:**
Expand All @@ -90,9 +95,11 @@ lmnsquid update all-teachers # -> maintained default image (health
lmnsquid update-all # every instance -> default image
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 add example.org # block a domain + subdomains (HTTP 403, HTTPS cert warning)
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
```

Expand Down
15 changes: 15 additions & 0 deletions RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,21 @@ does not yet exist — every `lmnsquid create --image ghcr.io/…@sha256:<digest

## Ongoing releases

**A release whose `image/` changed pins that image before it is tagged.** The control plane
installs and updates instances with `DEFAULT_IMAGE` (`controlplane/lmnsquid/models.py`); a
changed template that is not pinned never reaches an installed system. Order: merge the change
→ `build-image.yml` builds and pushes it on `main` (the digest is in the job log, "Report
digest") → a PR sets `DEFAULT_IMAGE` to that `@sha256:` digest (by hand while Renovate is
disabled) → CI green → set the changelog version and tag. The package upgrade then moves every
instance onto the new image (`update-all` in the postinst, health-gated with rollback).
**7.3.8** is such a release: the noauth list (ADR-016) and `lists-sync.sh` live in the image;
until the digest is pinned, `lmnsquid noauth <name> reload` answers 409 and the lists still
reach squid unfiltered. The E2E service `squid-old` and the integration test
`test_real_old_image_ignores_the_noauth_list` deliberately keep testing the image 7.3.7 pins
(`…@sha256:d13625b5…`, a constant in `scripts/tests/e2e_kerberos.sh` and
`controlplane/tests/test_docker_integration.py`), i.e. what instances run until the upgrade
replaces their containers; they do not follow `DEFAULT_IMAGE` and need no change for the pin.

Code + docs + a `debian/changelog` bullet in the same commit (Conventional Commits) → push →
CI green (`ci.yml`: fast tier, container build, install smoke, upgrade smoke) → Kevin sets the
release version in the changelog head and tags `v<version>` → `release.yml` builds, smokes and
Expand Down
74 changes: 72 additions & 2 deletions controlplane/lmnsquid/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,17 @@
from fastapi import Depends, FastAPI, HTTPException, Request, status
from fastapi.responses import JSONResponse

from .blocklist import Blocklist
from .blocklist import Blocklist, noauth_list
from .config import Settings
from .docker_service import DockerService
from .models import DEFAULT_IMAGE, BlocklistEntry, Instance, InstancePatch, UpdateRequest
from .models import (
DEFAULT_IMAGE,
BlocklistEntry,
Instance,
InstancePatch,
NoauthEntry,
UpdateRequest,
)
from .reconciler import Reconciler
from .security import make_verify_token
from .store import Store
Expand All @@ -38,6 +45,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 = noauth_list(settings.blocklists_dir)
app = FastAPI(title="linuxmuster-squid control plane")

@app.exception_handler(DockerException)
Expand Down Expand Up @@ -218,6 +226,68 @@ 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: NoauthEntry) -> 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 (lists-sync.sh, ``squid -k reconfigure``), refused if the image
has no noauth list.

An image from before ADR-016 has no noauth ACL in its squid.conf and never reads the
list: answer 409 instead of a silent "reloaded". (A ``noauth.domains`` that appeared
after the container started is read by this reload.)
"""
_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 data-plane image of {name!r} does not support the noauth list, "
"so the list has no effect: it needs a linuxmuster-squid release whose "
"pinned data-plane image supports it; installing that release moves the "
"instances onto that image (update-all), and `lmnsquid update "
f"{name}` moves this one if it stayed on its old image"
),
)
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]:
Expand Down
Loading
Loading