{ config , pkgs , lib , ... }: # Gitea git server, publicly exposed through an external reverse proxy. # # Topology: # internet -> gitea.edley.me -> external reverse proxy (TLS terminator) # -> Tailscale -> homeserver-1:port (Gitea, plain HTTP behind the proxy) # # Hardening posture (public instance): # - HTTPS only (DISABLE_SSH) so there is no second public port to forward. # - Open registration (with image CAPTCHA) for issue/comments-only accounts: # users CANNOT create repos or orgs (USER/ORG_MAX_CREATION_LIMIT = 0) so no # code can be stored. The admin (petere) is exempted via a per-user override # in the admin panel. # - Public repos are browsable anonymously; private content requires login. # - The firewall admits ONLY the proxy's Tailscale IP; no other tailnet # node (or the LAN) can reach Gitea directly. # - X-Forwarded-* headers are trusted only from that proxy IP. # - PUBLIC_URL_DETECTION = never pins all generated URLs to ROOT_URL # (no Host-header hijacking). # - OpenID disabled, migrations disabled (SSRF reduction), argon2 password # hashing, 12-char minimum password, git hooks disabled. { options.services.gitea-server = { enable = lib.mkEnableOption "Gitea git server"; port = lib.mkOption { type = lib.types.port; default = 3000; description = "Port Gitea listens on. Firewalled to the reverse proxy only."; }; domain = lib.mkOption { type = lib.types.str; default = "gitea.edley.me"; description = "Public domain name of the instance."; }; proxyIp = lib.mkOption { type = lib.types.str; description = "Tailscale IP of the external reverse proxy allowed to reach Gitea."; }; }; config = lib.mkIf config.services.gitea-server.enable { services.gitea = { enable = true; stateDir = "/data/gitea"; appName = "gitea: Git with a cup of tea"; database = { type = "postgres"; createDatabase = true; name = "gitea"; user = "gitea"; }; dump = { enable = true; interval = "daily"; type = "tar.zst"; }; # Image CAPTCHA on registration to deter bots spamming accounts. No # external service keys needed for the `image` type. captcha = { enable = true; type = "image"; }; settings = { server = { DOMAIN = config.services.gitea-server.domain; ROOT_URL = "https://${config.services.gitea-server.domain}/"; HTTP_ADDR = "0.0.0.0"; HTTP_PORT = config.services.gitea-server.port; DISABLE_SSH = true; PUBLIC_URL_DETECTION = "never"; MINIMUM_KEY_SIZE_CHECK = true; }; session = { COOKIE_SECURE = true; }; service = { # Public sign-up enabled so anyone can report issues/comment. Accounts # are issue/comments-only (see repository limits below): they cannot # create repos or orgs, so no code can be stored. DISABLE_REGISTRATION = false; SHOW_REGISTRATION_BUTTON = true; # Allow anonymous browsing of public repos; private content still # requires login. (REQUIRE_SIGNIN_VIEW would hide even public repos.) REQUIRE_SIGNIN_VIEW = false; }; security = { REVERSE_PROXY_TRUSTED_PROXIES = config.services.gitea-server.proxyIp; REVERSE_PROXY_LIMIT = 1; MIN_PASSWORD_LENGTH = 12; PASSWORD_HASH_ALGO = "argon2"; DISABLE_GIT_HOOKS = true; }; openid = { ENABLE_OPENID_SIGNIN = false; ENABLE_OPENID_SIGNUP = false; }; repository = { DISABLE_MIGRATIONS = true; # Regular users get zero repo/org creation quota -> issues/comments # only. Admin (petere) is exempted via a per-user override in the # admin panel (Site Administration -> Users). USER_MAX_CREATION_LIMIT = 0; ORG_MAX_CREATION_LIMIT = 0; }; other = { SHOW_FOOTER_VERSION = false; SHOW_FOOTER_TEMPLATE_LOAD_TIME = false; }; }; }; # The external proxy (TLS terminator for gitea.edley.me) connects over # Tailscale. Admit ONLY its address on the Gitea port; the default input # policy drops everything else. Update proxyIp when the proxy changes. networking.firewall.extraInputRules = '' ip saddr ${config.services.gitea-server.proxyIp} tcp dport ${toString config.services.gitea-server.port} accept ''; }; }