diff --git a/flake.nix b/flake.nix index a23946d..af51054 100644 --- a/flake.nix +++ b/flake.nix @@ -33,7 +33,12 @@ default = let pythonEnv = pkgs.python38.withPackages (pypkgs: with pypkgs; [ flask black pytest ]); in pkgs.mkShell { - packages = [ pythonEnv pkgs.nixos-shell ]; + packages = [ + pythonEnv + pkgs.nixos-shell + # We only take this dependency for htpasswd, which is a little unfortunate + pkgs.apacheHttpd + ]; shellHook = '' PS1="(develop) $PS1" ''; diff --git a/intake/app.py b/intake/app.py index 36c83a1..4651378 100644 --- a/intake/app.py +++ b/intake/app.py @@ -41,19 +41,21 @@ def auth_check(route): """ Checks the HTTP Basic Auth header against the stored credential. """ + @wraps(route) def _route(*args, **kwargs): data_path = intake_data_dir() auth_path = data_path / "credentials.json" if auth_path.exists(): if not request.authorization: - abort(403) + abort(401) auth = json.load(auth_path.open(encoding="utf8")) if request.authorization.username != auth["username"]: abort(403) if request.authorization.password != auth["secret"]: abort(403) return route(*args, **kwargs) + return _route diff --git a/intake/cli.py b/intake/cli.py index 88b1930..cac3a5d 100644 --- a/intake/cli.py +++ b/intake/cli.py @@ -2,9 +2,11 @@ from datetime import datetime from pathlib import Path from shutil import get_terminal_size import argparse +import getpass import json import os import os.path +import pwd import subprocess import sys @@ -263,6 +265,44 @@ def cmd_feed(cmd_args): print() +def cmd_passwd(cmd_args): + """Update password for the web interface.""" + parser = argparse.ArgumentParser( + prog="intake passwd", + description=cmd_passwd.__doc__, + ) + parser.add_argument( + "--data", + "-d", + default=intake_data_dir(), + help="Path to the intake data directory", + ) + args = parser.parse_args(cmd_args) + + command_exists = subprocess.run(["command", "-v" "htpasswd"], shell=True) + if command_exists.returncode: + print("Could not find htpasswd, cannot update password") + return 1 + + creds = Path(args.data) / "credentials.json" + if not creds.parent.exists(): + creds.parent.mkdir(parents=True) + + user = pwd.getpwuid(os.getuid()).pw_name + password = getpass.getpass(f"intake password for {user}: ") + update_pwd = subprocess.run( + ["htpasswd", "-b", "/etc/intake/htpasswd", user, password] + ) + if update_pwd.returncode: + print("Could not update password file") + return 1 + + new_creds = {"username": user, "secret": password} + creds.write_text(json.dumps(new_creds, indent=2)) + + return 0 + + def cmd_run(cmd_args): """Run the default Flask server.""" parser = argparse.ArgumentParser( diff --git a/module.nix b/module.nix index 0050e73..66eabfc 100644 --- a/module.nix +++ b/module.nix @@ -9,19 +9,22 @@ in { listen.addr = mkOption { type = types.str; default = "0.0.0.0"; - description = "The listen address for the entry point to intake services. This endpoint will redirect to a local port based on the request's HTTP Basic Auth credentials."; + description = "The listen address for the entry point to intake services. This endpoint will redirect to a " + "local port based on the request's HTTP Basic Auth credentials."; }; listen.port = mkOption { type = types.port; default = 80; - description = "The listen port for the entry point to intake services. This endpoint will redirect to a local port based on the request's HTTP Basic Auth credentials."; + description = "The listen port for the entry point to intake services. This endpoint will redirect to a local " + "port based on the request's HTTP Basic Auth credentials."; }; internalPortStart = mkOption { type = types.port; default = 24130; - description = "The first port to use for internal service endpoints. A number of ports will be continguously allocated equal to the number of users with enabled intake services."; + description = "The first port to use for internal service endpoints. A number of ports will be continguously " + "allocated equal to the number of users with enabled intake services."; }; users = mkOption { @@ -53,7 +56,39 @@ in { enabledUserNames = mapAttrsToList (userName: userCfg: userName) enabledUsers; userPortList = imap1 (i: userName: { ${userName} = i + intakeCfg.internalPortStart; }) enabledUserNames; userPort = foldl (acc: val: acc // val) {} userPortList; + + # To avoid polluting PATH with httpd programs, define an htpasswd wrapper + htpasswdWrapper = pkgs.writeShellScriptBin "htpasswd" '' + ${pkgs.apacheHttpd}/bin/htpasswd $@ + ''; + + # File locations + intakeDir = "/etc/intake"; + intakePwd = "${intakeDir}/htpasswd"; in { + # Define a user group for access to the htpasswd file. + users.groups.intake.members = mkIf (enabledUsers != {}) (enabledUserNames ++ [ "nginx" ]); + + # Define an activation script that ensures that the htpasswd file exists. + system.activationScripts.etc-intake = '' + if [ ! -e ${intakeDir} ]; then + ${pkgs.coreutils}/bin/mkdir -p ${intakeDir}; + fi + ${pkgs.coreutils}/bin/chown root:root ${intakeDir} + ${pkgs.coreutils}/bin/chmod 755 ${intakeDir} + if [ ! -e ${intakePwd} ]; then + ${pkgs.coreutils}/bin/touch ${intakePwd} + fi + ${pkgs.coreutils}/bin/chown root:intake ${intakePwd} + ${pkgs.coreutils}/bin/chmod 660 ${intakePwd} + ''; + + # Give the htpasswd wrapper to every intake user + users.users = + let + addWrapperToUser = userName: { ${userName}.packages = [ htpasswdWrapper ]; }; + in mkMerge (map addWrapperToUser enabledUserNames); + # Define a user service for each configured user systemd.services = let @@ -84,7 +119,7 @@ in { listen = [ intakeCfg.listen ]; locations."/" = { proxyPass = "http://127.0.0.1:$target_port"; - basicAuth = { alice = "alpha"; bob = "beta"; }; + basicAuthFile = intakePwd; }; extraConfig = foldl (acc: val: acc + val) "" (mapAttrsToList (userName: port: '' if ($remote_user = "${userName}") {