zboxapi 0.0.2__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.2/PKG-INFO +75 -0
- zboxapi-0.0.2/README.md +60 -0
- zboxapi-0.0.2/pyproject.toml +44 -0
- zboxapi-0.0.2/src/zboxapi/__init__.py +1 -0
- zboxapi-0.0.2/src/zboxapi/main.py +258 -0
zboxapi-0.0.2/PKG-INFO
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: zboxapi
|
|
3
|
+
Version: 0.0.2
|
|
4
|
+
Summary:
|
|
5
|
+
Author: Kelby Valenti
|
|
6
|
+
Author-email: kelby.valenti@gmail.com
|
|
7
|
+
Requires-Python: >=3.12
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
10
|
+
Requires-Dist: fastapi (==0.111.0)
|
|
11
|
+
Requires-Dist: ipython (>=8.24.0,<9.0.0)
|
|
12
|
+
Requires-Dist: uvicorn (==0.29.0)
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# zBoxApi
|
|
16
|
+
|
|
17
|
+
zPodFactory zBox Api
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
Complete the following steps to set up zBox Api:
|
|
22
|
+
|
|
23
|
+
1. Install pipx
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
# Install and configure pipx
|
|
27
|
+
apt update
|
|
28
|
+
apt install -y pipx
|
|
29
|
+
pipx ensurepath
|
|
30
|
+
|
|
31
|
+
# Reload your profile
|
|
32
|
+
source ~/.zshrc
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
1. Install pyenv and add 3.12.1
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
|
|
39
|
+
# Install required packages
|
|
40
|
+
apt update
|
|
41
|
+
apt install -y make build-essential libssl-dev zlib1g-dev \
|
|
42
|
+
libbz2-dev libreadline-dev libsqlite3-dev wget curl llvm \
|
|
43
|
+
libncursesw5-dev xz-utils tk-dev libxml2-dev libxmlsec1-dev libffi-dev \
|
|
44
|
+
liblzma-dev
|
|
45
|
+
|
|
46
|
+
# Install pyenv
|
|
47
|
+
curl https://pyenv.run | bash
|
|
48
|
+
|
|
49
|
+
# Configure user profile
|
|
50
|
+
echo 'export PYENV_ROOT="$HOME/.pyenv"' >> ~/.zshrc
|
|
51
|
+
echo '[[ -d $PYENV_ROOT/bin ]] && export PATH="$PYENV_ROOT/bin:$PATH"' >> ~/.zshrc
|
|
52
|
+
echo 'eval "$(pyenv init -)"' >> ~/.zshrc
|
|
53
|
+
|
|
54
|
+
# Reload your profile
|
|
55
|
+
source ~/.zshrc
|
|
56
|
+
|
|
57
|
+
# Install 3.12.1
|
|
58
|
+
pyenv install 3.12.1
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
1. Install zBoxApi:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
pipx install zboxapi --python /root/.pyenv/versions/3.12.1/bin/python3
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
1. Set up and start zboxapi.service
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
cp zboxapi.service /etc/systemd/system
|
|
71
|
+
systemctl daemon-reload
|
|
72
|
+
systemctl enable zboxapi.service
|
|
73
|
+
systemctl start zboxapi.service
|
|
74
|
+
```
|
|
75
|
+
|
zboxapi-0.0.2/README.md
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# zBoxApi
|
|
2
|
+
|
|
3
|
+
zPodFactory zBox Api
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
Complete the following steps to set up zBox Api:
|
|
8
|
+
|
|
9
|
+
1. Install pipx
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
# Install and configure pipx
|
|
13
|
+
apt update
|
|
14
|
+
apt install -y pipx
|
|
15
|
+
pipx ensurepath
|
|
16
|
+
|
|
17
|
+
# Reload your profile
|
|
18
|
+
source ~/.zshrc
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
1. Install pyenv and add 3.12.1
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
|
|
25
|
+
# Install required packages
|
|
26
|
+
apt update
|
|
27
|
+
apt install -y make build-essential libssl-dev zlib1g-dev \
|
|
28
|
+
libbz2-dev libreadline-dev libsqlite3-dev wget curl llvm \
|
|
29
|
+
libncursesw5-dev xz-utils tk-dev libxml2-dev libxmlsec1-dev libffi-dev \
|
|
30
|
+
liblzma-dev
|
|
31
|
+
|
|
32
|
+
# Install pyenv
|
|
33
|
+
curl https://pyenv.run | bash
|
|
34
|
+
|
|
35
|
+
# Configure user profile
|
|
36
|
+
echo 'export PYENV_ROOT="$HOME/.pyenv"' >> ~/.zshrc
|
|
37
|
+
echo '[[ -d $PYENV_ROOT/bin ]] && export PATH="$PYENV_ROOT/bin:$PATH"' >> ~/.zshrc
|
|
38
|
+
echo 'eval "$(pyenv init -)"' >> ~/.zshrc
|
|
39
|
+
|
|
40
|
+
# Reload your profile
|
|
41
|
+
source ~/.zshrc
|
|
42
|
+
|
|
43
|
+
# Install 3.12.1
|
|
44
|
+
pyenv install 3.12.1
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
1. Install zBoxApi:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
pipx install zboxapi --python /root/.pyenv/versions/3.12.1/bin/python3
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
1. Set up and start zboxapi.service
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
cp zboxapi.service /etc/systemd/system
|
|
57
|
+
systemctl daemon-reload
|
|
58
|
+
systemctl enable zboxapi.service
|
|
59
|
+
systemctl start zboxapi.service
|
|
60
|
+
```
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "zboxapi"
|
|
3
|
+
version = "0.0.2"
|
|
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.12"
|
|
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}"'
|
|
44
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.0.2"
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import contextlib
|
|
2
|
+
import fcntl
|
|
3
|
+
import re
|
|
4
|
+
import socket
|
|
5
|
+
|
|
6
|
+
# import ssl
|
|
7
|
+
import subprocess
|
|
8
|
+
import time
|
|
9
|
+
from ipaddress import IPv4Address
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import IO, Annotated
|
|
12
|
+
|
|
13
|
+
import uvicorn
|
|
14
|
+
from fastapi import APIRouter, Depends, FastAPI, HTTPException, Security, status
|
|
15
|
+
from fastapi.security.api_key import APIKey, APIKeyHeader
|
|
16
|
+
from pydantic import AfterValidator, BaseModel
|
|
17
|
+
from pydantic_core import PydanticCustomError
|
|
18
|
+
|
|
19
|
+
api_key_header = APIKeyHeader(name="access_token", auto_error=False)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@contextlib.contextmanager
|
|
23
|
+
def get_hosts_file_object():
|
|
24
|
+
pfile = Path("/etc/hosts")
|
|
25
|
+
if not pfile.is_file():
|
|
26
|
+
pfile.write_text("")
|
|
27
|
+
|
|
28
|
+
while True:
|
|
29
|
+
try:
|
|
30
|
+
file_handle = pfile.open("r+")
|
|
31
|
+
fcntl.flock(file_handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
32
|
+
break
|
|
33
|
+
except IOError: # noqa: UP024
|
|
34
|
+
# File is locked, wait for a while and try again
|
|
35
|
+
time.sleep(0.1)
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
yield file_handle
|
|
39
|
+
finally:
|
|
40
|
+
fcntl.flock(file_handle, fcntl.LOCK_UN)
|
|
41
|
+
file_handle.close()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def get_hosts_lines(hosts_fo: IO) -> list[dict[str, str]]:
|
|
45
|
+
lines = []
|
|
46
|
+
for line in hosts_fo.read().splitlines():
|
|
47
|
+
line = line.strip()
|
|
48
|
+
if not line or line.startswith("#"):
|
|
49
|
+
continue
|
|
50
|
+
line_ip, *line_fqdns = line.split()
|
|
51
|
+
lines.extend(
|
|
52
|
+
{"ip": IPv4Address(line_ip), "fqdn": line_fqdn} for line_fqdn in line_fqdns
|
|
53
|
+
)
|
|
54
|
+
return sorted(lines, key=sort_hosts_lines)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def filter_hosts_file(
|
|
58
|
+
lines: list[dict[str, str]],
|
|
59
|
+
ip: IPv4Address | None = None,
|
|
60
|
+
fqdn: str | None = None,
|
|
61
|
+
):
|
|
62
|
+
return [
|
|
63
|
+
line
|
|
64
|
+
for line in lines
|
|
65
|
+
if (not ip or ip == line["ip"]) and (not fqdn or fqdn == line["fqdn"])
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def write_hosts_file(hosts_fo: IO, lines: list[dict[str, str]]):
|
|
70
|
+
lines.sort(key=sort_hosts_lines)
|
|
71
|
+
hosts_fo.seek(0)
|
|
72
|
+
hosts_fo.truncate()
|
|
73
|
+
hosts_fo.writelines([f"{line['ip']}\t{line['fqdn']}\n" for line in lines])
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def validate_api_key(api_key: Annotated[APIKey, Security(api_key_header)]):
|
|
77
|
+
if api_key != ZPOD_PASSWORD:
|
|
78
|
+
raise HTTPException(
|
|
79
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
80
|
+
detail="Invalid access_token",
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def get_zpod_password():
|
|
85
|
+
ovfenv = subprocess.run(
|
|
86
|
+
["vmtoolsd", "--cmd", "info-get guestinfo.ovfenv"],
|
|
87
|
+
capture_output=True,
|
|
88
|
+
text=True,
|
|
89
|
+
)
|
|
90
|
+
pw_re = re.compile(r'<Property oe:key="guestinfo.password" oe:value="([^"]*)"/>')
|
|
91
|
+
if item := re.search(pw_re, ovfenv.stdout):
|
|
92
|
+
return item[1]
|
|
93
|
+
raise Exception("Unable to retrieve zpod password")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def dnsmasq_sighup():
|
|
97
|
+
print("Send SIGHUP to dnsmasq...")
|
|
98
|
+
subprocess.call(["pkill", "-SIGHUP", "dnsmasq"])
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def sort_hosts_lines(item: dict):
|
|
102
|
+
"""Sort list by loopback first, ip second, fqdn third"""
|
|
103
|
+
return (
|
|
104
|
+
not item["ip"].is_loopback,
|
|
105
|
+
socket.inet_aton(str(item["ip"])),
|
|
106
|
+
item["fqdn"],
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def make_list(obj):
|
|
111
|
+
return obj if isinstance(obj, list) else [obj]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def ip_fqdn_str(obj, ip_key="ip", fqdn_key="fqdn"):
|
|
115
|
+
return f"ip={getattr(obj, ip_key)}, fqdn={getattr(obj, fqdn_key)}"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def ip_fqdn_dict(obj, ip_key="ip", fqdn_key="fqdn"):
|
|
119
|
+
return {"ip": getattr(obj, ip_key), "fqdn": getattr(obj, fqdn_key)}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def RecordNotFound(obj, ip_key="ip", fqdn_key="fqdn"):
|
|
123
|
+
return HTTPException(
|
|
124
|
+
status_code=status.HTTP_406_NOT_ACCEPTABLE,
|
|
125
|
+
detail=f"Record not found: {ip_fqdn_str(obj, ip_key, fqdn_key)}",
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def RecordAlreadyPresent(obj, ip_key="ip", fqdn_key="fqdn"):
|
|
130
|
+
return HTTPException(
|
|
131
|
+
status_code=status.HTTP_406_NOT_ACCEPTABLE,
|
|
132
|
+
detail=f"Record already present: {ip_fqdn_str(obj, ip_key, fqdn_key)}",
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def validate_fqdn(value: str):
|
|
137
|
+
"""
|
|
138
|
+
https://en.m.wikipedia.org/wiki/Fully_qualified_domain_name
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
if not 1 < len(value) < 253:
|
|
142
|
+
raise PydanticCustomError("value_error", "Invalid fqdn length")
|
|
143
|
+
|
|
144
|
+
# Remove trailing dot
|
|
145
|
+
if value[-1] == ".":
|
|
146
|
+
value = value[:-1]
|
|
147
|
+
|
|
148
|
+
# Split hostname into list of DNS labels
|
|
149
|
+
labels = value.split(".")
|
|
150
|
+
|
|
151
|
+
# Define pattern of DNS label
|
|
152
|
+
# Can begin and end with a number or letter only
|
|
153
|
+
# Can contain hyphens, a-z, A-Z, 0-9
|
|
154
|
+
# 1 - 63 chars allowed
|
|
155
|
+
fqdn = re.compile(r"^[a-z0-9]([a-z-0-9-]{0,61}[a-z0-9])?$", re.IGNORECASE)
|
|
156
|
+
|
|
157
|
+
# Check that all labels match that pattern.
|
|
158
|
+
if not all(fqdn.match(label) for label in labels):
|
|
159
|
+
raise PydanticCustomError("value_error", "Invalid fqdn")
|
|
160
|
+
return value
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
FQDN = Annotated[str, AfterValidator(validate_fqdn)]
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class DnsCreate(BaseModel):
|
|
167
|
+
ip: IPv4Address
|
|
168
|
+
fqdn: FQDN
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class DnsDelete(BaseModel):
|
|
172
|
+
ip: IPv4Address
|
|
173
|
+
fqdn: FQDN
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class DnsUpdate(BaseModel):
|
|
177
|
+
ip: IPv4Address
|
|
178
|
+
fqdn: FQDN
|
|
179
|
+
new_ip: IPv4Address
|
|
180
|
+
new_fqdn: FQDN
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class DnsView(BaseModel):
|
|
184
|
+
ip: IPv4Address
|
|
185
|
+
fqdn: str
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
hosts_router = APIRouter(prefix="/hosts", tags=["hosts"])
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@hosts_router.get("")
|
|
192
|
+
def get_hosts(
|
|
193
|
+
ip: IPv4Address | None = None,
|
|
194
|
+
fqdn: str | None = None,
|
|
195
|
+
) -> list[DnsView]:
|
|
196
|
+
with get_hosts_file_object() as hosts_fo:
|
|
197
|
+
hosts_lines = get_hosts_lines(hosts_fo)
|
|
198
|
+
hosts_lines = filter_hosts_file(hosts_lines, ip, fqdn)
|
|
199
|
+
return hosts_lines
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@hosts_router.post("")
|
|
203
|
+
def add_hosts(lines_in: list[DnsCreate] | DnsCreate) -> list[DnsView]:
|
|
204
|
+
with get_hosts_file_object() as hosts_fo:
|
|
205
|
+
hosts_lines = get_hosts_lines(hosts_fo)
|
|
206
|
+
for line in make_list(lines_in):
|
|
207
|
+
if filter_hosts_file(hosts_lines, line.ip, line.fqdn):
|
|
208
|
+
raise RecordAlreadyPresent(line)
|
|
209
|
+
hosts_lines.append(ip_fqdn_dict(line))
|
|
210
|
+
write_hosts_file(hosts_fo, hosts_lines)
|
|
211
|
+
dnsmasq_sighup()
|
|
212
|
+
return hosts_lines
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
@hosts_router.put("")
|
|
216
|
+
def update_hosts(lines_in: list[DnsUpdate] | DnsUpdate) -> list[DnsView]:
|
|
217
|
+
with get_hosts_file_object() as hosts_fo:
|
|
218
|
+
hosts_lines = get_hosts_lines(hosts_fo)
|
|
219
|
+
for line in make_list(lines_in):
|
|
220
|
+
key = ip_fqdn_dict(line)
|
|
221
|
+
if key not in hosts_lines:
|
|
222
|
+
raise RecordNotFound(line)
|
|
223
|
+
ix = hosts_lines.index(key)
|
|
224
|
+
hosts_lines[ix] = ip_fqdn_dict(line, "new_ip", "new_fqdn")
|
|
225
|
+
write_hosts_file(hosts_fo, hosts_lines)
|
|
226
|
+
dnsmasq_sighup()
|
|
227
|
+
return hosts_lines
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
@hosts_router.delete("")
|
|
231
|
+
def delete_hosts(lines_in: list[DnsDelete] | DnsDelete) -> list[DnsView]:
|
|
232
|
+
with get_hosts_file_object() as hosts_fo:
|
|
233
|
+
hosts_lines = get_hosts_lines(hosts_fo)
|
|
234
|
+
for line in make_list(lines_in):
|
|
235
|
+
key = ip_fqdn_dict(line)
|
|
236
|
+
if key not in hosts_lines:
|
|
237
|
+
raise RecordNotFound(line)
|
|
238
|
+
hosts_lines.remove(key)
|
|
239
|
+
write_hosts_file(hosts_fo, hosts_lines)
|
|
240
|
+
dnsmasq_sighup()
|
|
241
|
+
return hosts_lines
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
app = FastAPI(title="zPod zBox API", dependencies=[Depends(validate_api_key)])
|
|
245
|
+
app.include_router(hosts_router)
|
|
246
|
+
ZPOD_PASSWORD = get_zpod_password()
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def launch():
|
|
250
|
+
uvicorn.run(
|
|
251
|
+
app,
|
|
252
|
+
host="zbox",
|
|
253
|
+
port=8000,
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
if __name__ == "__main__":
|
|
258
|
+
launch()
|