zfs-unlock 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ source .venv/bin/activate
@@ -0,0 +1,5 @@
1
+ ---
2
+ template: |
3
+ ## What’s Changed
4
+
5
+ $CHANGES
@@ -0,0 +1,35 @@
1
+ {
2
+ "$schema": "https://docs.renovatebot.com/renovate-schema.json",
3
+ "rebaseWhen": "behind-base-branch",
4
+ "dependencyDashboard": true,
5
+ "labels": [
6
+ "dependencies",
7
+ "no-stale"
8
+ ],
9
+ "commitMessagePrefix": "⬆️",
10
+ "commitMessageTopic": "{{depName}}",
11
+ "prBodyDefinitions": {
12
+ "Release": "yes"
13
+ },
14
+ "packageRules": [
15
+ {
16
+ "matchManagers": [
17
+ "github-actions"
18
+ ],
19
+ "addLabels": [
20
+ "github_actions"
21
+ ],
22
+ "rangeStrategy": "pin"
23
+ },
24
+ {
25
+ "matchManagers": [
26
+ "github-actions"
27
+ ],
28
+ "matchUpdateTypes": [
29
+ "minor",
30
+ "patch"
31
+ ],
32
+ "automerge": true
33
+ }
34
+ ]
35
+ }
@@ -0,0 +1,31 @@
1
+ ---
2
+ name: Test
3
+
4
+ on:
5
+ push:
6
+ branches: [main]
7
+ pull_request:
8
+ branches: [main]
9
+
10
+ jobs:
11
+ test:
12
+ runs-on: ubuntu-latest
13
+ strategy:
14
+ matrix:
15
+ python-version: ["3.11", "3.12", "3.13", "3.14"]
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+ - name: Install uv
19
+ uses: astral-sh/setup-uv@v5
20
+ - name: Set up Python ${{ matrix.python-version }}
21
+ run: uv python install ${{ matrix.python-version }}
22
+ - name: Install dependencies
23
+ run: uv sync --dev
24
+ - name: Lint
25
+ run: uv run ruff check .
26
+ - name: Format check
27
+ run: uv run ruff format --check .
28
+ - name: Type check
29
+ run: uv run mypy zfs_unlock.py
30
+ - name: Test
31
+ run: uv run pytest --cov=zfs_unlock --cov-report=term-missing
@@ -0,0 +1,15 @@
1
+ ---
2
+ name: Release Drafter
3
+
4
+ on:
5
+ push:
6
+ branches:
7
+ - main
8
+
9
+ jobs:
10
+ update_release_draft:
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - uses: release-drafter/release-drafter@v6
14
+ env:
15
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -0,0 +1,23 @@
1
+ ---
2
+ name: Upload Python Package
3
+
4
+ on:
5
+ release:
6
+ types: [published]
7
+
8
+ jobs:
9
+ deploy:
10
+ runs-on: ubuntu-latest
11
+ environment:
12
+ name: pypi
13
+ url: https://pypi.org/p/${{ github.repository }}
14
+ permissions:
15
+ id-token: write
16
+ steps:
17
+ - uses: actions/checkout@v6
18
+ - name: Install uv
19
+ uses: astral-sh/setup-uv@v7
20
+ - name: Build
21
+ run: uv build
22
+ - name: Publish package distributions to PyPI
23
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,16 @@
1
+ ---
2
+ name: TOC Generator
3
+
4
+ on:
5
+ push:
6
+ branches:
7
+ - main
8
+
9
+ jobs:
10
+ generateTOC:
11
+ name: TOC Generator
12
+ runs-on: ubuntu-latest
13
+ steps:
14
+ - uses: technote-space/toc-generator@v4
15
+ with:
16
+ TOC_TITLE: ""
@@ -0,0 +1,48 @@
1
+ ---
2
+ name: Update README.md
3
+
4
+ on:
5
+ push:
6
+ branches:
7
+ - main
8
+ pull_request:
9
+
10
+ jobs:
11
+ update_readme:
12
+ runs-on: ubuntu-latest
13
+ steps:
14
+ - name: Check out repository
15
+ uses: actions/checkout@v6
16
+ with:
17
+ persist-credentials: false
18
+ fetch-depth: 0
19
+
20
+ - name: Set up Python
21
+ uses: actions/setup-python@v6
22
+
23
+ - name: Install uv
24
+ uses: astral-sh/setup-uv@v7
25
+
26
+ - name: Run markdown-code-runner
27
+ run: uvx --with . markdown-code-runner README.md
28
+
29
+ - name: Commit updated README.md
30
+ id: commit
31
+ run: |
32
+ git add README.md
33
+ git config --local user.email "github-actions[bot]@users.noreply.github.com"
34
+ git config --local user.name "github-actions[bot]"
35
+ if git diff --quiet && git diff --staged --quiet; then
36
+ echo "No changes in README.md, skipping commit."
37
+ echo "commit_status=skipped" >> $GITHUB_ENV
38
+ else
39
+ git commit -m "Update README.md"
40
+ echo "commit_status=committed" >> $GITHUB_ENV
41
+ fi
42
+
43
+ - name: Push changes
44
+ if: env.commit_status == 'committed'
45
+ uses: ad-m/github-push-action@master
46
+ with:
47
+ github_token: ${{ secrets.GITHUB_TOKEN }}
48
+ branch: ${{ github.head_ref }}
@@ -0,0 +1,166 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ # Usually these files are written by a python script from a template
31
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
32
+ *.manifest
33
+ *.spec
34
+
35
+ # Installer logs
36
+ pip-log.txt
37
+ pip-delete-this-directory.txt
38
+
39
+ # Unit test / coverage reports
40
+ htmlcov/
41
+ .tox/
42
+ .nox/
43
+ .coverage
44
+ .coverage.*
45
+ .cache
46
+ nosetests.xml
47
+ coverage.xml
48
+ *.cover
49
+ *.py,cover
50
+ .hypothesis/
51
+ .pytest_cache/
52
+ cover/
53
+
54
+ # Translations
55
+ *.mo
56
+ *.pot
57
+
58
+ # Django stuff:
59
+ *.log
60
+ local_settings.py
61
+ db.sqlite3
62
+ db.sqlite3-journal
63
+
64
+ # Flask stuff:
65
+ instance/
66
+ .webassets-cache
67
+
68
+ # Scrapy stuff:
69
+ .scrapy
70
+
71
+ # Sphinx documentation
72
+ docs/_build/
73
+
74
+ # PyBuilder
75
+ .pybuilder/
76
+ target/
77
+
78
+ # Jupyter Notebook
79
+ .ipynb_checkpoints
80
+
81
+ # IPython
82
+ profile_default/
83
+ ipython_config.py
84
+
85
+ # pyenv
86
+ # For a library or package, you might want to ignore these files since the code is
87
+ # intended to run in multiple environments; otherwise, check them in:
88
+ # .python-version
89
+
90
+ # pipenv
91
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
93
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
94
+ # install all needed dependencies.
95
+ #Pipfile.lock
96
+
97
+ # poetry
98
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
99
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
100
+ # commonly ignored for libraries.
101
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
102
+ #poetry.lock
103
+
104
+ # pdm
105
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
106
+ #pdm.lock
107
+ # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
108
+ # in version control.
109
+ # https://pdm.fming.dev/#use-with-ide
110
+ .pdm.toml
111
+
112
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
113
+ __pypackages__/
114
+
115
+ # Celery stuff
116
+ celerybeat-schedule
117
+ celerybeat.pid
118
+
119
+ # SageMath parsed files
120
+ *.sage.py
121
+
122
+ # Environments
123
+ .env
124
+ .venv
125
+ env/
126
+ venv/
127
+ ENV/
128
+ env.bak/
129
+ venv.bak/
130
+
131
+ # Spyder project settings
132
+ .spyderproject
133
+ .spyproject
134
+
135
+ # Rope project settings
136
+ .ropeproject
137
+
138
+ # mkdocs documentation
139
+ /site
140
+
141
+ # mypy
142
+ .mypy_cache/
143
+ .dmypy.json
144
+ dmypy.json
145
+
146
+ # ruff
147
+ .ruff_cache/
148
+
149
+ # Pyre type checker
150
+ .pyre/
151
+
152
+ # hatch-vcs generated version file
153
+ _version.py
154
+
155
+ # pytype static type analyzer
156
+ .pytype/
157
+
158
+ # Cython debug symbols
159
+ cython_debug/
160
+
161
+ # PyCharm
162
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
163
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
164
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
165
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
166
+ #.idea/
@@ -0,0 +1,41 @@
1
+ ---
2
+ ci:
3
+ skip: [mypy, pytest]
4
+
5
+ repos:
6
+ - repo: https://github.com/pre-commit/pre-commit-hooks
7
+ rev: v5.0.0
8
+ hooks:
9
+ - id: trailing-whitespace
10
+ - id: end-of-file-fixer
11
+ - id: check-yaml
12
+ - id: check-added-large-files
13
+ - repo: https://github.com/codespell-project/codespell
14
+ rev: v2.4.1
15
+ hooks:
16
+ - id: codespell
17
+ - repo: https://github.com/adrienverge/yamllint
18
+ rev: v1.37.1
19
+ hooks:
20
+ - id: yamllint
21
+ - repo: https://github.com/astral-sh/ruff-pre-commit
22
+ rev: v0.8.4
23
+ hooks:
24
+ - id: ruff
25
+ args: [--fix]
26
+ - id: ruff-format
27
+ - repo: local
28
+ hooks:
29
+ - id: mypy
30
+ name: mypy
31
+ entry: uv run mypy
32
+ language: system
33
+ types: [python]
34
+ pass_filenames: false
35
+ args: [zfs_unlock.py]
36
+ - id: pytest
37
+ name: pytest
38
+ entry: uv run pytest
39
+ language: system
40
+ types: [python]
41
+ pass_filenames: false
@@ -0,0 +1,7 @@
1
+ ---
2
+ extends: default
3
+ rules:
4
+ truthy:
5
+ allowed-values: ["true", "false", "on"]
6
+ line-length:
7
+ max: 120
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Bas Nijholt
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,269 @@
1
+ Metadata-Version: 2.4
2
+ Name: zfs-unlock
3
+ Version: 0.1.0
4
+ Summary: Unlock encrypted OpenZFS datasets over a restricted SSH receiver
5
+ Project-URL: Homepage, https://github.com/basnijholt/zfs-unlock
6
+ Project-URL: Repository, https://github.com/basnijholt/zfs-unlock
7
+ Project-URL: Issues, https://github.com/basnijholt/zfs-unlock/issues
8
+ Author-email: Bas Nijholt <bas@nijho.lt>
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: encryption,nas,nixos,openzfs,unlock,zfs
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: System Administrators
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Programming Language :: Python :: 3.14
22
+ Classifier: Topic :: System :: Systems Administration
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: >=3.11
25
+ Requires-Dist: pydantic>=2.0
26
+ Requires-Dist: pyyaml>=6.0
27
+ Requires-Dist: rich>=13.0
28
+ Requires-Dist: typer>=0.9
29
+ Description-Content-Type: text/markdown
30
+
31
+ # ZFS Unlock
32
+
33
+ [![PyPI](https://img.shields.io/pypi/v/zfs-unlock)](https://pypi.org/project/zfs-unlock/)
34
+ [![Python](https://img.shields.io/pypi/pyversions/zfs-unlock)](https://pypi.org/project/zfs-unlock/)
35
+ [![Tests](https://github.com/basnijholt/zfs-unlock/actions/workflows/pytest.yml/badge.svg)](https://github.com/basnijholt/zfs-unlock/actions/workflows/pytest.yml)
36
+ [![License](https://img.shields.io/github/license/basnijholt/zfs-unlock)](LICENSE)
37
+
38
+ Unlock encrypted OpenZFS datasets over a restricted SSH receiver.
39
+
40
+ ## Why?
41
+
42
+ This is the NixOS/OpenZFS counterpart to
43
+ [`truenas-unlock`](https://github.com/basnijholt/truenas-unlock).
44
+
45
+ ZFS native encryption is useful, but:
46
+
47
+ 1. **Storing keys on the NAS defeats the purpose**—if it's stolen, the thief has both the encrypted data and the keys
48
+ 2. **Manual unlocking is tedious**—after every reboot, you need to manually decrypt each dataset
49
+
50
+ This tool solves both problems with the same **"poor-man's second-factor"** setup as `truenas-unlock`:
51
+
52
+ 1. Run `zfs-unlock` on a **separate device** (Raspberry Pi, home server, etc.)
53
+ 2. Store encryption passphrases **only on that device**
54
+ 3. Datasets auto-unlock when both devices are on the network
55
+ 4. If the NAS is stolen, data remains encrypted and inaccessible
56
+
57
+ Unlike a plain root SSH key, the NAS-side path is intentionally narrow:
58
+
59
+ - a dedicated `zfs-unlock` SSH user
60
+ - an SSH key restricted with `restrict`, `from=...`, and `command=...`
61
+ - sudo permission only for a root-owned receiver wrapper
62
+ - a NAS-side dataset allowlist
63
+ - a receiver parser that only accepts `status`, `unlock`, and `lock`
64
+
65
+ Think of it as a hardware security key for your storage—hidden somewhere in your house, it automatically unlocks your datasets whenever your NAS boots. No manual intervention required.
66
+
67
+ ## Table of Contents
68
+
69
+ <!-- START doctoc generated TOC please keep comment here to allow auto update -->
70
+ <!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
71
+
72
+ - [Install](#install)
73
+ - [Setup](#setup)
74
+ - [Usage](#usage)
75
+ - [CLI](#cli)
76
+ - [Running as a Service](#running-as-a-service)
77
+ - [Development](#development)
78
+ - [Credits](#credits)
79
+ - [License](#license)
80
+
81
+ <!-- END doctoc generated TOC please keep comment here to allow auto update -->
82
+
83
+ ## Install
84
+
85
+ ```bash
86
+ # With uv (recommended)
87
+ uv tool install zfs-unlock
88
+
89
+ # With pip
90
+ pip install zfs-unlock
91
+ ```
92
+
93
+ ## Setup
94
+
95
+ Create `~/.config/zfs-unlock/config.yaml` on the off-box unlock device:
96
+
97
+ ```yaml
98
+ host: nas.local
99
+ user: zfs-unlock
100
+ identity_file: ~/.ssh/zfs-unlock-nas
101
+
102
+ # secrets: auto # auto (default) | files | inline
103
+
104
+ datasets:
105
+ tank/syncthing: ~/.secrets/syncthing-key
106
+ tank/photos: my-literal-passphrase
107
+ ```
108
+
109
+ The `secrets` mode controls how values are interpreted:
110
+ - **auto** (default): if file exists, read from it; otherwise use as literal
111
+ - **files**: always treat values as file paths
112
+ - **inline**: always treat values as literal secrets
113
+
114
+ On the NAS, install a forced-command receiver. A NixOS setup can look like this:
115
+
116
+ ```nix
117
+ { pkgs, ... }:
118
+
119
+ let
120
+ zfsUnlock = pkgs.writeShellScriptBin "zfs-unlock" ''
121
+ exec ${pkgs.uv}/bin/uv tool run zfs-unlock "$@"
122
+ '';
123
+
124
+ receiver = pkgs.writeShellScript "zfs-unlock-receiver" ''
125
+ exec ${zfsUnlock}/bin/zfs-unlock receiver \
126
+ --allow-file /etc/zfs-unlock/allowed-datasets "$@"
127
+ '';
128
+
129
+ sshWrapper = pkgs.writeShellScript "zfs-unlock-ssh-wrapper" ''
130
+ set -eu
131
+ exec ${pkgs.sudo}/bin/sudo -n ${receiver} "$SSH_ORIGINAL_COMMAND"
132
+ '';
133
+ in
134
+ {
135
+ users.groups.zfs-unlock = {};
136
+
137
+ users.users.zfs-unlock = {
138
+ isSystemUser = true;
139
+ group = "zfs-unlock";
140
+ home = "/var/lib/zfs-unlock";
141
+ createHome = true;
142
+ openssh.authorizedKeys.keys = [
143
+ ''restrict,from="192.168.1.50",command="${sshWrapper}" ssh-ed25519 AAAA... unlock-device''
144
+ ];
145
+ };
146
+
147
+ security.sudo.extraRules = [
148
+ {
149
+ users = [ "zfs-unlock" ];
150
+ commands = [
151
+ {
152
+ command = "${receiver}";
153
+ options = [ "NOPASSWD" ];
154
+ }
155
+ ];
156
+ }
157
+ ];
158
+
159
+ environment.etc."zfs-unlock/allowed-datasets".text = ''
160
+ tank/syncthing
161
+ tank/photos
162
+ '';
163
+ }
164
+ ```
165
+
166
+ The wrapper captures `SSH_ORIGINAL_COMMAND` before `sudo` and passes it as one argument to the root receiver. The receiver still checks the requested dataset against `/etc/zfs-unlock/allowed-datasets`.
167
+
168
+ ## Usage
169
+
170
+ ```bash
171
+ # Run once
172
+ zfs-unlock
173
+
174
+ # Run as daemon
175
+ # (Checks every 1s if NAS is unreachable, otherwise every 30s)
176
+ zfs-unlock --daemon
177
+
178
+ # Custom interval (for the "relaxed" state)
179
+ zfs-unlock --daemon --interval 60
180
+
181
+ # Dry run
182
+ zfs-unlock --dry-run
183
+ ```
184
+
185
+ ## CLI
186
+
187
+ ```bash
188
+ zfs-unlock --help
189
+ ```
190
+
191
+ <!-- CODE:BASH:START -->
192
+ <!-- export NO_COLOR=1 -->
193
+ <!-- export TERM=dumb -->
194
+ <!-- export TERMINAL_WIDTH=90 -->
195
+ <!-- echo '```bash' -->
196
+ <!-- zfs-unlock --help -->
197
+ <!-- echo '```' -->
198
+ <!-- CODE:END -->
199
+
200
+ <!-- OUTPUT:START -->
201
+ <!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
202
+ ```bash
203
+
204
+ Usage: zfs-unlock [OPTIONS] COMMAND [ARGS]...
205
+
206
+ Unlock OpenZFS datasets over a restricted SSH receiver
207
+
208
+ ╭─ Options ────────────────────────────────────────────────────────────────────╮
209
+ │ --config -c PATH Config file path │
210
+ │ --dry-run -n Show what would be done │
211
+ │ --daemon -d Run continuously │
212
+ │ --interval -i INTEGER Seconds between checks (1s if unreachable) │
213
+ │ [default: 30] │
214
+ │ --dataset -D TEXT Filter by dataset path │
215
+ │ --version -v Show version and exit │
216
+ │ --help -h Show this message and exit. │
217
+ ╰──────────────────────────────────────────────────────────────────────────────╯
218
+ ╭─ Commands ───────────────────────────────────────────────────────────────────╮
219
+ │ lock Lock configured datasets. │
220
+ │ status Show lock status of configured datasets. │
221
+ │ receiver Run the restricted NAS-side receiver. │
222
+ │ service Manage system service │
223
+ ╰──────────────────────────────────────────────────────────────────────────────╯
224
+
225
+ ```
226
+
227
+ <!-- OUTPUT:END -->
228
+
229
+ ## Running as a Service
230
+
231
+ Requires [uv](https://docs.astral.sh/uv/) to be installed. Auto-detects Linux (systemd) or macOS (launchd):
232
+
233
+ ```bash
234
+ # Install and start
235
+ zfs-unlock service install
236
+
237
+ # Check status
238
+ zfs-unlock service status
239
+
240
+ # View logs (follows by default)
241
+ zfs-unlock service logs
242
+
243
+ # Uninstall
244
+ zfs-unlock service uninstall
245
+ ```
246
+
247
+ ## Development
248
+
249
+ ```bash
250
+ # Clone and install
251
+ git clone https://github.com/basnijholt/zfs-unlock
252
+ cd zfs-unlock
253
+ uv sync --dev
254
+
255
+ # Run tests
256
+ uv run pytest
257
+
258
+ # Run lints
259
+ uv run ruff check .
260
+ uv run mypy zfs_unlock.py
261
+ ```
262
+
263
+ ## Credits
264
+
265
+ Based on [`truenas-unlock`](https://github.com/basnijholt/truenas-unlock).
266
+
267
+ ## License
268
+
269
+ MIT