221 lines
7.7 KiB
Nix
221 lines
7.7 KiB
Nix
{
|
|
config,
|
|
lib,
|
|
pkgs,
|
|
...
|
|
}:
|
|
let
|
|
baseSiteConfig = import ../site-config.nix;
|
|
baseServiceConfigs = import ../hosts/muffin/service-configs.nix {
|
|
site_config = baseSiteConfig;
|
|
};
|
|
testServiceConfigs = lib.recursiveUpdate baseServiceConfigs {
|
|
zpool_ssds = "";
|
|
gitea = {
|
|
dir = "/var/lib/gitea";
|
|
# `:80` makes Caddy bind all hosts on HTTP port 80 with no Host-header
|
|
# matching — simplest path to a reachable vhost inside the test VM
|
|
# where there is no ACME / DNS and no TLS terminator.
|
|
domain = ":80";
|
|
};
|
|
ports.private.gitea = {
|
|
port = 3000;
|
|
proto = "tcp";
|
|
};
|
|
};
|
|
|
|
testLib = lib.extend (
|
|
final: prev: {
|
|
serviceMountWithZpool =
|
|
serviceName: zpool: dirs:
|
|
{ ... }:
|
|
{ };
|
|
serviceFilePerms = serviceName: tmpfilesRules: { ... }: { };
|
|
}
|
|
);
|
|
|
|
giteaModule =
|
|
{ config, pkgs, ... }:
|
|
{
|
|
imports = [
|
|
(import ../services/gitea/gitea.nix {
|
|
inherit config pkgs;
|
|
lib = testLib;
|
|
service_configs = testServiceConfigs;
|
|
})
|
|
];
|
|
};
|
|
in
|
|
pkgs.testers.runNixOSTest {
|
|
name = "gitea-hide-actions";
|
|
|
|
nodes = {
|
|
server =
|
|
{
|
|
config,
|
|
lib,
|
|
pkgs,
|
|
...
|
|
}:
|
|
{
|
|
imports = [
|
|
../modules/server-security.nix
|
|
giteaModule
|
|
];
|
|
|
|
# The shared gitea.nix module derives DOMAIN/ROOT_URL from the
|
|
# `service_configs.gitea.domain` string, which here is the full URL
|
|
# `http://server`. Override to valid bare values so Gitea doesn't
|
|
# get a malformed ROOT_URL like `https://http://server`.
|
|
services.gitea.settings = {
|
|
server = {
|
|
DOMAIN = lib.mkForce "server";
|
|
ROOT_URL = lib.mkForce "http://server/";
|
|
};
|
|
# Tests talk HTTP, so drop the Secure flag — otherwise curl's cookie
|
|
# jar holds the session cookie but never sends it back.
|
|
session.COOKIE_SECURE = lib.mkForce false;
|
|
};
|
|
services.caddy = {
|
|
enable = true;
|
|
# No DNS / ACME in the VM test network — serve plain HTTP.
|
|
globalConfig = ''
|
|
auto_https off
|
|
'';
|
|
};
|
|
|
|
services.postgresql.enable = true;
|
|
|
|
# Stub out zfs/mount ordering added by the real serviceMountWithZpool.
|
|
systemd.services."gitea-mounts".enable = lib.mkForce false;
|
|
systemd.services.gitea = {
|
|
wants = lib.mkForce [ ];
|
|
after = lib.mkForce [ "postgresql.service" ];
|
|
requires = lib.mkForce [ ];
|
|
};
|
|
|
|
networking.firewall.allowedTCPPorts = [
|
|
80
|
|
3000
|
|
];
|
|
};
|
|
|
|
client =
|
|
{ pkgs, ... }:
|
|
{
|
|
environment.systemPackages = [ pkgs.curl ];
|
|
};
|
|
};
|
|
|
|
testScript = ''
|
|
import re
|
|
|
|
start_all()
|
|
server.wait_for_unit("postgresql.service")
|
|
server.wait_for_unit("gitea.service")
|
|
server.wait_for_unit("caddy.service")
|
|
server.wait_for_open_port(3000)
|
|
server.wait_for_open_port(80)
|
|
|
|
server.succeed(
|
|
"su -l gitea -s /bin/sh -c '${pkgs.gitea}/bin/gitea admin user create "
|
|
"--username testuser --password testpassword "
|
|
"--email test@test.local --must-change-password=false "
|
|
"--work-path /var/lib/gitea'"
|
|
)
|
|
|
|
def curl(args, cookies=None):
|
|
cookie_args = f"-b {cookies} " if cookies else ""
|
|
cmd = (
|
|
"curl -4 -s -o /dev/null "
|
|
f"-w '%{{http_code}}|%{{redirect_url}}' {cookie_args}{args}"
|
|
)
|
|
return client.succeed(cmd).strip()
|
|
|
|
def login():
|
|
# Gitea's POST /user/login requires a _csrf token and expects the
|
|
# matching session cookie already set. Fetch the login form first
|
|
# to harvest both, then submit credentials with the same cookie jar.
|
|
client.succeed("rm -f /tmp/cookies.txt")
|
|
html = client.succeed(
|
|
"curl -4 -s -c /tmp/cookies.txt http://server/user/login"
|
|
)
|
|
match = re.search(r'name="_csrf"\s+value="([^"]+)"', html)
|
|
assert match, f"CSRF token not found in login form: {html[:500]!r}"
|
|
csrf = match.group(1)
|
|
# -L so we follow the post-login redirect; the session cookie is
|
|
# rewritten by Gitea on successful login to carry uid.
|
|
client.succeed(
|
|
"curl -4 -s -L -o /dev/null "
|
|
"-b /tmp/cookies.txt -c /tmp/cookies.txt "
|
|
f"--data-urlencode '_csrf={csrf}' "
|
|
"--data-urlencode 'user_name=testuser' "
|
|
"--data-urlencode 'password=testpassword' "
|
|
"http://server/user/login"
|
|
)
|
|
# Sanity-check the session by hitting the gated probe directly —
|
|
# the post-login cookie jar MUST drive /user/stopwatches to 200.
|
|
probe = client.succeed(
|
|
"curl -4 -s -o /dev/null -w '%{http_code}' "
|
|
"-b /tmp/cookies.txt http://server/user/stopwatches"
|
|
).strip()
|
|
assert probe == "200", f"session auth probe expected 200, got {probe!r}"
|
|
return "/tmp/cookies.txt"
|
|
|
|
with subtest("Anonymous /{user}/{repo}/actions redirects to login"):
|
|
result = curl("http://server/foo/bar/actions")
|
|
code, _, redir = result.partition("|")
|
|
print(f"anon /foo/bar/actions -> {result!r}")
|
|
assert code == "302", f"expected 302, got {code!r} (full: {result!r})"
|
|
assert "/user/login" in redir, f"expected login redirect, got {redir!r}"
|
|
assert "redirect_to=" in redir, f"expected redirect_to param, got {redir!r}"
|
|
assert "/foo/bar/actions" in redir, (
|
|
f"expected original URL preserved in redirect_to, got {redir!r}"
|
|
)
|
|
|
|
with subtest("Anonymous deep /actions paths also redirect"):
|
|
for path in ["/foo/bar/actions/", "/foo/bar/actions/runs/1", "/foo/bar/actions/workflows/build.yaml"]:
|
|
result = curl(f"http://server{path}")
|
|
code, _, redir = result.partition("|")
|
|
print(f"anon {path} -> {result!r}")
|
|
assert code == "302", f"{path}: expected 302, got {code!r}"
|
|
assert "/user/login" in redir, f"{path}: expected login redirect, got {redir!r}"
|
|
|
|
with subtest("Anonymous workflow badge stays public"):
|
|
result = curl("http://server/foo/bar/actions/workflows/ci.yaml/badge.svg")
|
|
code, _, redir = result.partition("|")
|
|
print(f"anon badge -> {result!r}")
|
|
assert code != "302" or "/user/login" not in redir, (
|
|
f"badge path should not redirect to login, got {result!r}"
|
|
)
|
|
|
|
cookies = login()
|
|
|
|
with subtest("Session-authenticated /{user}/{repo}/actions reaches Gitea"):
|
|
result = curl(
|
|
"http://server/testuser/nonexistent/actions", cookies=cookies
|
|
)
|
|
code, _, redir = result.partition("|")
|
|
print(f"auth /testuser/nonexistent/actions -> {result!r}")
|
|
# Gitea returns 404 for the missing repo — the key assertion is that
|
|
# Caddy's gate forwarded the request instead of redirecting to login.
|
|
assert not (code == "302" and "/user/login" in redir), (
|
|
f"session-authed actions request was intercepted by login gate: {result!r}"
|
|
)
|
|
|
|
with subtest("Anonymous /explore/repos is served without gating"):
|
|
result = curl("http://server/explore/repos")
|
|
code, _, _ = result.partition("|")
|
|
print(f"anon /explore/repos -> {result!r}")
|
|
assert code == "200", f"expected 200 for public explore page, got {result!r}"
|
|
|
|
with subtest("Anonymous /{user}/{repo} (non-actions) is not login-gated"):
|
|
result = curl("http://server/foo/bar")
|
|
code, _, redir = result.partition("|")
|
|
print(f"anon /foo/bar -> {result!r}")
|
|
assert not (code == "302" and "/user/login" in redir), (
|
|
f"non-actions repo path should not redirect to login: {result!r}"
|
|
)
|
|
'';
|
|
}
|