Author SHA1 Message Date
Autumn Liu 49137f9c16 fix bug
diff-pr / dix (pull_request) Successful in 6m49s
lint / lint (pull_request) Canceled after 0s
flake-check / flake-check (pull_request) Canceled after 2m14s
2026-09-11 15:50:58 -05:00
Autumn Liu 6cda214d97 Merge branch 'frcc-kvm' of https://git.floweytf.com/server/server-config into frcc-kvm 2026-09-11 15:27:56 -05:00
Autumn Liu 1bf51e9a9e update
diff-pr / dix (pull_request) Successful in 6m48s
flake-check / flake-check (pull_request) Failing after 2m49s
lint / lint (pull_request) Successful in 1m48s
2026-09-11 15:27:33 -05:00
Floweynt ba23f719ab ci: fix vulnix GITHUB_OUTPUT format error
diff-pr / dix (pull_request) Successful in 6m30s
flake-check / flake-check (pull_request) Successful in 52s
lint / lint (pull_request) Successful in 1m50s
2026-09-11 15:25:03 -05:00
Autumn Liu 9721112d67 update
diff-pr / dix (pull_request) Successful in 7m2s
flake-check / flake-check (pull_request) Failing after 3m56s
lint / lint (pull_request) Successful in 1m48s
2026-09-11 14:43:43 -05:00
Autumn Liu 3605fc36ef meow2
diff-pr / dix (pull_request) Successful in 6m20s
flake-check / flake-check (pull_request) Successful in 52s
lint / lint (pull_request) Successful in 1m50s
2026-09-11 02:08:07 -05:00
Autumn Liu bb79660386 meow
diff-pr / dix (pull_request) Successful in 6m46s
flake-check / flake-check (pull_request) Successful in 4m40s
lint / lint (pull_request) Failing after 1m54s
2026-09-11 01:48:02 -05:00
Autumn Liu a54cbc14bd Merge branch 'frcc-kvm' of https://git.floweytf.com/server/server-config into frcc-kvm 2026-09-11 01:47:19 -05:00
Floweynt 06ed76e5ab Merge branch 'master' into frcc-kvm
diff-pr / dix (pull_request) Successful in 18m18s
flake-check / flake-check (pull_request) Successful in 4m40s
lint / lint (pull_request) Failing after 1m57s
2026-09-10 19:30:53 -05:00
Autumn Liu 096908ac39 frcc experiments
diff-pr / dix (pull_request) Successful in 6m43s
flake-check / flake-check (pull_request) Successful in 4m42s
lint / lint (pull_request) Failing after 1m47s
2026-09-10 18:31:09 -05:00
Autumn Liu 33ccab9ad4 frcc experiments
diff-pr / dix (pull_request) Canceled after 0s
flake-check / flake-check (pull_request) Canceled after 0s
lint / lint (pull_request) Canceled after 0s
2026-09-09 17:58:14 -05:00
7 changed files with 77 additions and 86 deletions
+1 -9
View File
@@ -17,18 +17,10 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0 fetch-depth: 0
- name: merge base branch
run: |
git config user.email "ci@localhost"
git config user.name "CI"
git fetch origin ${{ github.event.pull_request.base.ref }}
git merge --no-edit origin/${{ github.event.pull_request.base.ref }}
- name: worktree base for diff - name: worktree base for diff
run: git worktree add "$RUNNER_TEMP/base" "origin/${{ github.event.pull_request.base.ref }}" run: git worktree add "$RUNNER_TEMP/base" "${{ github.event.pull_request.base.sha }}"
- name: build closures and render diff - name: build closures and render diff
run: | run: |
+5 -3
View File
@@ -5,19 +5,21 @@ on:
- cron: "17 4 * * *" - cron: "17 4 * * *"
workflow_dispatch: workflow_dispatch:
permissions:
contents: write
pull-requests: write
jobs: jobs:
update: update:
runs-on: nix runs-on: nix
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with:
persist-credentials: false
- run: nix flake update -L - run: nix flake update -L
- name: open/update PR - name: open/update PR
env: env:
GH_TOKEN: ${{ secrets.BOT_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
API: ${{ github.api_url }} API: ${{ github.api_url }}
REPO: ${{ github.repository }} REPO: ${{ github.repository }}
run: | run: |
+4
View File
@@ -5,6 +5,10 @@ on:
- cron: "0 6 * * 1" - cron: "0 6 * * 1"
workflow_dispatch: workflow_dispatch:
permissions:
contents: read
issues: write
jobs: jobs:
scan: scan:
runs-on: nix runs-on: nix
+7 -27
View File
@@ -8,7 +8,6 @@ import re
import subprocess import subprocess
import subprocess_tee import subprocess_tee
import sys import sys
import urllib.error
import urllib.request import urllib.request
from collections import Counter from collections import Counter
from pathlib import Path from pathlib import Path
@@ -27,10 +26,6 @@ def git(*args, check=True):
return run("git", *args, check=check, capture_output=True) return run("git", *args, check=check, capture_output=True)
class GiteaError(Exception):
pass
def gitea(method, path, body=None): def gitea(method, path, body=None):
url = f'{os.environ["API"]}/repos/{os.environ["REPO"]}{path}' url = f'{os.environ["API"]}/repos/{os.environ["REPO"]}{path}'
data = json.dumps(body).encode() if body is not None else None data = json.dumps(body).encode() if body is not None else None
@@ -38,20 +33,13 @@ def gitea(method, path, body=None):
req.add_header("Authorization", f'token {os.environ["GH_TOKEN"]}') req.add_header("Authorization", f'token {os.environ["GH_TOKEN"]}')
if data is not None: if data is not None:
req.add_header("Content-Type", "application/json") req.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(req) as resp: with urllib.request.urlopen(req) as resp:
raw = resp.read() raw = resp.read()
except urllib.error.HTTPError as e:
try:
detail = json.loads(e.read()).get("message", e.reason)
except (json.JSONDecodeError, UnicodeDecodeError):
detail = e.reason
raise GiteaError(f"{method} {path}: {e.code} {detail}") from None
return json.loads(raw) if raw else None return json.loads(raw) if raw else None
def find_open_issue(label): def find_open_issue(label):
issues = gitea("GET", f"/issues?state=open&type=issues&labels={label}") issues = gitea("GET", f"/issues?state=open&labels={label}")
return issues[0] if issues else None return issues[0] if issues else None
@@ -248,7 +236,7 @@ def format_vulnix(vulns):
for v in vulns: for v in vulns:
for cve in v["affected_by"]: for cve in v["affected_by"]:
score = v.get("cvssv3_basescore", {}).get(cve) score = v.get("cvssv3_basescore", {}).get(cve)
desc = v.get("description", {}).get(cve, "").replace("\n", " ").replace("|", "∣") desc = v.get("description", {}).get(cve, "")
if len(desc) > 120: if len(desc) > 120:
desc = desc[:117] + "..." desc = desc[:117] + "..."
rows.append((v["pname"], v["version"], cve, score, desc)) rows.append((v["pname"], v["version"], cve, score, desc))
@@ -475,24 +463,16 @@ def open_pr(args):
host = os.environ["API"].removesuffix("/api/v1") host = os.environ["API"].removesuffix("/api/v1")
auth = base64.b64encode(f'token:{os.environ["GH_TOKEN"]}'.encode()).decode() auth = base64.b64encode(f'token:{os.environ["GH_TOKEN"]}'.encode()).decode()
push = git("-c", f"http.extraHeader=Authorization: Basic {auth}", git("-c", f"http.extraHeader=Authorization: Basic {auth}",
"push", "-f", f'{host}/{os.environ["REPO"]}.git', f"HEAD:{args.branch}", "push", "-f", f'{host}/{os.environ["REPO"]}.git', f"HEAD:{args.branch}")
check=False)
if push.returncode != 0:
raise RuntimeError(f"git push failed:\n{push.stderr}")
pulls = gitea("GET", f"/pulls?state=open&base_branch={args.base}") owner = os.environ["REPO"].split("/")[0]
existing = next((pr for pr in pulls if pr["head"]["ref"] == args.branch), None) if gitea("GET", f"/pulls?state=open&head={owner}:{args.branch}"):
if existing:
print(f"PR #{existing['number']} already open, pushed update")
return return
labels = [label_id(args.label)] if args.label else [] labels = [label_id(args.label)] if args.label else []
pr = gitea("POST", "/pulls", gitea("POST", "/pulls",
{"title": args.title, "head": args.branch, "base": args.base, {"title": args.title, "head": args.branch, "base": args.base,
"body": Path(args.body_file).read_text(), "labels": labels}) "body": Path(args.body_file).read_text(), "labels": labels})
if not pr or "number" not in pr:
raise GiteaError(f"POST /pulls: unexpected response: {json.dumps(pr)}")
print(f"opened PR #{pr['number']}")
def main(): def main():
+10 -23
View File
@@ -76,14 +76,6 @@ let
miscProperties = self._sshpiperRegister routingInfo; miscProperties = self._sshpiperRegister routingInfo;
}; };
kvmTrait = {
containerConfig =
{ pkgs, ... }:
{
users.users.runner.extraGroups = [ "kvm" ];
environment.systemPackages = [ pkgs.qemu_kvm ];
};
};
in in
{ {
ssh-admin = mkSsh { ssh-admin = mkSsh {
@@ -126,32 +118,27 @@ in
ssh-generic = mkSsh { ssh-generic = mkSsh {
name = "ssh-generic"; name = "ssh-generic";
net.access = [ { to = "@internet"; } ]; net.access = [ { to = "@internet"; } ];
# /dev/kvm keeps its host ownership, so the kvm gid must not be shifted.
isolation.privateUsers = "identity";
resources = { resources = {
fs."/home/runner/data" = { fs."/home/runner/data" = {
hostPath = "${data}/ssh/generic"; hostPath = "${data}/ssh/generic";
isReadOnly = false; isReadOnly = false;
}; };
devices = [ "/dev/kvm" ];
mem = "16G"; mem = "16G";
cpu = 65; cpu = 65;
}; };
traits = [ kvmTrait ]; packages = with pkgs; [ qemu_kvm ];
isolation.allowedDevices = [ traits = [
{ {
node = "/dev/kvm"; containerConfig =
modifier = "rw"; { config, ... }:
}
{ {
node = "/dev/vhost-net"; users.groups.kvm.gid = config.ids.gids.kvm;
modifier = "rw"; users.users.runner.extraGroups = [ "kvm" ];
};
} }
{
node = "/dev/net/tun";
modifier = "rwm";
}
];
isolation.additionalCapabilities = [
"CAP_NET_ADMIN"
"CAP_MKNOD"
]; ];
routingInfo = [ routingInfo = [
{ {
+15 -19
View File
@@ -221,6 +221,15 @@ rec {
`resources.fs` `resources.fs`
: Bind mounts, keyed by mount point. : Bind mounts, keyed by mount point.
`resources.devices`
: Host device nodes to expose, as paths, added to the unit's
`DeviceAllow=`. nspawn populates a handful of privileged devices
(`/dev/kvm`, `/dev/net/tun`, ...) into the container's own `/dev` once
the cgroup allows it, and silently skips ones the host doesn't have -
no `resources.fs` bind mount needed. Nodes keep their host ownership,
so a consumer inside needs `isolation.privateUsers = "identity"` for
the owning gid to mean anything.
`isolation.nixDaemon` `isolation.nixDaemon`
: Whether the Nix daemon and CLI are available inside the container. : Whether the Nix daemon and CLI are available inside the container.
@@ -228,17 +237,6 @@ rec {
: systemd-nspawn user namespacing; see `--private-users=` in : systemd-nspawn user namespacing; see `--private-users=` in
systemd-nspawn(1). Bind mounts always go through idmapped `--bind`. systemd-nspawn(1). Bind mounts always go through idmapped `--bind`.
`isolation.allowedDevices`
: List of `{ node; modifier; }` host devices to grant the container's
cgroup access to; see `containers.<name>.allowedDevices` in the NixOS
manual. nspawn bind-mounts a handful of devices (`/dev/kvm`,
`/dev/net/tun`, ...) into the container itself once the cgroup allows it -
this doesn't need a matching `resources.fs` bind mount.
`isolation.additionalCapabilities`
: Extra Linux capabilities granted to the container; see
`containers.<name>.additionalCapabilities` in the NixOS manual.
`miscProperties` `miscProperties`
: Arbitrary data attached to the result. Used for cross-container discovery : Arbitrary data attached to the result. Used for cross-container discovery
via `collectContainers`. via `collectContainers`.
@@ -281,10 +279,9 @@ rec {
cpuQuota = "${toString (cpu * 100)}%"; cpuQuota = "${toString (cpu * 100)}%";
memoryMax = resources.mem or "512M"; memoryMax = resources.mem or "512M";
bindMounts = resources.fs or { }; bindMounts = resources.fs or { };
devices = resources.devices or [ ];
allowsNixDaemon = isolation.nixDaemon or false; allowsNixDaemon = isolation.nixDaemon or false;
privateUsers = isolation.privateUsers or "pick"; privateUsers = isolation.privateUsers or "pick";
allowedDevices = isolation.allowedDevices or [ ];
additionalCapabilities = isolation.additionalCapabilities or [ ];
allowInternet = hasInternetAccess access; allowInternet = hasInternetAccess access;
allBindMounts = builtins.foldl' (acc: t: acc // (t.resources.fs or { })) { } traits // bindMounts; allBindMounts = builtins.foldl' (acc: t: acc // (t.resources.fs or { })) { } traits // bindMounts;
@@ -376,17 +373,13 @@ rec {
hostAddr hostAddr
bindMountHostPaths bindMountHostPaths
includedInTests includedInTests
devices
; ;
bindMounts = allBindMounts; bindMounts = allBindMounts;
kind = "nspawn"; kind = "nspawn";
miscProperties = flattenMiscProp self miscProperties; miscProperties = flattenMiscProp self miscProperties;
nixosContainer = { nixosContainer = {
inherit inherit autoStart privateUsers;
autoStart
privateUsers
allowedDevices
additionalCapabilities
;
bindMounts = { }; bindMounts = { };
extraFlags = bindMountExtraFlags; extraFlags = bindMountExtraFlags;
privateNetwork = true; privateNetwork = true;
@@ -521,6 +514,7 @@ rec {
; ;
inherit needsGateway; inherit needsGateway;
bindMountHostPaths = map (s: s.source) shares; bindMountHostPaths = map (s: s.source) shares;
devices = [ ];
kind = "vm"; kind = "vm";
miscProperties = flattenMiscProp self miscProperties; miscProperties = flattenMiscProp self miscProperties;
microvmConfig = { microvmConfig = {
@@ -957,6 +951,7 @@ rec {
startLimitBurst = 5; startLimitBurst = 5;
startLimitIntervalSec = 120; startLimitIntervalSec = 120;
unitConfig.RequiresMountsFor = c.bindMountHostPaths; unitConfig.RequiresMountsFor = c.bindMountHostPaths;
serviceConfig.DeviceAllow = map (d: "${d} rw") c.devices;
}; };
# the host end of a link, for both kinds. nixos-containers only configures # the host end of a link, for both kinds. nixos-containers only configures
@@ -1316,6 +1311,7 @@ rec {
HOST_ADDR = ${pyDict pyStr (lib.mapAttrs (_: c: c.hostAddr) autoStartTestContainers)} HOST_ADDR = ${pyDict pyStr (lib.mapAttrs (_: c: c.hostAddr) autoStartTestContainers)}
IFACE = ${pyDict pyStr (lib.mapAttrs (_: c: c.iface) autoStartTestContainers)} IFACE = ${pyDict pyStr (lib.mapAttrs (_: c: c.iface) autoStartTestContainers)}
KIND = ${pyDict pyStr (lib.mapAttrs (_: c: c.kind) autoStartTestContainers)} KIND = ${pyDict pyStr (lib.mapAttrs (_: c: c.kind) autoStartTestContainers)}
DEVICES = ${pyDict pyList (lib.mapAttrs (_: c: c.devices) autoStartTestContainers)}
# only nspawn guests can be entered with nsenter; a vm has no host-side # only nspawn guests can be entered with nsenter; a vm has no host-side
# netns, so every probe that runs *inside* a guest is limited to these # netns, so every probe that runs *inside* a guest is limited to these
enterable = [c for c in included if KIND[c] == "nspawn"] enterable = [c for c in included if KIND[c] == "nspawn"]
+30
View File
@@ -27,6 +27,7 @@ if TYPE_CHECKING:
HOST_ADDR: dict[str, str] = {} HOST_ADDR: dict[str, str] = {}
IFACE: dict[str, str] = {} IFACE: dict[str, str] = {}
KIND: dict[str, str] = {} KIND: dict[str, str] = {}
DEVICES: dict[str, list[str]] = {}
EDGES: list[tuple[str, str, list[int], list[int]]] = [] EDGES: list[tuple[str, str, list[int], list[int]]] = []
SSH_KEY: str = "" SSH_KEY: str = ""
SSH_OPTS: str = "" SSH_OPTS: str = ""
@@ -55,6 +56,11 @@ def container_pid(name):
return server.succeed(f"machinectl show {name} -p Leader --value").strip() return server.succeed(f"machinectl show {name} -p Leader --value").strip()
def ct_cmd(name, cmd):
pid = container_pid(name)
return f"{NSENTER} --mount=/proc/{pid}/ns/mnt --root=/proc/{pid}/root -- {SH} -c '{cmd}'"
def net_cmd(name, cmd): def net_cmd(name, cmd):
pid = container_pid(name) pid = container_pid(name)
return f"{NSENTER} --net=/proc/{pid}/ns/net -- {cmd}" return f"{NSENTER} --net=/proc/{pid}/ns/net -- {cmd}"
@@ -323,3 +329,27 @@ with subtest("(functional): published ports follow the container's lifetime"):
server.succeed("systemctl start container@nginx") server.succeed("systemctl start container@nginx")
server.wait_for_unit("container@nginx.service") server.wait_for_unit("container@nginx.service")
internet.wait_until_succeeds(ext_connect(80), timeout=30) internet.wait_until_succeeds(ext_connect(80), timeout=30)
with subtest("(functional): declared devices are usable in-container"):
for name, devices in DEVICES.items():
for dev in devices:
if server.execute(f"test -e {dev}")[0] != 0:
# host lacks the device (e.g. /dev/kvm without nested
# virtualization, as in this test's own VM) - nspawn skips
# populating it in-container, so there's nothing to check.
continue
# opened, not stat'd: without DeviceAllow= the node is still visible
server.succeed(ct_cmd(name, f"exec 3<> {dev}"))
with subtest("(isolation): devices reach only the container that declared them"):
for name in enterable:
for dev in {d for ds in DEVICES.values() for d in ds} - set(DEVICES.get(name, [])):
server.fail(ct_cmd(name, f"test -e {dev}"))
with subtest("(isolation): identity uid mapping keeps the user namespace"):
for name in (n for n, ds in DEVICES.items() if ds):
host_ns = server.succeed("readlink /proc/1/ns/user").strip()
ct_ns = server.succeed(f"readlink /proc/{container_pid(name)}/ns/user").strip()
assert host_ns != ct_ns, f"{name} shares the host user namespace"