feat: add pure NixOS flake with bun2nix
Adds a complete Nix flake for NixOS integration: Package: - Pure reproducible builds using bun2nix (no network during build) - Bundles restic, rclone, shoutrrr, and other runtime dependencies - Supports x86_64-linux, aarch64-linux, x86_64-darwin, aarch64-darwin - Version read from package.json to avoid drift NixOS Module: - Systemd service with security hardening - FUSE support for remote mounts (NFS, SMB, WebDAV) - Configurable options: port, dataDir, user/group, timezone, etc. - protectHome option for backing up home directories - CAP_DAC_READ_SEARCH capability when protectHome=false - PORT env var properly passed to server Development: - devShell with bun, node, biome, typescript, and runtime tools - bun2nix CLI for regenerating bun.nix after dependency changes Also includes: - Overlay exposing zerobyte and shoutrrr packages - NixOS VM integration tests - Experimental darwin module (requires TCC permissions for home dirs) - Configurable MIGRATIONS_PATH and PORT for Nix store compatibility Note: package.json includes additional peer dependencies (@standard-community/*, @standard-schema/spec, openapi-types, quansync, react-is) required by bun2nix for pure builds - bun requires all transitive peer deps to be satisfied.
This commit is contained in:
parent
54068d5269
commit
5cb092f130
10 changed files with 3024 additions and 5 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -1,5 +1,7 @@
|
|||
.DS_Store
|
||||
.direnv/
|
||||
/node_modules/
|
||||
result
|
||||
|
||||
# React Router
|
||||
/.react-router/
|
||||
|
|
@ -13,3 +15,4 @@ CLAUDE.md
|
|||
mutagen.yml.lock
|
||||
notes.md
|
||||
smb-password.txt
|
||||
.envrc
|
||||
|
|
|
|||
61
README.md
61
README.md
|
|
@ -98,6 +98,67 @@ services:
|
|||
|
||||
If you need remote mount capabilities, keep the original configuration with `cap_add: SYS_ADMIN` and `devices: /dev/fuse:/dev/fuse`.
|
||||
|
||||
### Nix Flake
|
||||
|
||||
Zerobyte provides a Nix flake for NixOS users and Nix-based development environments.
|
||||
|
||||
**Development shell:**
|
||||
|
||||
```bash
|
||||
nix develop
|
||||
```
|
||||
|
||||
**NixOS module:**
|
||||
|
||||
```nix
|
||||
{
|
||||
inputs.zerobyte.url = "github:nicotsx/zerobyte";
|
||||
|
||||
outputs = { self, nixpkgs, zerobyte }: {
|
||||
nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
|
||||
modules = [
|
||||
zerobyte.nixosModules.default
|
||||
{
|
||||
services.zerobyte = {
|
||||
enable = true;
|
||||
port = 4096;
|
||||
openFirewall = true;
|
||||
# fuse.enable = true; # Enabled by default for remote mounts
|
||||
};
|
||||
}
|
||||
];
|
||||
};
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**Available module options (NixOS):**
|
||||
|
||||
| Option | Default | Description |
|
||||
|--------|---------|-------------|
|
||||
| `enable` | `false` | Enable Zerobyte service |
|
||||
| `port` | `4096` | HTTP port |
|
||||
| `dataDir` | `/var/lib/zerobyte` | Data directory |
|
||||
| `user` | `"zerobyte"` | User to run service as |
|
||||
| `group` | `"zerobyte"` | Group to run service as |
|
||||
| `createUser` | `true` | Create user/group automatically |
|
||||
| `serverIp` | `"0.0.0.0"` | Bind address |
|
||||
| `openFirewall` | `false` | Open firewall port (NixOS only) |
|
||||
| `fuse.enable` | `true` | Enable FUSE/remote mounts (NixOS only) |
|
||||
| `protectHome` | `true` | Block /home access (NixOS only, set false to backup home dirs) |
|
||||
| `extraReadWritePaths` | `[]` | Additional writable paths (e.g., `["/mnt/storage"]` for custom repos) |
|
||||
| `timezone` | `"UTC"` | Timezone for scheduling |
|
||||
| `resticHostname` | `"zerobyte"` | Hostname for restic |
|
||||
|
||||
**Updating dependencies (for contributors):**
|
||||
|
||||
After modifying `package.json` or `bun.lock`, regenerate the Nix dependency file:
|
||||
|
||||
```bash
|
||||
nix develop
|
||||
bun2nix -o bun.nix
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
See [examples/README.md](examples/README.md) for runnable, copy/paste-friendly examples.
|
||||
|
|
|
|||
|
|
@ -3,15 +3,19 @@ import "dotenv/config";
|
|||
|
||||
const envSchema = type({
|
||||
NODE_ENV: type.enumerated("development", "production", "test").default("production"),
|
||||
PORT: 'string.integer.parse = "4096"',
|
||||
SERVER_IP: 'string = "localhost"',
|
||||
SERVER_IDLE_TIMEOUT: 'string.integer.parse = "60"',
|
||||
RESTIC_HOSTNAME: "string = 'zerobyte'",
|
||||
MIGRATIONS_PATH: "string?",
|
||||
}).pipe((s) => ({
|
||||
__prod__: s.NODE_ENV === "production",
|
||||
environment: s.NODE_ENV,
|
||||
port: s.PORT,
|
||||
serverIp: s.SERVER_IP,
|
||||
serverIdleTimeout: s.SERVER_IDLE_TIMEOUT,
|
||||
resticHostname: s.RESTIC_HOSTNAME,
|
||||
migrationsPath: s.MIGRATIONS_PATH,
|
||||
}));
|
||||
|
||||
const parseConfig = (env: unknown) => {
|
||||
|
|
|
|||
|
|
@ -14,9 +14,17 @@ const sqlite = new Database(DATABASE_URL);
|
|||
export const db = drizzle({ client: sqlite, schema });
|
||||
|
||||
export const runDbMigrations = () => {
|
||||
let migrationsFolder = path.join("/app", "assets", "migrations");
|
||||
let migrationsFolder: string;
|
||||
|
||||
if (!config.__prod__) {
|
||||
// Migration path priority:
|
||||
// 1. MIGRATIONS_PATH env var (Nix, custom deployments)
|
||||
// 2. /app/assets/migrations (Docker production)
|
||||
// 3. /app/app/drizzle (Docker development)
|
||||
if (config.migrationsPath) {
|
||||
migrationsFolder = config.migrationsPath;
|
||||
} else if (config.__prod__) {
|
||||
migrationsFolder = path.join("/app", "assets", "migrations");
|
||||
} else {
|
||||
migrationsFolder = path.join("/app", "app", "drizzle");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ await validateRequiredMigrations(REQUIRED_MIGRATIONS);
|
|||
|
||||
startup();
|
||||
|
||||
logger.info(`Server is running at http://localhost:4096`);
|
||||
logger.info(`Server is running at http://localhost:${config.port}`);
|
||||
|
||||
export type AppType = typeof app;
|
||||
|
||||
|
|
@ -36,7 +36,7 @@ process.on("SIGINT", async () => {
|
|||
|
||||
export default await createHonoServer({
|
||||
app,
|
||||
port: 4096,
|
||||
port: config.port,
|
||||
customBunServer: {
|
||||
idleTimeout: config.serverIdleTimeout,
|
||||
error(err) {
|
||||
|
|
|
|||
9
bun.lock
9
bun.lock
|
|
@ -26,7 +26,11 @@
|
|||
"@react-router/node": "^7.10.0",
|
||||
"@react-router/serve": "^7.10.0",
|
||||
"@scalar/hono-api-reference": "^0.9.25",
|
||||
"@standard-community/standard-json": "^0.3.5",
|
||||
"@standard-community/standard-openapi": "^0.2.9",
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"@tanstack/react-query": "^5.90.11",
|
||||
"@types/json-schema": "^7.0.15",
|
||||
"arktype": "^2.1.28",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
|
@ -45,9 +49,12 @@
|
|||
"lucide-react": "^0.555.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"node-cron": "^4.2.1",
|
||||
"openapi-types": "^12.1.3",
|
||||
"quansync": "^1.0.0",
|
||||
"react": "^19.2.1",
|
||||
"react-dom": "^19.2.1",
|
||||
"react-hook-form": "^7.68.0",
|
||||
"react-is": "^19.2.3",
|
||||
"react-router": "^7.10.0",
|
||||
"react-router-hono-server": "^2.22.0",
|
||||
"recharts": "3.5.1",
|
||||
|
|
@ -952,7 +959,7 @@
|
|||
|
||||
"qs": ["qs@6.14.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w=="],
|
||||
|
||||
"quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="],
|
||||
"quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="],
|
||||
|
||||
"radix3": ["radix3@1.1.2", "", {}, "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA=="],
|
||||
|
||||
|
|
|
|||
170
flake.lock
Normal file
170
flake.lock
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
{
|
||||
"nodes": {
|
||||
"bun2nix": {
|
||||
"inputs": {
|
||||
"flake-parts": "flake-parts",
|
||||
"import-tree": "import-tree",
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
],
|
||||
"systems": "systems",
|
||||
"treefmt-nix": "treefmt-nix"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1765198123,
|
||||
"narHash": "sha256-pkahE6wwIszQ8e107qa95wyNr4Qj94hEdL9fA/IE288=",
|
||||
"owner": "nix-community",
|
||||
"repo": "bun2nix",
|
||||
"rev": "29d2c26c269b2bc7d8885c6a2fc90ec0fc86ec40",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-community",
|
||||
"repo": "bun2nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-parts": {
|
||||
"inputs": {
|
||||
"nixpkgs-lib": "nixpkgs-lib"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1763759067,
|
||||
"narHash": "sha256-LlLt2Jo/gMNYAwOgdRQBrsRoOz7BPRkzvNaI/fzXi2Q=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"rev": "2cccadc7357c0ba201788ae99c4dfa90728ef5e0",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems_2"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"import-tree": {
|
||||
"locked": {
|
||||
"lastModified": 1763762820,
|
||||
"narHash": "sha256-ZvYKbFib3AEwiNMLsejb/CWs/OL/srFQ8AogkebEPF0=",
|
||||
"owner": "vic",
|
||||
"repo": "import-tree",
|
||||
"rev": "3c23749d8013ec6daa1d7255057590e9ca726646",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "vic",
|
||||
"repo": "import-tree",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1766651565,
|
||||
"narHash": "sha256-QEhk0eXgyIqTpJ/ehZKg9IKS7EtlWxF3N7DXy42zPfU=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "3e2499d5539c16d0d173ba53552a4ff8547f4539",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs-lib": {
|
||||
"locked": {
|
||||
"lastModified": 1761765539,
|
||||
"narHash": "sha256-b0yj6kfvO8ApcSE+QmA6mUfu8IYG6/uU28OFn4PaC8M=",
|
||||
"owner": "nix-community",
|
||||
"repo": "nixpkgs.lib",
|
||||
"rev": "719359f4562934ae99f5443f20aa06c2ffff91fc",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-community",
|
||||
"repo": "nixpkgs.lib",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"bun2nix": "bun2nix",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"systems_2": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"treefmt-nix": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"bun2nix",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1762938485,
|
||||
"narHash": "sha256-AlEObg0syDl+Spi4LsZIBrjw+snSVU4T8MOeuZJUJjM=",
|
||||
"owner": "numtide",
|
||||
"repo": "treefmt-nix",
|
||||
"rev": "5b4ee75aeefd1e2d5a1cc43cf6ba65eba75e83e4",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "treefmt-nix",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
535
flake.nix
Normal file
535
flake.nix
Normal file
|
|
@ -0,0 +1,535 @@
|
|||
{
|
||||
description = "Zerobyte - Self-hosted backup automation and management";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
bun2nix.url = "github:nix-community/bun2nix";
|
||||
bun2nix.inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, flake-utils, bun2nix }:
|
||||
let
|
||||
# Systems for packages and devShells
|
||||
allSystems = [
|
||||
"x86_64-linux"
|
||||
"aarch64-linux"
|
||||
"x86_64-darwin"
|
||||
"aarch64-darwin"
|
||||
];
|
||||
|
||||
# Linux-only systems for NixOS module and tests
|
||||
linuxSystems = [
|
||||
"x86_64-linux"
|
||||
"aarch64-linux"
|
||||
];
|
||||
|
||||
# shoutrrr version and hashes (SRI format)
|
||||
shoutrrrVersion = "0.13.1";
|
||||
shoutrrrHashes = {
|
||||
x86_64-linux = "sha256-TZrDstm5InQOalYf9da5rhnsJm7qTnmG18jLJtvsD8A=";
|
||||
aarch64-linux = "sha256-IHgZhsykJbmW/uYUsd6o7Wh3EIsUldduIKFZ0GkjrwI=";
|
||||
x86_64-darwin = "sha256-pzmAGRzbWYVHoZqvx6tsuxpuKcfIXMVNPXbKHeeAyxs=";
|
||||
aarch64-darwin = "sha256-DKQRzdDd1xccNqetscEKKzgyT1IatOlwPBwa4E8fbDc=";
|
||||
};
|
||||
|
||||
# Map Nix system to shoutrrr release naming
|
||||
shoutrrrArch = {
|
||||
x86_64-linux = "linux_amd64";
|
||||
aarch64-linux = "linux_arm64v8";
|
||||
x86_64-darwin = "macOS_amd64";
|
||||
aarch64-darwin = "macOS_arm64v8";
|
||||
};
|
||||
|
||||
# Check if system is Linux
|
||||
isLinux = system: builtins.elem system linuxSystems;
|
||||
|
||||
in
|
||||
flake-utils.lib.eachSystem allSystems (system:
|
||||
let
|
||||
pkgs = import nixpkgs {
|
||||
inherit system;
|
||||
config.allowUnfree = true;
|
||||
overlays = [ bun2nix.overlays.default ];
|
||||
};
|
||||
|
||||
shoutrrr = pkgs.stdenv.mkDerivation {
|
||||
pname = "shoutrrr";
|
||||
version = shoutrrrVersion;
|
||||
|
||||
src = pkgs.fetchurl {
|
||||
url = "https://github.com/nicholas-fedor/shoutrrr/releases/download/v${shoutrrrVersion}/shoutrrr_${shoutrrrArch.${system}}_${shoutrrrVersion}.tar.gz";
|
||||
hash = shoutrrrHashes.${system};
|
||||
};
|
||||
|
||||
sourceRoot = ".";
|
||||
|
||||
nativeBuildInputs = pkgs.lib.optionals (isLinux system) [ pkgs.autoPatchelfHook ];
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
install -Dm755 shoutrrr $out/bin/shoutrrr
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
meta = with pkgs.lib; {
|
||||
description = "Notification library and CLI for various services";
|
||||
homepage = "https://github.com/nicholas-fedor/shoutrrr";
|
||||
license = licenses.mit;
|
||||
platforms = platforms.unix;
|
||||
};
|
||||
};
|
||||
|
||||
# Read version from package.json to avoid drift
|
||||
packageJson = builtins.fromJSON (builtins.readFile ./package.json);
|
||||
|
||||
zerobyte = pkgs.stdenv.mkDerivation {
|
||||
pname = "zerobyte";
|
||||
version = packageJson.version or "0.0.0";
|
||||
|
||||
src = pkgs.lib.cleanSource ./.;
|
||||
|
||||
nativeBuildInputs = [
|
||||
pkgs.bun2nix.hook
|
||||
pkgs.makeWrapper
|
||||
];
|
||||
|
||||
# Fetch bun dependencies using bun2nix
|
||||
bunDeps = pkgs.bun2nix.fetchBunDeps {
|
||||
bunNix = ./bun.nix;
|
||||
};
|
||||
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
|
||||
export HOME=$(mktemp -d)
|
||||
|
||||
# Build the application (react-router build)
|
||||
bun run build
|
||||
|
||||
runHook postBuild
|
||||
'';
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
|
||||
# Create output directories matching the expected structure
|
||||
mkdir -p $out/lib/zerobyte/dist
|
||||
mkdir -p $out/lib/zerobyte/drizzle
|
||||
mkdir -p $out/bin
|
||||
|
||||
# Copy built assets (server expects dist/server and dist/client)
|
||||
cp -r dist/server $out/lib/zerobyte/dist/server
|
||||
cp -r dist/client $out/lib/zerobyte/dist/client
|
||||
cp -r app/drizzle/* $out/lib/zerobyte/drizzle/
|
||||
cp package.json $out/lib/zerobyte/
|
||||
|
||||
# Copy node_modules for runtime dependencies
|
||||
cp -r node_modules $out/lib/zerobyte/
|
||||
|
||||
# Create wrapper script with runtime dependencies
|
||||
# --chdir ensures server finds dist/client relative to package dir
|
||||
makeWrapper ${pkgs.bun}/bin/bun $out/bin/zerobyte \
|
||||
--chdir $out/lib/zerobyte \
|
||||
--add-flags "dist/server/index.js" \
|
||||
--prefix PATH : ${pkgs.lib.makeBinPath ([
|
||||
pkgs.restic
|
||||
pkgs.rclone
|
||||
shoutrrr
|
||||
pkgs.openssh
|
||||
] ++ pkgs.lib.optionals (isLinux system) [
|
||||
pkgs.fuse3
|
||||
pkgs.davfs2
|
||||
])} \
|
||||
--set NODE_ENV "production"
|
||||
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
meta = with pkgs.lib; {
|
||||
description = "Self-hosted backup automation and management";
|
||||
homepage = "https://github.com/nicotsx/zerobyte";
|
||||
license = licenses.mit;
|
||||
platforms = platforms.unix;
|
||||
mainProgram = "zerobyte";
|
||||
};
|
||||
};
|
||||
|
||||
in
|
||||
{
|
||||
packages = {
|
||||
inherit zerobyte shoutrrr;
|
||||
default = zerobyte;
|
||||
};
|
||||
|
||||
devShells.default = pkgs.mkShell {
|
||||
buildInputs = [
|
||||
# JavaScript runtime and package manager
|
||||
pkgs.bun
|
||||
pkgs.nodejs
|
||||
|
||||
# Development tools
|
||||
pkgs.biome
|
||||
pkgs.typescript
|
||||
|
||||
# bun2nix CLI for regenerating bun.nix
|
||||
bun2nix.packages.${system}.bun2nix
|
||||
|
||||
# External tools (for local testing)
|
||||
pkgs.restic
|
||||
pkgs.rclone
|
||||
shoutrrr
|
||||
|
||||
# Database tools
|
||||
pkgs.sqlite
|
||||
|
||||
# Utilities
|
||||
pkgs.git
|
||||
pkgs.curl
|
||||
pkgs.jq
|
||||
];
|
||||
|
||||
shellHook = ''
|
||||
echo "Zerobyte development environment"
|
||||
echo " bun: $(bun --version)"
|
||||
echo " node: $(node --version)"
|
||||
echo " restic: $(restic version | head -1)"
|
||||
echo " rclone: $(rclone version | head -1)"
|
||||
echo ""
|
||||
echo "To update bun.nix after changing dependencies:"
|
||||
echo " bun2nix -o bun.nix"
|
||||
'';
|
||||
};
|
||||
}
|
||||
) // {
|
||||
# Overlay
|
||||
overlays.default = final: prev: {
|
||||
zerobyte = self.packages.${final.system}.zerobyte;
|
||||
shoutrrr = self.packages.${final.system}.shoutrrr;
|
||||
};
|
||||
|
||||
# NixOS Module
|
||||
nixosModules.default = { config, lib, pkgs, ... }:
|
||||
let
|
||||
cfg = config.services.zerobyte;
|
||||
in
|
||||
{
|
||||
options.services.zerobyte = {
|
||||
enable = lib.mkEnableOption "Zerobyte backup management service";
|
||||
|
||||
package = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
default = self.packages.${pkgs.system}.zerobyte;
|
||||
defaultText = lib.literalExpression "pkgs.zerobyte";
|
||||
description = "The Zerobyte package to use.";
|
||||
};
|
||||
|
||||
user = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "zerobyte";
|
||||
description = "User account under which Zerobyte runs.";
|
||||
};
|
||||
|
||||
group = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "zerobyte";
|
||||
description = "Group under which Zerobyte runs.";
|
||||
};
|
||||
|
||||
createUser = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = ''
|
||||
Whether to create the user and group automatically.
|
||||
Set to false if using an existing user account.
|
||||
'';
|
||||
};
|
||||
|
||||
dataDir = lib.mkOption {
|
||||
type = lib.types.path;
|
||||
default = "/var/lib/zerobyte";
|
||||
description = "Directory to store Zerobyte data.";
|
||||
};
|
||||
|
||||
port = lib.mkOption {
|
||||
type = lib.types.port;
|
||||
default = 4096;
|
||||
description = "Port on which Zerobyte listens.";
|
||||
};
|
||||
|
||||
openFirewall = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = "Whether to open the firewall for Zerobyte.";
|
||||
};
|
||||
|
||||
serverIp = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "0.0.0.0";
|
||||
description = "IP address to bind the server to.";
|
||||
};
|
||||
|
||||
timezone = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "UTC";
|
||||
description = "Timezone for scheduling backups.";
|
||||
};
|
||||
|
||||
resticHostname = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "zerobyte";
|
||||
description = "Hostname used for restic operations.";
|
||||
};
|
||||
|
||||
environment = lib.mkOption {
|
||||
type = lib.types.attrsOf lib.types.str;
|
||||
default = {};
|
||||
description = "Additional environment variables for Zerobyte.";
|
||||
};
|
||||
|
||||
fuse = {
|
||||
enable = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = ''
|
||||
Enable FUSE mounting capabilities.
|
||||
Requires CAP_SYS_ADMIN and access to /dev/fuse.
|
||||
Enables NFS, SMB, and WebDAV volume mounts.
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
protectHome = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = ''
|
||||
Enable ProtectHome systemd security hardening.
|
||||
When true, /home, /root, and /run/user are inaccessible.
|
||||
Set to false if you need to backup home directories.
|
||||
'';
|
||||
};
|
||||
|
||||
extraReadWritePaths = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
default = [];
|
||||
example = [ "/mnt/storage" "/backup" ];
|
||||
description = ''
|
||||
Additional paths the service can write to.
|
||||
Use this for custom repository locations outside of dataDir.
|
||||
Required because ProtectSystem=strict makes the filesystem read-only.
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
users.users.${cfg.user} = lib.mkIf cfg.createUser {
|
||||
isSystemUser = true;
|
||||
group = cfg.group;
|
||||
home = cfg.dataDir;
|
||||
createHome = true;
|
||||
description = "Zerobyte service user";
|
||||
};
|
||||
|
||||
users.groups.${cfg.group} = lib.mkIf cfg.createUser {};
|
||||
|
||||
systemd.services.zerobyte = {
|
||||
description = "Zerobyte backup management service";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "network.target" ];
|
||||
|
||||
environment = {
|
||||
NODE_ENV = "production";
|
||||
PORT = toString cfg.port;
|
||||
SERVER_IP = cfg.serverIp;
|
||||
RESTIC_HOSTNAME = cfg.resticHostname;
|
||||
DATABASE_URL = "${cfg.dataDir}/data/zerobyte.db";
|
||||
MIGRATIONS_PATH = "${cfg.package}/lib/zerobyte/drizzle";
|
||||
TZ = cfg.timezone;
|
||||
} // cfg.environment;
|
||||
|
||||
serviceConfig = {
|
||||
Type = "simple";
|
||||
User = cfg.user;
|
||||
Group = cfg.group;
|
||||
ExecStart = "${cfg.package}/bin/zerobyte";
|
||||
Restart = "on-failure";
|
||||
RestartSec = 5;
|
||||
|
||||
# State directory
|
||||
StateDirectory = "zerobyte";
|
||||
StateDirectoryMode = "0750";
|
||||
WorkingDirectory = cfg.dataDir;
|
||||
|
||||
# Capabilities
|
||||
# - CAP_SYS_ADMIN: Required for FUSE mounts
|
||||
# - CAP_DAC_READ_SEARCH: Required to read restricted directories (e.g., 700 home dirs)
|
||||
# - CAP_DAC_OVERRIDE: Required to write to directories not owned by service user
|
||||
AmbientCapabilities =
|
||||
lib.optional cfg.fuse.enable "CAP_SYS_ADMIN"
|
||||
++ lib.optional (!cfg.protectHome) "CAP_DAC_READ_SEARCH"
|
||||
++ lib.optional (cfg.extraReadWritePaths != []) "CAP_DAC_OVERRIDE";
|
||||
CapabilityBoundingSet =
|
||||
lib.optional cfg.fuse.enable "CAP_SYS_ADMIN"
|
||||
++ lib.optional (!cfg.protectHome) "CAP_DAC_READ_SEARCH"
|
||||
++ lib.optional (cfg.extraReadWritePaths != []) "CAP_DAC_OVERRIDE";
|
||||
DeviceAllow = lib.mkIf cfg.fuse.enable [ "/dev/fuse rw" ];
|
||||
|
||||
# Security hardening
|
||||
PrivateTmp = true;
|
||||
ProtectSystem = "strict";
|
||||
ProtectHome = cfg.protectHome;
|
||||
# Disable when capabilities are needed (FUSE, home access, or extra write paths)
|
||||
NoNewPrivileges = !cfg.fuse.enable && cfg.protectHome && cfg.extraReadWritePaths == [];
|
||||
ProtectKernelTunables = true;
|
||||
ProtectKernelModules = true;
|
||||
ProtectControlGroups = true;
|
||||
RestrictAddressFamilies = [ "AF_UNIX" "AF_INET" "AF_INET6" ];
|
||||
RestrictNamespaces = !cfg.fuse.enable;
|
||||
LockPersonality = true;
|
||||
MemoryDenyWriteExecute = false; # Required for bun/V8
|
||||
RestrictRealtime = true;
|
||||
RestrictSUIDSGID = true;
|
||||
RemoveIPC = true;
|
||||
PrivateMounts = !cfg.fuse.enable;
|
||||
|
||||
# Allow write access to data directory
|
||||
ReadWritePaths = [ cfg.dataDir ] ++ cfg.extraReadWritePaths;
|
||||
};
|
||||
};
|
||||
|
||||
networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall [ cfg.port ];
|
||||
};
|
||||
};
|
||||
|
||||
# nix-darwin Module (macOS) - EXPERIMENTAL/FUTURE USE
|
||||
# macOS lacks Linux capabilities (CAP_DAC_READ_SEARCH, etc.) and uses TCC
|
||||
# (Transparency, Consent, and Control) which blocks access to ~/Desktop,
|
||||
# ~/Documents, etc. even for root. Full support requires significant code
|
||||
# changes to handle TCC permission grants via System Preferences.
|
||||
darwinModules.default = { config, lib, pkgs, ... }:
|
||||
let
|
||||
cfg = config.services.zerobyte;
|
||||
in
|
||||
{
|
||||
options.services.zerobyte = {
|
||||
enable = lib.mkEnableOption "Zerobyte backup management service";
|
||||
|
||||
package = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
default = self.packages.${pkgs.system}.zerobyte;
|
||||
defaultText = lib.literalExpression "pkgs.zerobyte";
|
||||
description = "The Zerobyte package to use.";
|
||||
};
|
||||
|
||||
dataDir = lib.mkOption {
|
||||
type = lib.types.path;
|
||||
default = "/var/lib/zerobyte";
|
||||
description = "Directory to store Zerobyte data.";
|
||||
};
|
||||
|
||||
port = lib.mkOption {
|
||||
type = lib.types.port;
|
||||
default = 4096;
|
||||
description = "Port on which Zerobyte listens.";
|
||||
};
|
||||
|
||||
serverIp = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "0.0.0.0";
|
||||
description = "IP address to bind the server to.";
|
||||
};
|
||||
|
||||
timezone = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "UTC";
|
||||
description = "Timezone for scheduling backups.";
|
||||
};
|
||||
|
||||
resticHostname = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "zerobyte";
|
||||
description = "Hostname used for restic operations.";
|
||||
};
|
||||
|
||||
environment = lib.mkOption {
|
||||
type = lib.types.attrsOf lib.types.str;
|
||||
default = {};
|
||||
description = "Additional environment variables for Zerobyte.";
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
# Create data directory
|
||||
system.activationScripts.zerobyte.text = ''
|
||||
mkdir -p ${cfg.dataDir}/data
|
||||
chmod 750 ${cfg.dataDir}
|
||||
'';
|
||||
|
||||
launchd.daemons.zerobyte = {
|
||||
serviceConfig = {
|
||||
Label = "org.zerobyte.daemon";
|
||||
ProgramArguments = [ "${cfg.package}/bin/zerobyte" ];
|
||||
RunAtLoad = true;
|
||||
KeepAlive = true;
|
||||
WorkingDirectory = "${cfg.dataDir}";
|
||||
|
||||
EnvironmentVariables = {
|
||||
NODE_ENV = "production";
|
||||
PORT = toString cfg.port;
|
||||
SERVER_IP = cfg.serverIp;
|
||||
RESTIC_HOSTNAME = cfg.resticHostname;
|
||||
DATABASE_URL = "${cfg.dataDir}/data/zerobyte.db";
|
||||
MIGRATIONS_PATH = "${cfg.package}/lib/zerobyte/drizzle";
|
||||
TZ = cfg.timezone;
|
||||
} // cfg.environment;
|
||||
|
||||
StandardOutPath = "/var/log/zerobyte.log";
|
||||
StandardErrorPath = "/var/log/zerobyte.error.log";
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
# NixOS VM Tests (Linux only)
|
||||
checks = builtins.listToAttrs (map (system:
|
||||
let
|
||||
pkgs = import nixpkgs { inherit system; };
|
||||
in
|
||||
{
|
||||
name = system;
|
||||
value = {
|
||||
integration = pkgs.testers.nixosTest {
|
||||
name = "zerobyte-integration";
|
||||
|
||||
nodes.machine = { config, pkgs, ... }: {
|
||||
imports = [ self.nixosModules.default ];
|
||||
|
||||
services.zerobyte = {
|
||||
enable = true;
|
||||
openFirewall = true;
|
||||
};
|
||||
|
||||
# Ensure the test VM has enough resources
|
||||
virtualisation = {
|
||||
memorySize = 1024;
|
||||
diskSize = 2048;
|
||||
};
|
||||
};
|
||||
|
||||
testScript = ''
|
||||
machine.start()
|
||||
machine.wait_for_unit("zerobyte.service")
|
||||
machine.wait_for_open_port(4096)
|
||||
|
||||
# Test healthcheck endpoint (returns {"status":"ok"})
|
||||
result = machine.succeed("curl -s http://localhost:4096/healthcheck")
|
||||
assert '"status":"ok"' in result or '"ok"' in result, f"Healthcheck failed: {result}"
|
||||
|
||||
machine.log("Zerobyte integration test passed!")
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
) linuxSystems);
|
||||
};
|
||||
}
|
||||
|
|
@ -43,7 +43,11 @@
|
|||
"@react-router/node": "^7.10.0",
|
||||
"@react-router/serve": "^7.10.0",
|
||||
"@scalar/hono-api-reference": "^0.9.25",
|
||||
"@standard-community/standard-json": "^0.3.5",
|
||||
"@standard-community/standard-openapi": "^0.2.9",
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"@tanstack/react-query": "^5.90.11",
|
||||
"@types/json-schema": "^7.0.15",
|
||||
"arktype": "^2.1.28",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
|
@ -62,9 +66,12 @@
|
|||
"lucide-react": "^0.555.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"node-cron": "^4.2.1",
|
||||
"openapi-types": "^12.1.3",
|
||||
"quansync": "^1.0.0",
|
||||
"react": "^19.2.1",
|
||||
"react-dom": "^19.2.1",
|
||||
"react-hook-form": "^7.68.0",
|
||||
"react-is": "^19.2.3",
|
||||
"react-router": "^7.10.0",
|
||||
"react-router-hono-server": "^2.22.0",
|
||||
"recharts": "3.5.1",
|
||||
|
|
|
|||
Loading…
Reference in a new issue