diff --git a/go/default.nix b/go/default.nix index d92bb2b8fa..681f76998b 100644 --- a/go/default.nix +++ b/go/default.nix @@ -15,6 +15,7 @@ args: { access = import ./access args; vault = import ./vault args; tumblrandom = import ./tumblrandom args; + emfminiserv = import ./emfminiserv args; buildgo2 = import ./buildgo2 args; } diff --git a/go/emfminiserv/default.nix b/go/emfminiserv/default.nix new file mode 100644 index 0000000000..e373b4a270 --- /dev/null +++ b/go/emfminiserv/default.nix @@ -0,0 +1,12 @@ +# SPDX-FileCopyrightText: 2024 Luke Granger-Brown +# +# SPDX-License-Identifier: Apache-2.0 + +{ depot, ... }: +depot.third_party.buildGo.program { + name = "emfminiserv"; + srcs = [ ./emfminiserv.go ]; + deps = with depot.third_party; [ + # gopkgs."github.com".golang.glog + ]; +} diff --git a/go/emfminiserv/emfminiserv.go b/go/emfminiserv/emfminiserv.go new file mode 100644 index 0000000000..0e920845bb --- /dev/null +++ b/go/emfminiserv/emfminiserv.go @@ -0,0 +1,216 @@ +package main + +import ( + "crypto/hmac" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "flag" + "fmt" + "io/fs" + "log" + "net" + "net/http" + "os" + "regexp" + "sort" + "strings" + "sync" +) + +var ( + baseDir = flag.String("base_dir", "/store/emf/2024/video/output/", "base directory for output") + httpListen = flag.String("http_listen", "", "TCP address to listen&serve") + httpListenUNIX = flag.String("http_listen_unix", "", "UNIX socket path to listen&serve") + + computeForBase = flag.String("compute_base", "https://prerelease.voc.emf.camp/", "base URL to prepend when computing a secret") + computeFor = flag.String("compute", "", "something to compute the secret for") + + list = flag.Bool("list", false, "list the available content") + + devMode = flag.Bool("dev_mode", false, "enable insecure dev mode") +) + +func computeSignature(content, secret string) string { + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(content)) + return base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(mac.Sum(nil)) +} + +func validateSignature(content, secret, signature string) bool { + computed := computeSignature(content, secret) + if *devMode { + log.Printf("got signature %q; computed signature %q", signature, computed) + } + return subtle.ConstantTimeCompare([]byte(computed), []byte(signature)) == 1 +} + +const ( + talkRegexFragment = `[0-9]+(_[\p{L}\p{N}\-]+)?` + fileEndRegexFragment = `[0-9]{8}-[0-9]{6}[.]mp4` +) + +var ( + validContentRegex = regexp.MustCompile("^" + talkRegexFragment + "$") + validFileRegex = regexp.MustCompile("^" + fileEndRegexFragment + "$") + filenameRegex = regexp.MustCompile(`^(` + talkRegexFragment + ")-(" + fileEndRegexFragment + ")$") +) + +func logResultFactory(r *http.Request, what string) func(string, ...any) { + return func(f string, bits ...any) { + log.Printf("%s{%s}: %s %s - %s", what, r.RemoteAddr, r.Method, r.RequestURI, fmt.Sprintf(f, bits...)) + } +} + +func main() { + flag.Parse() + + contentFS := os.DirFS(*baseDir) + + secret := os.Getenv("EMFMINISERV_SECRET") + if secret == "" { + if !*devMode { + log.Fatal("set EMFMINISERV_SECRET or -dev_mode") + } + secret = "testing-do-not-use-in-production" + log.Printf("secret is set to %q! development mode only.", secret) + } + + if *httpListen == "" && *httpListenUNIX == "" && *computeFor == "" && !*list { + log.Printf("need -http_listen, -http_listen_unix, -compute or -list") + os.Exit(1) + } + + if *list { + matches, err := fs.Glob(contentFS, "*.mp4") + if err != nil { + log.Fatalf("globbing for *.mp4: %v", err) + } + + available := map[string]bool{} + + for _, m := range matches { + bits := filenameRegex.FindStringSubmatch(m) + if bits == nil { + continue + } + available[bits[1]] = true + } + + availableKeys := make([]string, 0, len(available)) + for k := range available { + availableKeys = append(availableKeys, k) + } + sort.Strings(availableKeys) + + for _, k := range availableKeys { + fmt.Printf("%s\n", k) + } + + os.Exit(0) + } + + if *computeFor != "" { + sig := computeSignature(*computeFor, secret) + fmt.Printf("%s%s/%s/\n", *computeForBase, *computeFor, sig) + os.Exit(0) + } + + http.HandleFunc("/{content}/{signature}/{file}", func(rw http.ResponseWriter, r *http.Request) { + logResult := logResultFactory(r, "contentget") + + if r.Method != "GET" { + logResult("method not allowed") + http.Error(rw, "GET only", http.StatusMethodNotAllowed) + return + } + + log.Printf("contentget{%s}: %s %s", r.RemoteAddr, r.Method, r.RequestURI) + + content := r.PathValue("content") + file := r.PathValue("file") + if !validContentRegex.MatchString(content) || !validFileRegex.MatchString(file) { + logResult("404 invalid content or file segment") + http.NotFound(rw, r) + return + } + if !validateSignature(content, secret, r.PathValue("signature")) { + logResult("404 invalid signature") + http.NotFound(rw, r) + return + } + + rw.Header().Set("x-accel-redir", fmt.Sprintf("/%s-%s", content, file)) + logResult("200 OK") + }) + http.HandleFunc("/{content}/{signature}/{$}", func(rw http.ResponseWriter, r *http.Request) { + logResult := logResultFactory(r, "contentdir") + + if r.Method != "GET" { + logResult("method not allowed") + http.Error(rw, "GET only", http.StatusMethodNotAllowed) + return + } + + content := r.PathValue("content") + if !validContentRegex.MatchString(content) { + logResult("404 invalid content segment") + http.NotFound(rw, r) + return + } + if !validateSignature(content, secret, r.PathValue("signature")) { + logResult("404 invalid signature") + http.NotFound(rw, r) + return + } + + matches, err := fs.Glob(contentFS, fmt.Sprintf("%s-*", content)) + if err != nil { + logResult("500 globbing with %s-*: %v", content, err) + http.Error(rw, "internal server error finding content", http.StatusInternalServerError) + return + } + sort.Strings(matches) + + unprefixed := make([]string, len(matches)) + for n, m := range matches { + unprefixed[n] = strings.TrimPrefix(m, fmt.Sprintf("%s-", content)) + } + + rw.Header().Set("Content-type", "text/html; encoding=utf-8") + + fmt.Fprintf(rw, "") + logResult("200 OK") + }) + + var wg sync.WaitGroup + if *httpListen != "" { + wg.Add(1) + go func() { + defer wg.Done() + log.Fatal(http.ListenAndServe(*httpListen, nil)) + }() + } + if *httpListenUNIX != "" { + wg.Add(1) + go func() { + defer wg.Done() + l, err := net.Listen("unix", *httpListenUNIX) + if err != nil { + log.Fatalf("listening on unix:%v: %v", *httpListenUNIX, err) + } + defer l.Close() + + if err := os.Chmod(*httpListenUNIX, 0770); err != nil { + log.Fatalf("chmodding unix:%v: %v", *httpListenUNIX, err) + } + + log.Fatal(http.Serve(l, nil)) + }() + } + wg.Wait() +} diff --git a/ops/nixos/lib/emfminiserv.nix b/ops/nixos/lib/emfminiserv.nix new file mode 100644 index 0000000000..10da6b5626 --- /dev/null +++ b/ops/nixos/lib/emfminiserv.nix @@ -0,0 +1,86 @@ +# SPDX-FileCopyrightText: 2024 Luke Granger-Brown +# +# SPDX-License-Identifier: Apache-2.0 + +{ depot, pkgs, lib, config, ... }: + +let + emfminiserv = depot.go.emfminiserv; + + cfg = config.my.emfminiserv; +in +{ + options.my.emfminiserv = { + enable = lib.mkEnableOption "emfminiserv"; + hostname = lib.mkOption { + type = lib.types.str; + default = "prerelease.voc.emf.camp"; + }; + listenAddresses = lib.mkOption { + type = lib.types.nullOr (lib.types.listOf lib.types.str); + default = null; + }; + serveDir = lib.mkOption { + type = lib.types.str; + default = "/store/emf/2024/video/output/"; + }; + }; + + config = lib.mkMerge [ + (lib.mkIf cfg.enable { + users.groups.hackyplayer = {}; + + systemd.services.emfminiserv = { + serviceConfig = { + User = "emfminiserv"; + Group = "hackyplayer"; + RuntimeDirectory = "emfminiserv"; + DynamicUser = true; + ExecStart = "${emfminiserv}/bin/emfminiserv -http_listen_unix /run/emfminiserv/listen.sock -base_dir '${cfg.serveDir}'"; + EnvironmentFile = config.my.vault.secrets.emfminiserv-environment.path; + }; + wantedBy = [ "multi-user.target" ]; + }; + + my.vault.secrets.emfminiserv-environment = { + reloadOrRestartUnits = ["emfminiserv.service"]; + group = "hackyplayer"; + template = '' + {{ with secret "kv/apps/emfminiserv" }} + {{ .Data.data.environment }} + {{ end }} + ''; + }; + + environment.systemPackages = [ + (pkgs.writeShellApplication { + name = "emfminiserv"; + text = '' + read -ra vars < <(xargs <"${config.my.vault.secrets.emfminiserv-environment.path}") + export "''${vars[@]}" + exec "${emfminiserv}/bin/emfminiserv" -base_dir '${cfg.serveDir}' "$@" + ''; + }) + ]; + + services.caddy = { + enable = true; + virtualHosts."${cfg.hostname}" = { + listenAddresses = lib.mkIf (cfg.listenAddresses != null) cfg.listenAddresses; + extraConfig = '' + reverse_proxy unix//run/emfminiserv/listen.sock { + @accel header X-Accel-Redir * + handle_response @accel { + root * ${cfg.serveDir} + rewrite * {rp.header.X-Accel-Redir} + method * GET + file_server + } + } + ''; + }; + }; + systemd.services.caddy.serviceConfig.SupplementaryGroups = lib.mkAfter [ "hackyplayer" ]; + }) + ]; +} diff --git a/ops/nixos/lib/hackyplayer.nix b/ops/nixos/lib/hackyplayer.nix new file mode 100644 index 0000000000..67ca96a87e --- /dev/null +++ b/ops/nixos/lib/hackyplayer.nix @@ -0,0 +1,271 @@ +# SPDX-FileCopyrightText: 2024 Luke Granger-Brown +# +# SPDX-License-Identifier: Apache-2.0 + +{ depot, pkgs, lib, config, ... }: + +let + hackyplayerSrc = /home/lukegb/Projects/hackyplayer; + hackyplayer = import hackyplayerSrc { inherit pkgs; }; + + yamlFormat = pkgs.formats.yaml {}; + + cfg = config.my.hackyplayer; + processedConfig = lib.mapAttrs' (name: value: lib.nameValuePair "FLASK_${name}" (builtins.toJSON value)) cfg.config; + readWritePaths = [ + cfg.config.VIDEO_OUTPUT + cfg.config.VIDEO_TEMP + cfg.config.LOG_DIR + ] + ++ (map (value: value.OUTPUT_DIR) cfg.config.WATCHFOLDERS) + ++ (map (value: value.FULLPATH) cfg.config.WATCHFOLDERS); +in +{ + options.my.hackyplayer = { + enable = lib.mkEnableOption "hackyplayer"; + hostname = lib.mkOption { + type = lib.types.str; + }; + listenAddresses = lib.mkOption { + type = lib.types.nullOr (lib.types.listOf lib.types.str); + default = null; + }; + config = lib.mkOption { + type = lib.types.anything; + default = let + paths = { + input = "/store/emf/2024/video/input"; + source = "/store/emf/2024/video/source"; + output = "/store/emf/2024/video/output"; + temp = "/store/emf/2024/video/tmp"; + logs = "/store/emf/2024/video/logs"; + }; + in { + VIDEO_SOURCES = [{ + DISKDIR = paths.source; + WEBDIR = "source"; + EXT = [".mp4"]; + NAME = "Source"; + } { + DISKDIR = paths.output; + WEBDIR = "output"; + EXT = [".mp4"]; + NAME = "Output"; + }]; + VIDEO_OUTPUT = paths.output; + VIDEO_TEMP = paths.temp; + WATCHFOLDERS = [{ + NAME = "input"; + FULLPATH = paths.input; + OUTPUT_DIR = paths.source; + }]; + LOG_DIR = paths.logs; + }; + }; + caddyPrefixExtraHandles = lib.mkOption { + type = lib.types.lines; + default = ""; + }; + caddyPrefixExtraConfig = lib.mkOption { + type = lib.types.lines; + default = ""; + }; + gunicornConcurrency = lib.mkOption { + type = lib.types.int; + default = 4; + }; + workerConcurrency = lib.mkOption { + type = lib.types.int; + default = 4; + }; + vouch = { + enable = lib.mkEnableOption "hackyplayer-vouch"; + config = lib.mkOption { + type = lib.types.submodule { + freeformType = yamlFormat.type; + }; + default = {}; + }; + }; + }; + + config = lib.mkMerge [ + (lib.mkIf cfg.enable { + users.users.hackyplayer = { + isSystemUser = true; + group = "hackyplayer"; + }; + users.groups.hackyplayer = {}; + + systemd.services.hackyplayer = { + serviceConfig = { + User = "hackyplayer"; + ProtectSystem = "strict"; + ProtectHome = "read-only"; + PrivateTmp = true; + RemoveIPC = true; + RuntimeDirectory = "hackyplayer"; + ExecStart = "${hackyplayer}/bin/gunicorn -w ${toString cfg.gunicornConcurrency} --bind unix:/run/hackyplayer/hackyplayer.sock hackyplayer.app:app"; + EnvironmentFile = config.my.vault.secrets.hackyplayer-environment.path; + }; + wantedBy = [ "hackyplayer.target" ]; + environment = processedConfig; + }; + systemd.services.hackyplayer-celery = { + serviceConfig = { + User = "hackyplayer"; + ProtectSystem = "strict"; + ProtectHome = "read-only"; + PrivateTmp = true; + RemoveIPC = true; + RuntimeDirectory = "hackyplayer-celery"; + ExecStart = "${hackyplayer}/bin/celery -A hackyplayer.tasks worker --concurrency ${toString cfg.workerConcurrency} --loglevel INFO --statedb /run/hackyplayer-celery/state.db"; + ReadWritePaths = readWritePaths; + EnvironmentFile = config.my.vault.secrets.hackyplayer-environment.path; + }; + wantedBy = [ "hackyplayer.target" ]; + environment = processedConfig; + }; + systemd.targets.hackyplayer = { + wantedBy = [ "multi-user.target" ]; + }; + + my.vault.secrets.hackyplayer-environment = { + reloadOrRestartUnits = ["hackyplayer.service"]; + group = "root"; + template = '' + {{ with secret "kv/apps/hackyplayer" }} + {{ .Data.data.environment }} + {{ end }} + ''; + }; + + environment.systemPackages = let + envPrelude = lib.concatStringsSep "\n" ( + lib.mapAttrsToList (var: val: "export ${var}=${lib.escapeShellArg val}") processedConfig); + in [ + (pkgs.writeShellApplication { + name = "hackyplayer-celery"; + text = '' + ${envPrelude} + exec ${hackyplayer}/bin/celery -A hackyplayer.tasks "$@" + ''; + }) + (pkgs.writeShellApplication { + name = "hackyplayer-flask"; + text = '' + ${envPrelude} + exec ${hackyplayer}/bin/flask -A hackyplayer.app:app "$@" + ''; + }) + ]; + + services.caddy = { + enable = true; + virtualHosts."${cfg.hostname}" = { + listenAddresses = lib.mkIf (cfg.listenAddresses != null) cfg.listenAddresses; + extraConfig = '' + ${cfg.caddyPrefixExtraHandles} + handle { + ${cfg.caddyPrefixExtraConfig} + ${lib.concatMapStrings (src: '' + route /static/video/${src.WEBDIR}/* { + uri strip_prefix /static/video/${src.WEBDIR} + root * ${src.DISKDIR} + file_server + } + '') cfg.config.VIDEO_SOURCES} + route /static/* { + uri strip_prefix /static + root * ${hackyplayer}/${hackyplayer.python.sitePackages}/hackyplayer/static + file_server + } + reverse_proxy unix//run/hackyplayer/hackyplayer.sock + } + ''; + }; + }; + systemd.services.caddy.serviceConfig.SupplementaryGroups = lib.mkAfter [ "hackyplayer" ]; + + services.redis.servers."".enable = true; + + my.hackyplayer.vouch.enable = lib.mkDefault true; + my.hackyplayer.vouch.config = lib.mkDefault { + vouch = { + allowAllUsers = true; + cookie.domain = "voc.emf.camp"; + listen = "unix:/run/hacky-vouchproxy/sock"; + socket_mode = "0770"; + socket_group = "hackyplayer"; + document_root = "/_vouch"; + }; + + oauth = let + base = "https://identity.emfcamp.org"; + in { + provider = "oidc"; + auth_url = "${base}/oauth2/authorize"; + token_url = "${base}/oauth2/token"; + user_info_url = "${base}/oauth2/userinfo"; + scopes = [ "profile" ]; + callback_url = "https://hackyplayer.voc.emf.camp/auth"; + }; + }; + }) (lib.mkIf cfg.vouch.enable { + environment.etc."hacky-vouchproxy/config.yaml".source = yamlFormat.generate "hackyplayer-vouch-config.yaml" cfg.vouch.config; + systemd.services.hacky-vouchproxy = { + serviceConfig = { + User = "hacky-vouchproxy"; + SupplementaryGroups = [ "hackyplayer" ]; + DynamicUser = true; + RuntimeDirectory = "hacky-vouchproxy"; + ExecStart = "${pkgs.vouch-proxy}/bin/vouch-proxy -config /etc/hacky-vouchproxy/config.yaml"; + EnvironmentFile = config.my.vault.secrets.hacky-vouchproxy-environment.path; + }; + wantedBy = [ "multi-user.target" ]; + }; + + my.vault.secrets.hacky-vouchproxy-environment = { + reloadOrRestartUnits = ["hacky-vouchproxy.service"]; + group = "root"; + template = '' + {{ with secret "kv/apps/hacky-vouchproxy" }} + {{ .Data.data.environment }} + {{ end }} + ''; + }; + + my.hackyplayer.caddyPrefixExtraHandles = lib.mkBefore '' + handle /auth { + reverse_proxy unix//run/hacky-vouchproxy/sock { + rewrite /_vouch/auth + } + } + handle /_vouch/* { + reverse_proxy unix//run/hacky-vouchproxy/sock + } + ''; + my.hackyplayer.caddyPrefixExtraConfig = lib.mkBefore '' + reverse_proxy unix//run/hacky-vouchproxy/sock { + method GET + rewrite /_vouch/validate + + header_up Host {host} + + @good status 2xx + handle_response @good { + request_header X-Vouch-User {rp.header.X-Vouch-User} + } + + @bad status 4xx + handle_response @bad { + header Location /_vouch/login?url=https://{host}{uri}&vouch-failcount={rp.header.X-Vouch-Failcount}&X-Vouch-Token={rp.header.X-Vouch-JWT}&error={rp.header.X-Vouch-Err} + respond 303 { + close + } + } + } + ''; + }) + ]; +} diff --git a/ops/nixos/nausicaa/default.nix b/ops/nixos/nausicaa/default.nix index 80815fbc3f..5369ad89ac 100644 --- a/ops/nixos/nausicaa/default.nix +++ b/ops/nixos/nausicaa/default.nix @@ -15,6 +15,7 @@ in { ../lib/rexxar-distributed.nix ../lib/quadv-ca/default.nix ((import ../../../third_party/lanzaboote.nix { }).nixosModules.lanzaboote) + ../lib/hackyplayer.nix ]; boot = lib.mkMerge [{ @@ -299,6 +300,12 @@ in { enable = true; }; + my.hackyplayer = { + enable = true; + listenAddresses = [ "127.0.0.1" "::1" ]; + hostname = "hackyplayer.localhost"; + }; + # This value determines the NixOS release with which your system is to be # compatible, in order to avoid breaking some software such as database # servers. You should change this only after NixOS release notes say you diff --git a/ops/nixos/rexxar/default.nix b/ops/nixos/rexxar/default.nix index 1ef041d76c..2d90a5ec88 100644 --- a/ops/nixos/rexxar/default.nix +++ b/ops/nixos/rexxar/default.nix @@ -10,6 +10,8 @@ ../lib/bgp.nix ../lib/gitlab-runner-cacher.nix #../lib/nixbuild-distributed.nix # error: build of '/nix/store/3r7456yr8r9g4fl7w6xbgqlbsdjwfvr4-stdlib-pkgs.json.drv' on 'ssh://eu.nixbuild.net' failed: unexpected: Built outputs are invalid + ../lib/hackyplayer.nix + ../lib/emfminiserv.nix ]; # Otherwise _this_ machine won't enumerate things properly. @@ -56,6 +58,13 @@ "/store" = zfs "zu2/safe/store"; "/home" = zfs "zu2/safe/home"; + "/store/emf" = zfs "zu2/safe/store/emf"; + "/store/emf/2024" = zfs "zu2/safe/store/emf/2024"; + "/store/emf/2024/video" = zfs "zu2/safe/store/emf/2024/video"; + "/store/emf/2024/video/source" = zfs "zu2/safe/store/emf/2024/video/source"; + "/store/emf/2024/video/input" = zfs "zu2/safe/store/emf/2024/video/input"; + "/store/emf/2024/video/output" = zfs "zu2/safe/store/emf/2024/video/output"; + "/boot" = { device = "/dev/disk/by-label/ESP"; fsType = "vfat"; @@ -81,6 +90,10 @@ allowedUDPPorts = [ 51821 51822 51823 34197 # factorio + 443 + ]; + allowedTCPPorts = [ + 80 443 ]; }; }; @@ -348,8 +361,10 @@ wg-cofractal-ams01-private = wireguardSecret "privateKeyToCofractalAms01"; }; + users.users.lukegb.extraGroups = lib.mkAfter [ "hackyplayer" ]; users.users.samw = { isNormalUser = true; + extraGroups = lib.mkAfter [ "hackyplayer "]; openssh.authorizedKeys.keys = [ "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBDQdGzwYiallvWXIHgSAf2GOwMJKA8bxPmwyuO+vsd1HwB65hMRPCpKS+FNLIpkrADNnuhGS3xGCGSSuQ+zAu/g=" "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBNLrJyUXmiFWb9vhlTWZLYr64IsKut+c9TGqq3/uwPDeF4X0Qb2jzxqXfQcDSztjR09JHbC8BOqfpYYT9LHahIo=" @@ -377,5 +392,17 @@ }; }; + my.hackyplayer = { + enable = true; + hostname = "hackyplayer.voc.emf.camp"; + workerConcurrency = 13; + }; + my.emfminiserv = { + enable = true; + hostname = "prerelease.voc.emf.camp"; + }; + + hardware.rasdaemon.enable = true; + system.stateVersion = "24.05"; } diff --git a/ops/vault/cfg/config.nix b/ops/vault/cfg/config.nix index 6552056fcc..fa79c997b0 100644 --- a/ops/vault/cfg/config.nix +++ b/ops/vault/cfg/config.nix @@ -72,6 +72,9 @@ my.apps.nixbuild = {}; my.apps.tumblrandom = {}; my.apps.netbox = {}; + my.apps.hacky-vouchproxy = {}; + my.apps.hackyplayer = {}; + my.apps.emfminiserv = {}; my.servers.etheroute-lon01.apps = [ "pomerium" ]; my.servers.howl.apps = [ "nixbuild" ]; @@ -88,5 +91,5 @@ my.servers.bvm-nixosmgmt.apps = [ "plex-pass" ]; my.servers.blade-tuvok.apps = [ "fup" ]; my.servers.bvm-netbox.apps = [ "netbox" ]; - my.servers.rexxar.apps = [ "deluge" "gitlab-runner" "nixbuild" ]; + my.servers.rexxar.apps = [ "deluge" "gitlab-runner" "nixbuild" "hacky-vouchproxy" "hackyplayer" "emfminiserv" ]; }