diff --git a/.envrc b/.envrc new file mode 100644 index 000000000..c974eaba2 --- /dev/null +++ b/.envrc @@ -0,0 +1,14 @@ +# shellcheck shell=bash + +if command -v nix >/dev/null 2>&1; then + if ! has nix_direnv_version; then + log_status "nix-direnv not found; installing to add nix support..." + source_url "https://raw.githubusercontent.com/nix-community/nix-direnv/3.1.1/direnvrc" "sha256-p+fzQdrms/hDa7g+soShAybJNo4bN4SIAeSfqNKgD5I=" + fi + + log_status "Loading nix shell from flake..." + log_status "To suppress direnv log output: export DIRENV_LOG_FORMAT=\"\"" + + watch_file $(find . -name "*.nix") + use flake +fi diff --git a/.gitignore b/.gitignore index 22a8197dd..4aa32ba3c 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,6 @@ ui.zip # local embedded frontend output for fast E2E runs internal/http/dist/* !internal/http/dist/.gitkeep + +result +.direnv diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 000000000..49869f319 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,6 @@ +{ + "recommendations": [ + "bbenoist.nix", + "mkhl.direnv" + ] +} diff --git a/README.md b/README.md index 16c0d57aa..980102285 100644 --- a/README.md +++ b/README.md @@ -14,9 +14,13 @@ If you've looked at Wiki.js or Outline and thought "this is too much to operate → If it fits, [a star](https://github.com/perber/leafwiki) helps others find it. ```bash +# Docker docker run -p 8080:8080 -v ~/leafwiki-data:/app/data \ ghcr.io/perber/leafwiki:latest \ --jwt-secret=yoursecret --admin-password=yourpassword --allow-insecure=true + +# Nix +nix run github:perber/leafwiki -- --help ``` → [All install options](#install) (Docker Compose, Linux installer, binary) @@ -165,6 +169,81 @@ sudo ./install.sh --non-interactive --env-file ./.env - [Install with nginx on Ubuntu](docs/install/nginx.md) - [Install on a Raspberry Pi](docs/install/raspberry.md) +### Nix + +#### Try it without installing + +```bash +nix run github:perber/leafwiki -- --jwt-secret=yoursecret --admin-password=yourpassword --allow-insecure=true +``` + +#### Add to a NixOS configuration + +In your `flake.nix`: + +```nix +{ + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + leafwiki.url = "github:perber/leafwiki"; + }; + + outputs = { nixpkgs, leafwiki, ... }: { + nixosConfigurations.myhost = nixpkgs.lib.nixosSystem { + modules = [ + leafwiki.nixosModules.default + { + services.leafwiki = { + enable = true; + jwtSecretFile = "/run/secrets/leafwiki-jwt"; + adminPasswordFile = "/run/secrets/leafwiki-admin-password"; + # Optional overrides: + # host = "0.0.0.0"; + # port = 8080; + # dataDir = "/var/lib/leafwiki"; + # disableAuth = true; # insecure - only for local dev instances! + }; + } + ]; + }; + }; +} +``` + +Secrets are loaded from files at service start using systemd `LoadCredential`, so they never appear in the Nix store or the unit file. + +#### Add to a Home Manager configuration + +```nix +{ + inputs = { + home-manager.url = "github:nix-community/home-manager"; + leafwiki.url = "github:perber/leafwiki"; + }; + + outputs = { home-manager, leafwiki, ... }: { + homeConfigurations.myuser = home-manager.lib.homeManagerConfiguration { + modules = [ + leafwiki.homeManagerModules.default + { + services.leafwiki = { + enable = true; + jwtSecretFile = "/run/user/1000/secrets/leafwiki-jwt"; + adminPasswordFile = "/run/user/1000/secrets/leafwiki-admin-password"; + }; + } + ]; + }; + }; +} +``` + +The Home Manager module runs leafwiki as a `systemd --user` service. Enable lingering so it starts at boot without a login session: + +```bash +loginctl enable-linger $USER +``` + ### Binary ```bash diff --git a/flake.lock b/flake.lock new file mode 100644 index 000000000..225d4a914 --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1781577229, + "narHash": "sha256-lrp67w8AulE9Ks53n27I45ADSzbOCn4H+CNW1Ck8B+8=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "567a49d1913ce81ac6e9582e3553dd90a955875f", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 000000000..79ad6dcb7 --- /dev/null +++ b/flake.nix @@ -0,0 +1,55 @@ +{ + description = "LeafWiki - a fast wiki for people who think in folders, not feeds"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = + { + self, + nixpkgs, + flake-utils, + }: + let + version = "0.0.0"; + + perSystemOutputs = flake-utils.lib.eachDefaultSystem ( + system: + let + pkgs = nixpkgs.legacyPackages.${system}; + pkgSet = import ./nix/packages.nix { inherit pkgs version; }; + packages = { + default = pkgSet.leafwiki; + inherit (pkgSet) leafwiki ui; + }; + apps = rec { + default = leafwiki-app; + leafwiki-app = { + type = "app"; + program = "${pkgSet.leafwiki}/bin/leafwiki"; + }; + }; + shell = import ./nix/shell.nix { inherit pkgs; }; + in + { + inherit packages apps; + inherit (shell) devShells; + formatter = pkgs.nixfmt; + } + ); + in + perSystemOutputs + // { + nixosModules = { + default = import ./nix/nixos-module.nix self; + leafwiki = import ./nix/nixos-module.nix self; + }; + + homeManagerModules = { + default = import ./nix/home-manager-module.nix self; + leafwiki = import ./nix/home-manager-module.nix self; + }; + }; +} diff --git a/nix/common.nix b/nix/common.nix new file mode 100644 index 000000000..a52e50d88 --- /dev/null +++ b/nix/common.nix @@ -0,0 +1,82 @@ +# Shared option declarations and helpers used by both the NixOS and the +# Home Manager module. Callers must pass: +# - lib – the nixpkgs lib +# - defaultPkg – the resolved leafwiki package for the current system +# - dataDirType – the mkOption type for dataDir (path vs. str differs) +# - dataDirDefault – the default value for dataDir +{ + lib, + defaultPkg, + dataDirType, + dataDirDefault, +}: +{ + # ---- shared option declarations ----------------------------------------- + options = { + package = lib.mkOption { + type = lib.types.package; + default = defaultPkg; + description = "The leafwiki package to use."; + }; + + host = lib.mkOption { + type = lib.types.str; + default = "127.0.0.1"; + description = "Address to bind to."; + }; + + port = lib.mkOption { + type = lib.types.port; + default = 8080; + description = "Port to listen on."; + }; + + dataDir = lib.mkOption { + type = dataDirType; + default = dataDirDefault; + description = "Directory to store wiki data."; + }; + + jwtSecretFile = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "Path to a file containing the JWT secret. Required unless disableAuth is true."; + }; + + adminPasswordFile = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "Path to a file containing the admin password. Required unless disableAuth is true."; + }; + + disableAuth = lib.mkOption { + type = lib.types.bool; + default = false; + description = "Disable authentication (everyone can read and edit)."; + }; + + extraArgs = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + description = "Additional CLI arguments passed to leafwiki."; + }; + }; + + # ---- shared helpers ------------------------------------------------------ + + # Build the ExecStart string. The authArgs differ slightly between the NixOS + # module (reads from $CREDENTIALS_DIRECTORY) and Home Manager (reads the file + # path directly), so the caller supplies them. + mkExecStart = + cfg: authArgs: + lib.escapeShellArgs ( + [ + "${cfg.package}/bin/leafwiki" + "--host=${cfg.host}" + "--port=${toString cfg.port}" + "--data-dir=${cfg.dataDir}" + ] + ++ authArgs + ++ cfg.extraArgs + ); +} diff --git a/nix/home-manager-module.nix b/nix/home-manager-module.nix new file mode 100644 index 000000000..64dac7573 --- /dev/null +++ b/nix/home-manager-module.nix @@ -0,0 +1,55 @@ +# Home Manager module – runs leafwiki as a systemd --user service. +# Secrets are read from plain files at service start time. +self: +{ + config, + lib, + pkgs, + ... +}: +let + cfg = config.services.leafwiki; + # Fall back gracefully when the flake's system package is unavailable + # (e.g. when home-manager is used standalone with a different nixpkgs). + leafwikiPkg = self.packages.${pkgs.stdenv.hostPlatform.system}.leafwiki or pkgs.leafwiki; + common = import ./common.nix { + inherit lib; + defaultPkg = leafwikiPkg; + # str instead of path: supports systemd specifiers like %h + dataDirType = lib.types.str; + dataDirDefault = "%h/.local/share/leafwiki"; + }; +in +{ + options.services.leafwiki = { + enable = lib.mkEnableOption "LeafWiki (user service)"; + } + // common.options; + + config = lib.mkIf cfg.enable { + systemd.user.services.leafwiki = { + Unit = { + Description = "LeafWiki"; + After = [ "network.target" ]; + }; + + Service = { + ExecStart = + let + authArgs = + if cfg.disableAuth then + [ "--disable-auth" ] + else + [ + ''--jwt-secret=$(cat "${cfg.jwtSecretFile}")'' + ''--admin-password=$(cat "${cfg.adminPasswordFile}")'' + ]; + in + common.mkExecStart cfg authArgs; + Restart = "on-failure"; + }; + + Install.WantedBy = [ "default.target" ]; + }; + }; +} diff --git a/nix/nixos-module.nix b/nix/nixos-module.nix new file mode 100644 index 000000000..619ec541e --- /dev/null +++ b/nix/nixos-module.nix @@ -0,0 +1,86 @@ +# NixOS module – runs leafwiki as a hardened system-level systemd service. +# Secret files are loaded via systemd LoadCredential so they never appear +# in the Nix store or the unit file. +self: +{ + config, + lib, + pkgs, + ... +}: +let + cfg = config.services.leafwiki; + leafwikiPkg = self.packages.${pkgs.stdenv.hostPlatform.system}.leafwiki; + common = import ./common.nix { + inherit lib; + defaultPkg = leafwikiPkg; + dataDirType = lib.types.path; + dataDirDefault = "/var/lib/leafwiki"; + }; +in +{ + options.services.leafwiki = { + enable = lib.mkEnableOption "LeafWiki"; + + user = lib.mkOption { + type = lib.types.str; + default = "leafwiki"; + description = "User account under which leafwiki runs."; + }; + + group = lib.mkOption { + type = lib.types.str; + default = "leafwiki"; + description = "Group under which leafwiki runs."; + }; + } + // common.options; + + config = lib.mkIf cfg.enable { + users.users.${cfg.user} = { + isSystemUser = true; + group = cfg.group; + home = cfg.dataDir; + createHome = true; + }; + users.groups.${cfg.group} = { }; + + systemd.services.leafwiki = { + description = "LeafWiki"; + wantedBy = [ "multi-user.target" ]; + after = [ "network.target" ]; + + serviceConfig = { + User = cfg.user; + Group = cfg.group; + WorkingDirectory = cfg.dataDir; + Restart = "on-failure"; + + ExecStart = + let + authArgs = + if cfg.disableAuth then + [ "--disable-auth" ] + else + [ + # LoadCredential exposes secrets under $CREDENTIALS_DIRECTORY + ''--jwt-secret=$(cat "$CREDENTIALS_DIRECTORY/jwt-secret")'' + ''--admin-password=$(cat "$CREDENTIALS_DIRECTORY/admin-password")'' + ]; + in + common.mkExecStart cfg authArgs; + + LoadCredential = lib.optionals (!cfg.disableAuth) [ + "jwt-secret:${cfg.jwtSecretFile}" + "admin-password:${cfg.adminPasswordFile}" + ]; + + # Hardening + NoNewPrivileges = true; + PrivateTmp = true; + ProtectSystem = "strict"; + ReadWritePaths = [ cfg.dataDir ]; + }; + }; + }; +} diff --git a/nix/packages.nix b/nix/packages.nix new file mode 100644 index 000000000..391c96f3b --- /dev/null +++ b/nix/packages.nix @@ -0,0 +1,46 @@ +# Per-system package derivations, consumed by flake-utils.lib.eachDefaultSystem. +{ pkgs, version }: +rec { + ui = pkgs.buildNpmPackage { + pname = "leafwiki-ui"; + inherit version; + src = ../ui/leafwiki-ui; + + npmDepsHash = "sha256-gYL+VczQNEISjvNEtsfjFMFJ0tvfYgqeERlUcHdzAsY="; + + env.VITE_API_URL = "/"; + + installPhase = '' + runHook preInstall + mkdir -p $out + cp -r dist/. $out/ + runHook postInstall + ''; + }; + + leafwiki = pkgs.buildGoModule { + pname = "leafwiki"; + inherit version; + src = ../.; + + vendorHash = "sha256-/5K4BfYCFeNCJ/Sfd1eV7GO3LMx7pEe5yst7el+TfaY="; + + preBuild = '' + mkdir -p internal/http/dist + cp -r ${ui}/. internal/http/dist/ + ''; + + ldflags = [ + "-s" + "-w" + "-X github.com/perber/wiki/internal/http.EmbedFrontend=true" + "-X github.com/perber/wiki/internal/http.Environment=production" + ]; + + # Only build the main binary; e2e-proxy is a separate Go module + subPackages = [ "cmd/leafwiki" ]; + + # modernc.org/sqlite is pure Go – no C compiler needed + env.CGO_ENABLED = "0"; + }; +} diff --git a/nix/shell.nix b/nix/shell.nix new file mode 100644 index 000000000..2e107379c --- /dev/null +++ b/nix/shell.nix @@ -0,0 +1,18 @@ +{ pkgs }: +{ + devShells.default = pkgs.mkShell { + packages = with pkgs; [ + # Go toolchain + go + gopls + gotools + golangci-lint + # Node / frontend + nodejs + typescript-language-server + # Utilities + git + gnumake + ]; + }; +}