diff --git a/.gitignore b/.gitignore index 6e35230..d584aee 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.DS_Store userdata/userdata.json userdata/tokens.json hardware-configuration.nix diff --git a/sp-modules/actual/config-paths-needed.json b/sp-modules/actual/config-paths-needed.json new file mode 100644 index 0000000..2e4e2fc --- /dev/null +++ b/sp-modules/actual/config-paths-needed.json @@ -0,0 +1,59 @@ +[ + [ + "services", + "actual" + ], + [ + "systemd", + "services", + "actual" + ], + [ + "selfprivacy", + "domain" + ], + [ + "selfprivacy", + "useBinds" + ], + [ + "selfprivacy", + "modules", + "actual" + ], + [ + "selfprivacy", + "modules", + "auth", + "enable" + ], + [ + "selfprivacy", + "sso", + "enable" + ], + [ + "selfprivacy", + "passthru", + "auth", + "mkOAuth2ClientSecretFP" + ], + [ + "selfprivacy", + "passthru", + "auth", + "mkServiceAccountTokenFP" + ], + [ + "selfprivacy", + "passthru", + "auth", + "oauth2-discovery-url" + ], + [ + "selfprivacy", + "passthru", + "auth", + "oauth2-provider-name" + ] +] \ No newline at end of file diff --git a/sp-modules/actual/flake.nix b/sp-modules/actual/flake.nix new file mode 100644 index 0000000..beaefac --- /dev/null +++ b/sp-modules/actual/flake.nix @@ -0,0 +1,41 @@ +{ + description = "Flake description"; + + outputs = + { self }: + { + nixosModules.default = import ./module.nix; + configPathsNeeded = builtins.fromJSON (builtins.readFile ./config-paths-needed.json); + meta = + { lib, ... }: + { + spModuleSchemaVersion = 1; + id = "actual"; + name = "Actual"; + description = "Actual (aka Actual Budget) is a super fast and privacy-focused app for managing your finances."; + svgIcon = builtins.readFile ./icon.svg; + showUrl = true; + primarySubdomain = "subdomain"; + isMovable = false; + isRequired = false; + canBeBackedUp = true; + backupDescription = "Your budgets, settings, and account secrets (where applicable)."; + systemdServices = [ + "actual.service" + ]; + user = "actual"; + group = "actual"; + folders = [ + "/var/lib/actual" + ]; + + license = [ + lib.licenses.mit + ]; + homepage = "https://actualbudget.org/"; + sourcePage = "https://github.com/actualbudget/actual"; + # since this module hasn't been thoroughly tested, I'd advertise it as `experimental`, but is also a `community` class module + supportLevel = "experimental"; + }; + }; +} diff --git a/sp-modules/actual/icon-lg.svg b/sp-modules/actual/icon-lg.svg new file mode 100644 index 0000000..9f15e98 --- /dev/null +++ b/sp-modules/actual/icon-lg.svg @@ -0,0 +1,48 @@ + + + + diff --git a/sp-modules/actual/icon.svg b/sp-modules/actual/icon.svg new file mode 100644 index 0000000..762f5eb --- /dev/null +++ b/sp-modules/actual/icon.svg @@ -0,0 +1,44 @@ + + + + diff --git a/sp-modules/actual/module.nix b/sp-modules/actual/module.nix new file mode 100644 index 0000000..733391d --- /dev/null +++ b/sp-modules/actual/module.nix @@ -0,0 +1,239 @@ +{ + config, + lib, + pkgs, + ... +}: +let + # Just for convenience, this module's config values + sp = config.selfprivacy; + cfg = sp.modules.actual; + + is-auth-enabled = cfg.enableSso && config.selfprivacy.sso.enable; + oauthClientID = "actual"; + auth-passthru = config.selfprivacy.passthru.auth; + full-domain = "https://${cfg.subdomain}.${sp.domain}"; + redirect-uri = "${full-domain}/openid/callback"; + landing-uri = "${full-domain}/login"; + oauthDiscoveryURL = auth-passthru.oauth2-discovery-url oauthClientID; + adminsGroup = "sp.${oauthClientID}.admins"; + usersGroup = "sp.${oauthClientID}.users"; + + linuxUserOfService = "actual"; + linuxGroupOfService = "actual"; + + oauthClientSecretFP = auth-passthru.mkOAuth2ClientSecretFP linuxGroupOfService; + oauthSecretDir = "/run/actual/shh"; + oauthSecretFile = "${oauthSecretDir}/totallynotasecretfile.env"; + # creates an env file with the oauth client secret and configures permissions for the actual user/group + oauthClientInjectScript = pkgs.writeShellScript "inject-oidc-secrets" '' + mkdir -p ${oauthSecretDir} + echo "ACTUAL_OPENID_CLIENT_SECRET=$(<${oauthClientSecretFP})" > ${oauthSecretFile} + chown actual:actual ${oauthSecretFile} + chmod 600 ${oauthSecretFile} + ''; +in +{ + # Here go the options you expose to the user. + options.selfprivacy.modules.actual = { + # This is required and must always be named "enable" + enable = + (lib.mkOption { + default = false; + type = lib.types.bool; + description = "Enable the Actual Budget server"; + }) + // { + meta = { + type = "enable"; + }; + }; + # This is required if your service stores data on disk + location = + (lib.mkOption { + type = lib.types.str; + description = "Data location"; + }) + // { + meta = { + type = "location"; + }; + }; + # This is required if your service needs a subdomain + subdomain = + (lib.mkOption { + default = "actual"; + type = lib.types.strMatching "[A-Za-z0-9][A-Za-z0-9\-]{0,61}[A-Za-z0-9]"; + description = "Subdomain"; + }) + // { + meta = { + widget = "subdomain"; + type = "string"; + regex = "[A-Za-z0-9][A-Za-z0-9\-]{0,61}[A-Za-z0-9]"; + weight = 0; + }; + }; + # Other options, that user sees directly. + # Refer to Module options reference to learn more. + enableSso = + (lib.mkOption { + default = false; + type = lib.types.bool; + description = "Enable Single Sign-On"; + }) + // { + meta = { + type = "bool"; + weight = 2; + }; + }; + enableDebug = + (lib.mkOption { + default = false; + type = lib.types.bool; + description = "Enable Debug Logging"; + }) + // { + meta = { + type = "bool"; + weight = 3; + }; + }; + }; + + # All your changes to the system must go to this config attrset. + # It MUST use lib.mkIf with an enable option. + # This makes sure your module only makes changes to the system + # if the module is enabled. + config = lib.mkIf cfg.enable ( + lib.mkMerge [ + { + # prevent SSO from being enabled in the module config if SSO isn't available/is disabled + assertions = [ + { + assertion = cfg.enableSso -> sp.sso.enable; + message = "SSO cannot be enabled for Actual when SSO is disabled globally."; + } + ]; + # If your service stores data on disk, you have to mount a folder + # for this. useBinds is always true on modern SelfPrivacy installations + # but we keep this mkIf to keep migration flow possible. + fileSystems = lib.mkIf sp.useBinds { + "/var/lib/actual" = { + device = "/volumes/${cfg.location}/actual"; + # Make sure that your service does not start before folder mounts + options = [ + "bind" + "x-systemd.required-by=actual.service" + "x-systemd.before=actual.service" + ]; + }; + }; + + # actual service config + services.actual = { + enable = true; + settings = { + port = 5006; + # default to only password logins + allowedLoginMethods = [ "password" ]; + }; + }; + # adding the user/group to be used by the service + users = { + users.actual = { + isSystemUser = true; + group = "actual"; + }; + groups.actual = {}; + }; + + systemd = { + services = { + actual = { + # extra guard against the service starting before the bind has been mounted + unitConfig.RequiresMountsFor = lib.mkIf sp.useBinds "/volumes/${cfg.location}/actual"; + serviceConfig = { + Slice = "actual.slice"; + # override dynamic user since service from nixpkgs enables by default, but it doesn't work in the selfprivacy environment + DynamicUser = lib.mkForce false; + # use service user + User = "actual"; + # use service group + Group = "actual"; + }; + environment = + # tell actual to log debug info to the console if option is enabled + (lib.mkIf cfg.enableDebug { + DEBUG = "actual:config,actual-sensitive:config"; + } + ); + }; + }; + # Define the slice itself + slices.actual = { + description = "Actual server service slice"; + }; + }; + + # You can define a reverse proxy for your service like this + services.nginx.virtualHosts."${cfg.subdomain}.${sp.domain}" = { + useACMEHost = sp.domain; + forceSSL = true; + locations = { + "/" = { + proxyPass = "http://127.0.0.1:5006"; + }; + }; + }; + } + # SSO config + (lib.mkIf is-auth-enabled { + services.actual = { + settings = { + # permit openid logins + allowedLoginMethods = [ "openid" ]; + # default to openid if enabled + loginMethod = "openid"; + # SSO config + openId = { + discoveryURL = oauthDiscoveryURL; + client_id = oauthClientID; + server_hostname = full-domain; + authMethod = "openid"; + }; + }; + }; + systemd.services.actual = { + serviceConfig={ + # run inject script with root privileges + ExecStartPre = "+${oauthClientInjectScript}"; + # use the file generated by the inject script, even if it doesn't yet exist + EnvironmentFile = "-${oauthSecretFile}"; + RuntimeDirectory = "actual"; + }; + }; + + # OIDC for Actual is currently in beta and requires legacy cryptography algorithms + services.kanidm.provision.systems.oauth2."${oauthClientID}".enableLegacyCrypto = true; + # Configure the OIDC client + selfprivacy.auth.clients."${oauthClientID}" = { + inherit adminsGroup usersGroup; + imageFile = ./icon-lg.svg; + displayName = "Actual"; + subdomain = cfg.subdomain; + originLanding = landing-uri; + originUrl = redirect-uri; + clientSystemdUnits = [ "actual.service" ]; + enablePkce = true; + linuxUserOfClient = linuxUserOfService; + linuxGroupOfClient = linuxGroupOfService; + useShortPreferredUsername = true; + scopeMaps.${usersGroup} = [ "email" "openid" "profile" ]; + }; + }) + ] + ); + +}