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.
- zfs_unlock-0.1.0/.envrc +1 -0
- zfs_unlock-0.1.0/.github/release-drafter.yml +5 -0
- zfs_unlock-0.1.0/.github/renovate.json +35 -0
- zfs_unlock-0.1.0/.github/workflows/pytest.yml +31 -0
- zfs_unlock-0.1.0/.github/workflows/release-drafter.yml +15 -0
- zfs_unlock-0.1.0/.github/workflows/release.yml +23 -0
- zfs_unlock-0.1.0/.github/workflows/toc.yaml +16 -0
- zfs_unlock-0.1.0/.github/workflows/update-readme.yml +48 -0
- zfs_unlock-0.1.0/.gitignore +166 -0
- zfs_unlock-0.1.0/.pre-commit-config.yaml +41 -0
- zfs_unlock-0.1.0/.yamllint +7 -0
- zfs_unlock-0.1.0/LICENSE +21 -0
- zfs_unlock-0.1.0/PKG-INFO +269 -0
- zfs_unlock-0.1.0/README.md +239 -0
- zfs_unlock-0.1.0/_version.py +24 -0
- zfs_unlock-0.1.0/config.example.yaml +17 -0
- zfs_unlock-0.1.0/devbox.json +13 -0
- zfs_unlock-0.1.0/devbox.lock +114 -0
- zfs_unlock-0.1.0/pyproject.toml +87 -0
- zfs_unlock-0.1.0/tests/__init__.py +1 -0
- zfs_unlock-0.1.0/tests/test_cli.py +190 -0
- zfs_unlock-0.1.0/tests/test_client.py +118 -0
- zfs_unlock-0.1.0/tests/test_config.py +131 -0
- zfs_unlock-0.1.0/tests/test_integration.py +106 -0
- zfs_unlock-0.1.0/tests/test_main.py +138 -0
- zfs_unlock-0.1.0/uv.lock +746 -0
- zfs_unlock-0.1.0/zfs_unlock.py +806 -0
zfs_unlock-0.1.0/.envrc
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
source .venv/bin/activate
|
|
@@ -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,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,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
|
zfs_unlock-0.1.0/LICENSE
ADDED
|
@@ -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
|
+
[](https://pypi.org/project/zfs-unlock/)
|
|
34
|
+
[](https://pypi.org/project/zfs-unlock/)
|
|
35
|
+
[](https://github.com/basnijholt/zfs-unlock/actions/workflows/pytest.yml)
|
|
36
|
+
[](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
|