zboxapi 0.0.7__tar.gz → 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.
- zboxapi-0.0.7/README.md → zboxapi-0.1.0/PKG-INFO +63 -0
- zboxapi-0.0.7/PKG-INFO → zboxapi-0.1.0/README.md +42 -17
- zboxapi-0.1.0/pyproject.toml +72 -0
- zboxapi-0.1.0/pyproject.toml.orig +70 -0
- zboxapi-0.1.0/src/zboxapi/__init__.py +6 -0
- {zboxapi-0.0.7 → zboxapi-0.1.0}/src/zboxapi/dns.py +37 -16
- {zboxapi-0.0.7 → zboxapi-0.1.0}/src/zboxapi/main.py +29 -24
- {zboxapi-0.0.7 → zboxapi-0.1.0}/src/zboxapi/vlan.py +117 -147
- zboxapi-0.0.7/pyproject.toml +0 -43
- zboxapi-0.0.7/src/zboxapi/__init__.py +0 -1
|
@@ -1,3 +1,24 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: zboxapi
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: zPodFactory zBox API: DNS and VLAN management for the zbox VM
|
|
5
|
+
Author: Kelby Valenti, Timo Sugliani
|
|
6
|
+
Author-email: Kelby Valenti <kelby.valenti@gmail.com>, Timo Sugliani <timo.sugliani@gmail.com>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
14
|
+
Classifier: Framework :: FastAPI
|
|
15
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
16
|
+
Requires-Dist: fastapi>=0.118
|
|
17
|
+
Requires-Dist: pydantic>=2.12
|
|
18
|
+
Requires-Dist: uvicorn>=0.37
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
1
22
|
# zBoxApi
|
|
2
23
|
|
|
3
24
|
zPodFactory zBox Api
|
|
@@ -29,6 +50,14 @@ Complete the following steps to set up zBox Api:
|
|
|
29
50
|
pipx install zboxapi
|
|
30
51
|
```
|
|
31
52
|
|
|
53
|
+
Or with [uv](https://docs.astral.sh/uv/), which also fetches a suitable Python if needed:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
uv tool install zboxapi
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
zBoxApi supports Python 3.10 through 3.14.
|
|
60
|
+
|
|
32
61
|
1. Set up and start zboxapi.service
|
|
33
62
|
|
|
34
63
|
```bash
|
|
@@ -112,6 +141,40 @@ curl -X GET "http://127.0.0.1:8000/vlan" \
|
|
|
112
141
|
For complete VLAN management documentation, see [DOC_VLAN.md](DOC_VLAN.md).
|
|
113
142
|
|
|
114
143
|
|
|
144
|
+
## Development
|
|
145
|
+
|
|
146
|
+
The project is managed with [uv](https://docs.astral.sh/uv/). Clone the repository, then:
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
uv sync # create .venv with the project and dev dependencies
|
|
150
|
+
uv run pytest # run the unit tests (no root, /etc or network access needed)
|
|
151
|
+
uv run pytest --cov # same, with a coverage report
|
|
152
|
+
uv run ruff check src tests && uv run ruff format --check src tests
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
A `justfile` wraps the same commands (`just test`, `just lint`, `just format`).
|
|
156
|
+
|
|
157
|
+
### Releasing
|
|
158
|
+
|
|
159
|
+
Every change gets a line under `[Unreleased]` in [CHANGELOG.md](CHANGELOG.md). A release is
|
|
160
|
+
one command:
|
|
161
|
+
|
|
162
|
+
```bash
|
|
163
|
+
python3 tools/release.py 0.2.0 --push # or: just release 0.2.0
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
It turns `[Unreleased]` into a dated `[0.2.0]` section, bumps `pyproject.toml` and `uv.lock`,
|
|
167
|
+
runs the tests, commits, tags `v0.2.0` and pushes. The tag then runs
|
|
168
|
+
`.github/workflows/release.yml`, which publishes the changelog section as the GitHub release
|
|
169
|
+
note, builds the package with `uv build`, publishes it to PyPI with `uv publish` and attaches
|
|
170
|
+
the wheel and sdist to the release. See [tools/README.md](tools/README.md) for the details,
|
|
171
|
+
including the one-time PyPI setup (an API token secret or trusted publishing).
|
|
172
|
+
|
|
173
|
+
The test suite exercises every endpoint through FastAPI's `TestClient`. The hosts file,
|
|
174
|
+
`/etc/zboxapi.conf`, `/etc/network/interfaces.d/` and the `vmtoolsd` password lookup are
|
|
175
|
+
redirected to temporary locations, and the `ip`, `ifup`, `ifdown` and `pkill` commands are
|
|
176
|
+
replaced by an in-memory fake, so the tests can run on any machine.
|
|
177
|
+
|
|
115
178
|
## Documentation
|
|
116
179
|
|
|
117
180
|
- [DOC_DNS.md](DOC_DNS.md) - Complete guide to DNS management features
|
|
@@ -1,19 +1,3 @@
|
|
|
1
|
-
Metadata-Version: 2.1
|
|
2
|
-
Name: zboxapi
|
|
3
|
-
Version: 0.0.7
|
|
4
|
-
Summary:
|
|
5
|
-
Author: Kelby Valenti
|
|
6
|
-
Author-email: kelby.valenti@gmail.com
|
|
7
|
-
Requires-Python: >=3.10
|
|
8
|
-
Classifier: Programming Language :: Python :: 3
|
|
9
|
-
Classifier: Programming Language :: Python :: 3.10
|
|
10
|
-
Classifier: Programming Language :: Python :: 3.11
|
|
11
|
-
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
-
Requires-Dist: fastapi (==0.111.0)
|
|
13
|
-
Requires-Dist: ipython (>=8.24.0,<9.0.0)
|
|
14
|
-
Requires-Dist: uvicorn (==0.29.0)
|
|
15
|
-
Description-Content-Type: text/markdown
|
|
16
|
-
|
|
17
1
|
# zBoxApi
|
|
18
2
|
|
|
19
3
|
zPodFactory zBox Api
|
|
@@ -45,6 +29,14 @@ Complete the following steps to set up zBox Api:
|
|
|
45
29
|
pipx install zboxapi
|
|
46
30
|
```
|
|
47
31
|
|
|
32
|
+
Or with [uv](https://docs.astral.sh/uv/), which also fetches a suitable Python if needed:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
uv tool install zboxapi
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
zBoxApi supports Python 3.10 through 3.14.
|
|
39
|
+
|
|
48
40
|
1. Set up and start zboxapi.service
|
|
49
41
|
|
|
50
42
|
```bash
|
|
@@ -128,8 +120,41 @@ curl -X GET "http://127.0.0.1:8000/vlan" \
|
|
|
128
120
|
For complete VLAN management documentation, see [DOC_VLAN.md](DOC_VLAN.md).
|
|
129
121
|
|
|
130
122
|
|
|
123
|
+
## Development
|
|
124
|
+
|
|
125
|
+
The project is managed with [uv](https://docs.astral.sh/uv/). Clone the repository, then:
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
uv sync # create .venv with the project and dev dependencies
|
|
129
|
+
uv run pytest # run the unit tests (no root, /etc or network access needed)
|
|
130
|
+
uv run pytest --cov # same, with a coverage report
|
|
131
|
+
uv run ruff check src tests && uv run ruff format --check src tests
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
A `justfile` wraps the same commands (`just test`, `just lint`, `just format`).
|
|
135
|
+
|
|
136
|
+
### Releasing
|
|
137
|
+
|
|
138
|
+
Every change gets a line under `[Unreleased]` in [CHANGELOG.md](CHANGELOG.md). A release is
|
|
139
|
+
one command:
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
python3 tools/release.py 0.2.0 --push # or: just release 0.2.0
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
It turns `[Unreleased]` into a dated `[0.2.0]` section, bumps `pyproject.toml` and `uv.lock`,
|
|
146
|
+
runs the tests, commits, tags `v0.2.0` and pushes. The tag then runs
|
|
147
|
+
`.github/workflows/release.yml`, which publishes the changelog section as the GitHub release
|
|
148
|
+
note, builds the package with `uv build`, publishes it to PyPI with `uv publish` and attaches
|
|
149
|
+
the wheel and sdist to the release. See [tools/README.md](tools/README.md) for the details,
|
|
150
|
+
including the one-time PyPI setup (an API token secret or trusted publishing).
|
|
151
|
+
|
|
152
|
+
The test suite exercises every endpoint through FastAPI's `TestClient`. The hosts file,
|
|
153
|
+
`/etc/zboxapi.conf`, `/etc/network/interfaces.d/` and the `vmtoolsd` password lookup are
|
|
154
|
+
redirected to temporary locations, and the `ip`, `ifup`, `ifdown` and `pkill` commands are
|
|
155
|
+
replaced by an in-memory fake, so the tests can run on any machine.
|
|
156
|
+
|
|
131
157
|
## Documentation
|
|
132
158
|
|
|
133
159
|
- [DOC_DNS.md](DOC_DNS.md) - Complete guide to DNS management features
|
|
134
160
|
- [DOC_VLAN.md](DOC_VLAN.md) - Complete guide to VLAN management features
|
|
135
|
-
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "zboxapi"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "zPodFactory zBox API: DNS and VLAN management for the zbox VM"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
requires-python = ">=3.10"
|
|
8
|
+
classifiers = [
|
|
9
|
+
"Programming Language :: Python :: 3",
|
|
10
|
+
"Programming Language :: Python :: 3.10",
|
|
11
|
+
"Programming Language :: Python :: 3.11",
|
|
12
|
+
"Programming Language :: Python :: 3.12",
|
|
13
|
+
"Programming Language :: Python :: 3.13",
|
|
14
|
+
"Programming Language :: Python :: 3.14",
|
|
15
|
+
"Framework :: FastAPI",
|
|
16
|
+
"Operating System :: POSIX :: Linux",
|
|
17
|
+
]
|
|
18
|
+
dependencies = [
|
|
19
|
+
"fastapi>=0.118",
|
|
20
|
+
"pydantic>=2.12",
|
|
21
|
+
"uvicorn>=0.37",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[[project.authors]]
|
|
25
|
+
name = "Kelby Valenti"
|
|
26
|
+
email = "kelby.valenti@gmail.com"
|
|
27
|
+
|
|
28
|
+
[[project.authors]]
|
|
29
|
+
name = "Timo Sugliani"
|
|
30
|
+
email = "timo.sugliani@gmail.com"
|
|
31
|
+
|
|
32
|
+
[project.scripts]
|
|
33
|
+
zboxapi = "zboxapi.main:launch"
|
|
34
|
+
|
|
35
|
+
[dependency-groups]
|
|
36
|
+
dev = [
|
|
37
|
+
"httpx2>=0.1",
|
|
38
|
+
"ipython>=8.24",
|
|
39
|
+
"pytest>=8.3",
|
|
40
|
+
"pytest-cov>=6.0",
|
|
41
|
+
"ruff>=0.13",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
[build-system]
|
|
45
|
+
requires = ["uv_build>=0.9,<1"]
|
|
46
|
+
build-backend = "uv_build"
|
|
47
|
+
|
|
48
|
+
[tool.uv]
|
|
49
|
+
required-version = ">=0.9"
|
|
50
|
+
|
|
51
|
+
[tool.pytest.ini_options]
|
|
52
|
+
testpaths = ["tests"]
|
|
53
|
+
addopts = "-ra"
|
|
54
|
+
|
|
55
|
+
[tool.coverage.run]
|
|
56
|
+
source = ["zboxapi"]
|
|
57
|
+
|
|
58
|
+
[tool.ruff]
|
|
59
|
+
target-version = "py310"
|
|
60
|
+
line-length = 88
|
|
61
|
+
|
|
62
|
+
[tool.ruff.lint]
|
|
63
|
+
select = [
|
|
64
|
+
"E",
|
|
65
|
+
"W",
|
|
66
|
+
"F",
|
|
67
|
+
"I",
|
|
68
|
+
"C",
|
|
69
|
+
"B",
|
|
70
|
+
"UP",
|
|
71
|
+
]
|
|
72
|
+
ignore = ["B008"]
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "zboxapi"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "zPodFactory zBox API: DNS and VLAN management for the zbox VM"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
requires-python = ">=3.10"
|
|
8
|
+
authors = [
|
|
9
|
+
{ name = "Kelby Valenti", email = "kelby.valenti@gmail.com" },
|
|
10
|
+
{ name = "Timo Sugliani", email = "timo.sugliani@gmail.com" },
|
|
11
|
+
]
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Programming Language :: Python :: 3",
|
|
14
|
+
"Programming Language :: Python :: 3.10",
|
|
15
|
+
"Programming Language :: Python :: 3.11",
|
|
16
|
+
"Programming Language :: Python :: 3.12",
|
|
17
|
+
"Programming Language :: Python :: 3.13",
|
|
18
|
+
"Programming Language :: Python :: 3.14",
|
|
19
|
+
"Framework :: FastAPI",
|
|
20
|
+
"Operating System :: POSIX :: Linux",
|
|
21
|
+
]
|
|
22
|
+
dependencies = [
|
|
23
|
+
"fastapi>=0.118",
|
|
24
|
+
"pydantic>=2.12",
|
|
25
|
+
"uvicorn>=0.37",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.scripts]
|
|
29
|
+
zboxapi = "zboxapi.main:launch"
|
|
30
|
+
|
|
31
|
+
[dependency-groups]
|
|
32
|
+
dev = [
|
|
33
|
+
"httpx2>=0.1",
|
|
34
|
+
"ipython>=8.24",
|
|
35
|
+
"pytest>=8.3",
|
|
36
|
+
"pytest-cov>=6.0",
|
|
37
|
+
"ruff>=0.13",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
[build-system]
|
|
41
|
+
requires = ["uv_build>=0.9,<1"]
|
|
42
|
+
build-backend = "uv_build"
|
|
43
|
+
|
|
44
|
+
[tool.uv]
|
|
45
|
+
required-version = ">=0.9"
|
|
46
|
+
|
|
47
|
+
[tool.pytest.ini_options]
|
|
48
|
+
testpaths = ["tests"]
|
|
49
|
+
addopts = "-ra"
|
|
50
|
+
|
|
51
|
+
[tool.coverage.run]
|
|
52
|
+
source = ["zboxapi"]
|
|
53
|
+
|
|
54
|
+
[tool.ruff]
|
|
55
|
+
target-version = "py310"
|
|
56
|
+
line-length = 88
|
|
57
|
+
|
|
58
|
+
[tool.ruff.lint]
|
|
59
|
+
select = [
|
|
60
|
+
"E", # pycodestyle errors
|
|
61
|
+
"W", # pycodestyle warnings
|
|
62
|
+
"F", # pyflakes
|
|
63
|
+
"I", # isort
|
|
64
|
+
"C", # flake8-comprehensions
|
|
65
|
+
"B", # flake8-bugbear
|
|
66
|
+
"UP", # pyupgrade
|
|
67
|
+
]
|
|
68
|
+
ignore = [
|
|
69
|
+
"B008",
|
|
70
|
+
]
|
|
@@ -12,11 +12,14 @@ from fastapi import APIRouter, HTTPException, status
|
|
|
12
12
|
from pydantic import AfterValidator, BaseModel
|
|
13
13
|
from pydantic_core import PydanticCustomError
|
|
14
14
|
|
|
15
|
+
# Path of the hosts file managed by this API (module-level so tests can override)
|
|
16
|
+
HOSTS_FILE = Path("/etc/hosts")
|
|
17
|
+
|
|
15
18
|
|
|
16
19
|
@contextlib.contextmanager
|
|
17
20
|
def get_hosts_file_object():
|
|
18
|
-
"""Context manager for safely handling
|
|
19
|
-
pfile =
|
|
21
|
+
"""Context manager for safely handling the hosts file"""
|
|
22
|
+
pfile = HOSTS_FILE
|
|
20
23
|
if not pfile.is_file():
|
|
21
24
|
pfile.write_text("")
|
|
22
25
|
|
|
@@ -25,7 +28,7 @@ def get_hosts_file_object():
|
|
|
25
28
|
file_handle = pfile.open("r+")
|
|
26
29
|
fcntl.flock(file_handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
27
30
|
break
|
|
28
|
-
except
|
|
31
|
+
except OSError:
|
|
29
32
|
# File is locked, wait for a while and try again
|
|
30
33
|
time.sleep(0.1)
|
|
31
34
|
|
|
@@ -105,19 +108,37 @@ def RecordAlreadyPresent(ip, hostname):
|
|
|
105
108
|
|
|
106
109
|
|
|
107
110
|
def validate_hostname(value: str):
|
|
108
|
-
"""Validate hostname format"""
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
111
|
+
"""Validate hostname format according to DNS standards"""
|
|
112
|
+
# Check total FQDN length (1-253 characters)
|
|
113
|
+
if not 1 <= len(value) <= 253:
|
|
114
|
+
raise PydanticCustomError(
|
|
115
|
+
"value_error",
|
|
116
|
+
f"Invalid hostname length: {len(value)} characters (must be 1-253)",
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
# Define pattern for individual DNS labels
|
|
120
|
+
# Each label: 1-63 chars, alphanumeric + hyphens, can't start/end with hyphen
|
|
121
|
+
label_re = re.compile(r"^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$", re.IGNORECASE)
|
|
122
|
+
|
|
123
|
+
# Check that all labels match the pattern and length requirements
|
|
124
|
+
for label in value.split("."):
|
|
125
|
+
if not label: # Empty label (leading/trailing/consecutive dots)
|
|
126
|
+
raise PydanticCustomError("value_error", "Invalid hostname: empty label")
|
|
127
|
+
|
|
128
|
+
# Check individual label length (1-63 characters)
|
|
129
|
+
if len(label) > 63:
|
|
130
|
+
raise PydanticCustomError(
|
|
131
|
+
"value_error",
|
|
132
|
+
f"Invalid label length: '{label}' is {len(label)} characters "
|
|
133
|
+
"(must be 1-63)",
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
# Check label format (alphanumeric + hyphens, can't start/end with hyphen)
|
|
137
|
+
if not label_re.match(label):
|
|
138
|
+
raise PydanticCustomError(
|
|
139
|
+
"value_error", f"Invalid hostname label: '{label}'"
|
|
140
|
+
)
|
|
141
|
+
|
|
121
142
|
return value
|
|
122
143
|
|
|
123
144
|
|
|
@@ -1,12 +1,15 @@
|
|
|
1
|
+
import functools
|
|
1
2
|
import os
|
|
2
3
|
import re
|
|
3
4
|
import subprocess
|
|
5
|
+
from collections.abc import AsyncIterator
|
|
6
|
+
from contextlib import asynccontextmanager
|
|
4
7
|
from typing import Annotated
|
|
5
8
|
|
|
6
9
|
import uvicorn
|
|
7
10
|
from fastapi import Depends, FastAPI, HTTPException, Security, status
|
|
8
11
|
from fastapi.routing import APIRoute
|
|
9
|
-
from fastapi.security.api_key import
|
|
12
|
+
from fastapi.security.api_key import APIKeyHeader
|
|
10
13
|
|
|
11
14
|
from zboxapi import __version__
|
|
12
15
|
from zboxapi.dns import dns_router
|
|
@@ -15,17 +18,9 @@ from zboxapi.vlan import vlan_router
|
|
|
15
18
|
api_key_header = APIKeyHeader(name="access_token", auto_error=False)
|
|
16
19
|
|
|
17
20
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
raise HTTPException(
|
|
22
|
-
status_code=status.HTTP_403_FORBIDDEN,
|
|
23
|
-
detail="Invalid access_token",
|
|
24
|
-
)
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
def get_zpod_password():
|
|
28
|
-
"""Retrieve zpod password from VMware tools"""
|
|
21
|
+
@functools.cache
|
|
22
|
+
def get_zpod_password() -> str:
|
|
23
|
+
"""Retrieve zpod password from VMware tools (cached after first lookup)"""
|
|
29
24
|
ovfenv = subprocess.run(
|
|
30
25
|
["vmtoolsd", "--cmd", "info-get guestinfo.ovfenv"],
|
|
31
26
|
capture_output=True,
|
|
@@ -37,19 +32,30 @@ def get_zpod_password():
|
|
|
37
32
|
raise Exception("Unable to retrieve zpod password")
|
|
38
33
|
|
|
39
34
|
|
|
40
|
-
def
|
|
35
|
+
def validate_api_key(api_key: Annotated[str | None, Security(api_key_header)]):
|
|
36
|
+
"""Validate API key for authentication"""
|
|
37
|
+
if api_key != get_zpod_password():
|
|
38
|
+
raise HTTPException(
|
|
39
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
40
|
+
detail="Invalid access_token",
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def generate_operation_id(route: APIRoute) -> str:
|
|
41
45
|
"""
|
|
42
|
-
|
|
43
|
-
names.
|
|
46
|
+
Build operation IDs as "<tag>_<function name>" so that generated API
|
|
47
|
+
clients have simpler function names (e.g. dns_dns_get_all).
|
|
44
48
|
"""
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
tag = route.tags[0] if route.tags else "default"
|
|
48
|
-
route.operation_id = f"{tag}_{route.name}"
|
|
49
|
+
tag = route.tags[0] if route.tags else "default"
|
|
50
|
+
return f"{tag}_{route.name}"
|
|
49
51
|
|
|
50
52
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
+
@asynccontextmanager
|
|
54
|
+
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
|
55
|
+
"""Resolve the zpod password at startup so a broken VM env fails fast"""
|
|
56
|
+
get_zpod_password()
|
|
57
|
+
yield
|
|
58
|
+
|
|
53
59
|
|
|
54
60
|
# Get root path from environment
|
|
55
61
|
zboxapi_root_path = os.getenv("ZBOXAPI_ROOT_PATH", None)
|
|
@@ -60,15 +66,14 @@ app = FastAPI(
|
|
|
60
66
|
root_path=zboxapi_root_path,
|
|
61
67
|
dependencies=[Depends(validate_api_key)],
|
|
62
68
|
version=__version__,
|
|
69
|
+
lifespan=lifespan,
|
|
70
|
+
generate_unique_id_function=generate_operation_id,
|
|
63
71
|
)
|
|
64
72
|
|
|
65
73
|
# Include routers
|
|
66
74
|
app.include_router(dns_router)
|
|
67
75
|
app.include_router(vlan_router)
|
|
68
76
|
|
|
69
|
-
# Simplify operation IDs
|
|
70
|
-
simplify_operation_ids(app)
|
|
71
|
-
|
|
72
77
|
|
|
73
78
|
def launch():
|
|
74
79
|
"""Launch the FastAPI application with uvicorn"""
|
|
@@ -12,6 +12,10 @@ from fastapi import APIRouter, HTTPException, status
|
|
|
12
12
|
from pydantic import AfterValidator, BaseModel, Field
|
|
13
13
|
from pydantic_core import PydanticCustomError
|
|
14
14
|
|
|
15
|
+
# Paths managed by this API (module-level so tests can override)
|
|
16
|
+
CONFIG_FILE = Path("/etc/zboxapi.conf")
|
|
17
|
+
INTERFACES_DIR = Path("/etc/network/interfaces.d")
|
|
18
|
+
|
|
15
19
|
|
|
16
20
|
class ConfigError(Exception):
|
|
17
21
|
"""Configuration error"""
|
|
@@ -28,17 +32,20 @@ class NetworkError(Exception):
|
|
|
28
32
|
def load_config() -> configparser.ConfigParser:
|
|
29
33
|
"""Load configuration from /etc/zboxapi.conf"""
|
|
30
34
|
config = configparser.ConfigParser()
|
|
31
|
-
config_path =
|
|
35
|
+
config_path = CONFIG_FILE
|
|
32
36
|
|
|
33
37
|
if not config_path.exists():
|
|
34
|
-
raise ConfigError("Configuration file
|
|
38
|
+
raise ConfigError(f"Configuration file {config_path} not found")
|
|
35
39
|
|
|
36
40
|
config.read(config_path)
|
|
37
41
|
return config
|
|
38
42
|
|
|
39
43
|
|
|
40
44
|
def get_config_value(
|
|
41
|
-
config: configparser.ConfigParser,
|
|
45
|
+
config: configparser.ConfigParser,
|
|
46
|
+
section: str,
|
|
47
|
+
key: str,
|
|
48
|
+
default: str | None = None,
|
|
42
49
|
) -> str:
|
|
43
50
|
"""Get configuration value with optional default"""
|
|
44
51
|
try:
|
|
@@ -115,7 +122,7 @@ def validate_cidr(cidr: str) -> str:
|
|
|
115
122
|
"""Validate CIDR notation, but preserve the original input."""
|
|
116
123
|
try:
|
|
117
124
|
# Parse the CIDR to validate it's correct
|
|
118
|
-
|
|
125
|
+
ipaddress.IPv4Network(cidr, strict=False)
|
|
119
126
|
# But return the original input, not the normalized network
|
|
120
127
|
return cidr
|
|
121
128
|
except ValueError as e:
|
|
@@ -150,7 +157,9 @@ def check_no_overlap(cidrs):
|
|
|
150
157
|
return True
|
|
151
158
|
|
|
152
159
|
|
|
153
|
-
def validate_vlan_networks(
|
|
160
|
+
def validate_vlan_networks(
|
|
161
|
+
new_gateway: str, exclude_vlan_id: int | None = None
|
|
162
|
+
) -> None:
|
|
154
163
|
"""Validate that a new gateway doesn't overlap with existing VLAN networks"""
|
|
155
164
|
# Get all existing VLAN gateways
|
|
156
165
|
existing_vlans = get_existing_vlans()
|
|
@@ -160,6 +169,13 @@ def validate_vlan_networks(new_gateway: str, exclude_vlan_id: int = None) -> Non
|
|
|
160
169
|
# Skip the VLAN we're updating (if this is an update operation)
|
|
161
170
|
if exclude_vlan_id is not None and vlan.vlan == exclude_vlan_id:
|
|
162
171
|
continue
|
|
172
|
+
# System VLANs whose interface has no address carry a placeholder
|
|
173
|
+
# ("system-default" / "system-zpod") instead of a CIDR: nothing to
|
|
174
|
+
# compare against, so leave them out of the overlap check.
|
|
175
|
+
try:
|
|
176
|
+
ipaddress.ip_network(vlan.gateway, strict=False)
|
|
177
|
+
except ValueError:
|
|
178
|
+
continue
|
|
163
179
|
all_gateways.append(vlan.gateway)
|
|
164
180
|
|
|
165
181
|
# Check for overlaps
|
|
@@ -192,9 +208,9 @@ class VlanView(BaseModel):
|
|
|
192
208
|
|
|
193
209
|
@contextlib.contextmanager
|
|
194
210
|
def get_vlan_config_file_object(vlan_id: int):
|
|
195
|
-
"""Context manager for safely handling VLAN
|
|
211
|
+
"""Context manager for safely handling VLAN config files in interfaces.d/"""
|
|
196
212
|
interface_name = get_interface_name()
|
|
197
|
-
config_dir =
|
|
213
|
+
config_dir = INTERFACES_DIR
|
|
198
214
|
config_file = config_dir / f"{interface_name}.{vlan_id}.cfg"
|
|
199
215
|
|
|
200
216
|
# Ensure the directory exists
|
|
@@ -205,7 +221,7 @@ def get_vlan_config_file_object(vlan_id: int):
|
|
|
205
221
|
file_handle = config_file.open("w") # Use write mode for individual files
|
|
206
222
|
fcntl.flock(file_handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
207
223
|
break
|
|
208
|
-
except
|
|
224
|
+
except OSError:
|
|
209
225
|
time.sleep(0.1)
|
|
210
226
|
|
|
211
227
|
try:
|
|
@@ -215,108 +231,95 @@ def get_vlan_config_file_object(vlan_id: int):
|
|
|
215
231
|
file_handle.close()
|
|
216
232
|
|
|
217
233
|
|
|
218
|
-
def
|
|
219
|
-
"""
|
|
220
|
-
|
|
221
|
-
|
|
234
|
+
def get_interface_status(interface_name_full: str) -> str:
|
|
235
|
+
"""Return up if the interface exists and is up, otherwise down"""
|
|
236
|
+
try:
|
|
237
|
+
result = subprocess.run(
|
|
238
|
+
["ip", "link", "show", interface_name_full],
|
|
239
|
+
capture_output=True,
|
|
240
|
+
text=True,
|
|
241
|
+
check=False,
|
|
242
|
+
)
|
|
243
|
+
if result.returncode == 0 and "UP" in result.stdout:
|
|
244
|
+
return "up"
|
|
245
|
+
except Exception:
|
|
246
|
+
pass
|
|
247
|
+
return "down"
|
|
222
248
|
|
|
223
|
-
vlans = []
|
|
224
249
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
250
|
+
def get_interface_gateway(interface_name_full: str) -> str | None:
|
|
251
|
+
"""Return the first inet address (CIDR) of an interface, if any"""
|
|
252
|
+
try:
|
|
253
|
+
result = subprocess.run(
|
|
254
|
+
["ip", "addr", "show", interface_name_full],
|
|
255
|
+
capture_output=True,
|
|
256
|
+
text=True,
|
|
257
|
+
check=False,
|
|
258
|
+
)
|
|
259
|
+
if result.returncode == 0:
|
|
260
|
+
ip_match = re.search(r"inet\s+(\d+\.\d+\.\d+\.\d+/\d+)", result.stdout)
|
|
261
|
+
if ip_match:
|
|
262
|
+
return ip_match.group(1)
|
|
263
|
+
except Exception:
|
|
264
|
+
pass
|
|
265
|
+
return None
|
|
231
266
|
|
|
232
|
-
# Check if interface is currently up
|
|
233
|
-
status = "down"
|
|
234
|
-
try:
|
|
235
|
-
result = subprocess.run(
|
|
236
|
-
["ip", "link", "show", interface_name_full],
|
|
237
|
-
capture_output=True,
|
|
238
|
-
text=True,
|
|
239
|
-
check=False,
|
|
240
|
-
)
|
|
241
|
-
if result.returncode == 0 and "UP" in result.stdout:
|
|
242
|
-
status = "up"
|
|
243
|
-
except Exception:
|
|
244
|
-
pass
|
|
245
267
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
if ip_match:
|
|
257
|
-
gateway = ip_match.group(1)
|
|
258
|
-
except Exception:
|
|
259
|
-
pass
|
|
260
|
-
|
|
261
|
-
vlans.append(
|
|
262
|
-
VlanView(
|
|
263
|
-
vlan=vlan_id,
|
|
264
|
-
gateway=gateway,
|
|
265
|
-
interface=interface_name_full,
|
|
266
|
-
status=status,
|
|
267
|
-
owner="system-default",
|
|
268
|
-
)
|
|
269
|
-
)
|
|
268
|
+
def get_system_vlan_view(interface_name: str, vlan_id: int, owner: str) -> VlanView:
|
|
269
|
+
"""Build the view of a system VLAN from the live interface state"""
|
|
270
|
+
interface_name_full = f"{interface_name}.{vlan_id}"
|
|
271
|
+
return VlanView(
|
|
272
|
+
vlan=vlan_id,
|
|
273
|
+
gateway=get_interface_gateway(interface_name_full) or owner,
|
|
274
|
+
interface=interface_name_full,
|
|
275
|
+
status=get_interface_status(interface_name_full),
|
|
276
|
+
owner=owner,
|
|
277
|
+
)
|
|
270
278
|
|
|
271
|
-
# Add system zPod VLANs
|
|
272
|
-
system_vlans_zpod = get_system_vlans_zpod()
|
|
273
|
-
for vlan_id in system_vlans_zpod:
|
|
274
|
-
# Try to get actual gateway from ip addr command
|
|
275
|
-
gateway = "system-zpod"
|
|
276
|
-
interface_name_full = f"{interface_name}.{vlan_id}"
|
|
277
279
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
)
|
|
287
|
-
if result.returncode == 0 and "UP" in result.stdout:
|
|
288
|
-
status = "up"
|
|
289
|
-
except Exception:
|
|
290
|
-
pass
|
|
280
|
+
def get_user_vlan_view(
|
|
281
|
+
interface_name: str, vlan_id: int, config_file: Path
|
|
282
|
+
) -> VlanView | None:
|
|
283
|
+
"""Build the view of a user-defined VLAN from its interfaces.d config file"""
|
|
284
|
+
try:
|
|
285
|
+
content = config_file.read_text()
|
|
286
|
+
except OSError:
|
|
287
|
+
return None
|
|
291
288
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
pass
|
|
306
|
-
|
|
307
|
-
vlans.append(
|
|
308
|
-
VlanView(
|
|
309
|
-
vlan=vlan_id,
|
|
310
|
-
gateway=gateway,
|
|
311
|
-
interface=interface_name_full,
|
|
312
|
-
status=status,
|
|
313
|
-
owner="system-zpod",
|
|
314
|
-
)
|
|
315
|
-
)
|
|
289
|
+
# Extract gateway from the configuration
|
|
290
|
+
gateway_match = re.search(r"address\s+(\d+\.\d+\.\d+\.\d+/\d+)", content)
|
|
291
|
+
if not gateway_match:
|
|
292
|
+
return None
|
|
293
|
+
|
|
294
|
+
interface_name_full = f"{interface_name}.{vlan_id}"
|
|
295
|
+
return VlanView(
|
|
296
|
+
vlan=vlan_id,
|
|
297
|
+
gateway=gateway_match.group(1),
|
|
298
|
+
interface=interface_name_full,
|
|
299
|
+
status=get_interface_status(interface_name_full),
|
|
300
|
+
owner="user-defined",
|
|
301
|
+
)
|
|
316
302
|
|
|
317
|
-
|
|
303
|
+
|
|
304
|
+
def get_existing_vlans() -> list[VlanView]:
|
|
305
|
+
"""Get all VLANs: system VLANs from config plus user VLANs from interfaces.d/"""
|
|
306
|
+
interface_name = get_interface_name()
|
|
307
|
+
config_dir = INTERFACES_DIR
|
|
308
|
+
|
|
309
|
+
system_vlans_default = get_system_vlans_default()
|
|
310
|
+
system_vlans_zpod = get_system_vlans_zpod()
|
|
311
|
+
|
|
312
|
+
vlans = [
|
|
313
|
+
get_system_vlan_view(interface_name, vlan_id, "system-default")
|
|
314
|
+
for vlan_id in system_vlans_default
|
|
315
|
+
]
|
|
316
|
+
vlans.extend(
|
|
317
|
+
get_system_vlan_view(interface_name, vlan_id, "system-zpod")
|
|
318
|
+
for vlan_id in system_vlans_zpod
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
# Add user-configured VLANs from interfaces.d/
|
|
318
322
|
if config_dir.exists():
|
|
319
|
-
# Find all VLAN configuration files
|
|
320
323
|
vlan_pattern = rf"{re.escape(interface_name)}\.(\d+)\.cfg$"
|
|
321
324
|
|
|
322
325
|
for config_file in config_dir.glob(f"{interface_name}.*.cfg"):
|
|
@@ -330,44 +333,8 @@ def get_existing_vlans() -> list[VlanView]:
|
|
|
330
333
|
if vlan_id in system_vlans_default or vlan_id in system_vlans_zpod:
|
|
331
334
|
continue
|
|
332
335
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
content = f.read()
|
|
336
|
-
|
|
337
|
-
# Extract gateway from the configuration
|
|
338
|
-
gateway_match = re.search(
|
|
339
|
-
r"address\s+(\d+\.\d+\.\d+\.\d+/\d+)", content
|
|
340
|
-
)
|
|
341
|
-
if not gateway_match:
|
|
342
|
-
continue
|
|
343
|
-
|
|
344
|
-
gateway = gateway_match.group(1)
|
|
345
|
-
|
|
346
|
-
# Check if interface is currently up
|
|
347
|
-
status = "down"
|
|
348
|
-
try:
|
|
349
|
-
result = subprocess.run(
|
|
350
|
-
["ip", "link", "show", f"{interface_name}.{vlan_id}"],
|
|
351
|
-
capture_output=True,
|
|
352
|
-
text=True,
|
|
353
|
-
check=False,
|
|
354
|
-
)
|
|
355
|
-
if result.returncode == 0 and "UP" in result.stdout:
|
|
356
|
-
status = "up"
|
|
357
|
-
except Exception:
|
|
358
|
-
pass
|
|
359
|
-
|
|
360
|
-
vlans.append(
|
|
361
|
-
VlanView(
|
|
362
|
-
vlan=vlan_id,
|
|
363
|
-
gateway=gateway,
|
|
364
|
-
interface=f"{interface_name}.{vlan_id}",
|
|
365
|
-
status=status,
|
|
366
|
-
owner="user-defined",
|
|
367
|
-
)
|
|
368
|
-
)
|
|
369
|
-
except Exception:
|
|
370
|
-
continue # Skip files that can't be read or parsed
|
|
336
|
+
if view := get_user_vlan_view(interface_name, vlan_id, config_file):
|
|
337
|
+
vlans.append(view)
|
|
371
338
|
|
|
372
339
|
return sorted(vlans, key=lambda x: x.vlan)
|
|
373
340
|
|
|
@@ -381,8 +348,7 @@ def add_vlan_interface(vlan_id: int, gateway: str) -> None:
|
|
|
381
348
|
validate_vlan_networks(gateway)
|
|
382
349
|
|
|
383
350
|
# Check if VLAN configuration file already exists
|
|
384
|
-
|
|
385
|
-
config_file = config_dir / f"{interface_name}.{vlan_id}.cfg"
|
|
351
|
+
config_file = INTERFACES_DIR / f"{interface_name}.{vlan_id}.cfg"
|
|
386
352
|
|
|
387
353
|
if config_file.exists():
|
|
388
354
|
raise NetworkError(f"VLAN interface {interface_name}.{vlan_id} already exists")
|
|
@@ -404,12 +370,11 @@ def update_vlan_interface(vlan_id: int, gateway: str) -> None:
|
|
|
404
370
|
interface_name = get_interface_name()
|
|
405
371
|
mtu = get_mtu()
|
|
406
372
|
|
|
407
|
-
# Validate inputs - check for overlaps
|
|
373
|
+
# Validate inputs - check for overlaps, excluding the VLAN being updated
|
|
408
374
|
validate_vlan_networks(gateway, exclude_vlan_id=vlan_id)
|
|
409
375
|
|
|
410
376
|
# Check if VLAN configuration file exists
|
|
411
|
-
|
|
412
|
-
config_file = config_dir / f"{interface_name}.{vlan_id}.cfg"
|
|
377
|
+
config_file = INTERFACES_DIR / f"{interface_name}.{vlan_id}.cfg"
|
|
413
378
|
|
|
414
379
|
if not config_file.exists():
|
|
415
380
|
raise NetworkError(f"VLAN interface {interface_name}.{vlan_id} does not exist")
|
|
@@ -429,8 +394,7 @@ iface {interface_name}.{vlan_id} inet static
|
|
|
429
394
|
def delete_vlan_interface(vlan_id: int) -> None:
|
|
430
395
|
"""Delete VLAN interface configuration from /etc/network/interfaces.d/"""
|
|
431
396
|
interface_name = get_interface_name()
|
|
432
|
-
|
|
433
|
-
config_file = config_dir / f"{interface_name}.{vlan_id}.cfg"
|
|
397
|
+
config_file = INTERFACES_DIR / f"{interface_name}.{vlan_id}.cfg"
|
|
434
398
|
|
|
435
399
|
if not config_file.exists():
|
|
436
400
|
raise NetworkError(f"VLAN interface {interface_name}.{vlan_id} does not exist")
|
|
@@ -572,6 +536,9 @@ def vlan_update(vlan_id: int, vlan_in: VlanUpdate) -> VlanView:
|
|
|
572
536
|
status="up",
|
|
573
537
|
owner="user-defined",
|
|
574
538
|
)
|
|
539
|
+
except PydanticCustomError as e:
|
|
540
|
+
# System VLANs are forbidden from modification
|
|
541
|
+
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e)) from e
|
|
575
542
|
except (ConfigError, NetworkError) as e:
|
|
576
543
|
raise HTTPException(
|
|
577
544
|
status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)
|
|
@@ -590,10 +557,13 @@ def vlan_delete(vlan_id: int) -> dict:
|
|
|
590
557
|
# Validate VLAN ID
|
|
591
558
|
validate_vlan_id(vlan_id)
|
|
592
559
|
|
|
593
|
-
# Delete VLAN
|
|
560
|
+
# Delete VLAN configuration (brings the interface down first)
|
|
594
561
|
delete_vlan_interface(vlan_id)
|
|
595
562
|
|
|
596
563
|
return {"message": f"VLAN {vlan_id} deleted successfully"}
|
|
564
|
+
except PydanticCustomError as e:
|
|
565
|
+
# System VLANs are forbidden from modification
|
|
566
|
+
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e)) from e
|
|
597
567
|
except (ConfigError, NetworkError) as e:
|
|
598
568
|
raise HTTPException(
|
|
599
569
|
status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)
|
zboxapi-0.0.7/pyproject.toml
DELETED
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
[tool.poetry]
|
|
2
|
-
name = "zboxapi"
|
|
3
|
-
version = "0.0.7"
|
|
4
|
-
description = ""
|
|
5
|
-
authors = ["Kelby Valenti <kelby.valenti@gmail.com>", "Timo Sugliani <timo.sugliani@gmail.com>"]
|
|
6
|
-
readme = "README.md"
|
|
7
|
-
|
|
8
|
-
[tool.poetry.scripts]
|
|
9
|
-
zboxapi = "zboxapi.main:launch"
|
|
10
|
-
|
|
11
|
-
[tool.poetry.dependencies]
|
|
12
|
-
fastapi = "0.111.0"
|
|
13
|
-
python = ">=3.10"
|
|
14
|
-
uvicorn = "0.29.0"
|
|
15
|
-
ipython = "^8.24.0"
|
|
16
|
-
|
|
17
|
-
[tool.poetry.group.dev.dependencies]
|
|
18
|
-
ruff = "^0.4"
|
|
19
|
-
|
|
20
|
-
[build-system]
|
|
21
|
-
requires = ["poetry-core"]
|
|
22
|
-
build-backend = "poetry.core.masonry.api"
|
|
23
|
-
|
|
24
|
-
[tool.ruff.lint]
|
|
25
|
-
select = [
|
|
26
|
-
"E", # pycodestyle errors
|
|
27
|
-
"W", # pycodestyle warnings
|
|
28
|
-
"F", # pyflakes
|
|
29
|
-
"I", # isort
|
|
30
|
-
"C", # flake8-comprehensions
|
|
31
|
-
"B", # flake8-bugbear
|
|
32
|
-
"UP", # pyupgrade
|
|
33
|
-
]
|
|
34
|
-
ignore = [
|
|
35
|
-
"B008",
|
|
36
|
-
]
|
|
37
|
-
|
|
38
|
-
[[tool.poetry_bumpversion.replacements]]
|
|
39
|
-
files = [
|
|
40
|
-
"src/zboxapi/__init__.py",
|
|
41
|
-
]
|
|
42
|
-
search = '__version__ = "{current_version}"'
|
|
43
|
-
replace = '__version__ = "{new_version}"'
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.0.7"
|