xms-dns 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.
- xms_dns-0.1.0/.gitignore +10 -0
- xms_dns-0.1.0/LICENSE +20 -0
- xms_dns-0.1.0/PKG-INFO +214 -0
- xms_dns-0.1.0/README.md +178 -0
- xms_dns-0.1.0/pyproject.toml +68 -0
- xms_dns-0.1.0/scripts/smoke_test.py +76 -0
- xms_dns-0.1.0/src/xms_dns/__init__.py +13 -0
- xms_dns-0.1.0/src/xms_dns/_browser.py +30 -0
- xms_dns-0.1.0/src/xms_dns/_http_log.py +48 -0
- xms_dns-0.1.0/src/xms_dns/_parsers.py +159 -0
- xms_dns-0.1.0/src/xms_dns/auth_cache.py +99 -0
- xms_dns-0.1.0/src/xms_dns/cli.py +283 -0
- xms_dns-0.1.0/src/xms_dns/client.py +346 -0
- xms_dns-0.1.0/src/xms_dns/config.py +42 -0
- xms_dns-0.1.0/src/xms_dns/credentials.py +55 -0
- xms_dns-0.1.0/src/xms_dns/exceptions.py +14 -0
- xms_dns-0.1.0/src/xms_dns/paths.py +24 -0
- xms_dns-0.1.0/src/xms_dns/records.py +98 -0
- xms_dns-0.1.0/src/xms_dns/server/__init__.py +1 -0
- xms_dns-0.1.0/src/xms_dns/server/app.py +40 -0
- xms_dns-0.1.0/src/xms_dns/server/checkip.py +80 -0
- xms_dns-0.1.0/src/xms_dns/server/dyndns2.py +123 -0
- xms_dns-0.1.0/src/xms_dns/server/main.py +18 -0
- xms_dns-0.1.0/src/xms_dns/server/session.py +85 -0
- xms_dns-0.1.0/tests/fixtures/dashboard.html +388 -0
- xms_dns-0.1.0/tests/fixtures/login.html +12 -0
- xms_dns-0.1.0/tests/fixtures/zone-gmbros.html +403 -0
- xms_dns-0.1.0/tests/mock_xms.py +154 -0
- xms_dns-0.1.0/tests/test_auth_cache.py +49 -0
- xms_dns-0.1.0/tests/test_auth_cache_client.py +52 -0
- xms_dns-0.1.0/tests/test_browser.py +33 -0
- xms_dns-0.1.0/tests/test_cli.py +99 -0
- xms_dns-0.1.0/tests/test_client.py +147 -0
- xms_dns-0.1.0/tests/test_config.py +45 -0
- xms_dns-0.1.0/tests/test_http_log.py +27 -0
- xms_dns-0.1.0/tests/test_parsers.py +51 -0
- xms_dns-0.1.0/tests/test_server.py +221 -0
- xms_dns-0.1.0/tests/test_server_session.py +77 -0
- xms_dns-0.1.0/tests/test_verbose.py +77 -0
xms_dns-0.1.0/.gitignore
ADDED
xms_dns-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Creative Commons Attribution-NonCommercial 4.0 International
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sergio Garcia <soukron@gmbros.net>
|
|
4
|
+
|
|
5
|
+
This work is licensed under the Creative Commons Attribution-NonCommercial
|
|
6
|
+
4.0 International License.
|
|
7
|
+
|
|
8
|
+
You are free to:
|
|
9
|
+
- Share — copy and redistribute the material in any medium or format
|
|
10
|
+
- Adapt — remix, transform, and build upon the material
|
|
11
|
+
|
|
12
|
+
Under the following terms:
|
|
13
|
+
- Attribution — You must give appropriate credit, provide a link to the
|
|
14
|
+
license, and indicate if changes were made.
|
|
15
|
+
- NonCommercial — You may not use the material for commercial purposes.
|
|
16
|
+
|
|
17
|
+
No additional restrictions — You may not apply legal terms or technological
|
|
18
|
+
measures that legally restrict others from doing anything the license permits.
|
|
19
|
+
|
|
20
|
+
Full license text: https://creativecommons.org/licenses/by-nc/4.0/legalcode
|
xms_dns-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: xms-dns
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Cliente Python para gestionar DNS en el panel XMS (Digital Value). CLI, biblioteca y servidor dyndns2/checkip compatible con ddclient. A, AAAA y TXT.
|
|
5
|
+
Project-URL: Homepage, https://github.com/soukron/xms-dns
|
|
6
|
+
Project-URL: Repository, https://github.com/soukron/xms-dns
|
|
7
|
+
Project-URL: Issues, https://github.com/soukron/xms-dns/issues
|
|
8
|
+
Author-email: Sergio Garcia <soukron@gmbros.net>
|
|
9
|
+
Maintainer-email: Sergio Garcia <soukron@gmbros.net>
|
|
10
|
+
License-Expression: CC-BY-NC-4.0
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: ddclient,digitalvalue,dns,dyndns,xms
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Environment :: Console
|
|
15
|
+
Classifier: Intended Audience :: System Administrators
|
|
16
|
+
Classifier: License :: Free for non-commercial use
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Topic :: Internet :: Name Service (DNS)
|
|
24
|
+
Classifier: Topic :: System :: Networking
|
|
25
|
+
Requires-Python: >=3.9
|
|
26
|
+
Requires-Dist: beautifulsoup4>=4.12
|
|
27
|
+
Requires-Dist: fastapi>=0.100
|
|
28
|
+
Requires-Dist: httpx>=0.27
|
|
29
|
+
Requires-Dist: lxml>=5.0
|
|
30
|
+
Requires-Dist: pyyaml>=6.0
|
|
31
|
+
Requires-Dist: typing-extensions>=4.0; python_version < '3.11'
|
|
32
|
+
Requires-Dist: uvicorn>=0.23
|
|
33
|
+
Provides-Extra: dev
|
|
34
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
35
|
+
Description-Content-Type: text/markdown
|
|
36
|
+
|
|
37
|
+
# xms-dns
|
|
38
|
+
|
|
39
|
+
Cliente Python para gestionar DNS en el panel XMS (Digital Value). Incluye CLI (`xms-dns`), biblioteca y servidor dyndns2/checkip (`xms-dns-server`) compatible con ddclient. Soporta registros A, AAAA y TXT.
|
|
40
|
+
|
|
41
|
+
## Setup
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install xms-dns
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
For development:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
git clone https://github.com/soukron/xms-dns.git
|
|
51
|
+
cd xms-dns
|
|
52
|
+
python3 -m venv .venv
|
|
53
|
+
source .venv/bin/activate
|
|
54
|
+
pip install -e ".[dev]"
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Config file (optional, no secrets)
|
|
58
|
+
|
|
59
|
+
Create `~/.config/xms-dns/config` with non-sensitive settings only:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
mkdir -p ~/.config/xms-dns
|
|
63
|
+
cat > ~/.config/xms-dns/config <<'EOF'
|
|
64
|
+
XMS_BASE_URL=https://xms.digitalvalue.es
|
|
65
|
+
XMS_LOGIN=admin#example.test
|
|
66
|
+
EOF
|
|
67
|
+
chmod 600 ~/.config/xms-dns/config
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Passwords are **not** read from this file. Use `xms-dns login` instead.
|
|
71
|
+
|
|
72
|
+
Environment variables override the config file. Override paths with `XMS_CONFIG_DIR` or `XMS_CACHE_DIR`.
|
|
73
|
+
|
|
74
|
+
### Login
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
xms-dns login
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Resolves settings in this order:
|
|
81
|
+
|
|
82
|
+
1. Environment variables (`XMS_BASE_URL`, `XMS_LOGIN`, `XMS_PASSWORD`)
|
|
83
|
+
2. Config file (`XMS_BASE_URL`, `XMS_LOGIN` only)
|
|
84
|
+
3. Interactive prompts for anything still missing (password always prompted unless in env)
|
|
85
|
+
|
|
86
|
+
Connection type is always `siempre` (permanent session). On success, the session cookie is saved to `~/.cache/xms-dns/auth.cache`.
|
|
87
|
+
|
|
88
|
+
### HTTP calls
|
|
89
|
+
|
|
90
|
+
Login and operations are separate executions:
|
|
91
|
+
|
|
92
|
+
- **`xms-dns login`** — authenticates against the panel and saves the session (its own HTTP calls; not counted against other commands).
|
|
93
|
+
- **Any other command** — exactly **one** HTTP request to the panel, using the cached session. Credentials are never sent again.
|
|
94
|
+
|
|
95
|
+
Example: `xms-dns domains` is a single `GET` to `Operacion=Dominios`. `xms-dns list` is a single `GET` to load the zone.
|
|
96
|
+
|
|
97
|
+
The only exception is **`upsert` when the value changes**: XMS has no inline edit, so the client deletes and recreates the record (two or three requests).
|
|
98
|
+
|
|
99
|
+
## CLI
|
|
100
|
+
|
|
101
|
+
Global flags:
|
|
102
|
+
|
|
103
|
+
- `-o` / `--output` — output format: `table` (default), `csv`, `json`, or `yaml`
|
|
104
|
+
- `-v` / `--verbose` — log HTTP requests to stderr
|
|
105
|
+
- `--debug` — log requests and response bodies to stderr (includes verbose)
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
xms-dns login
|
|
109
|
+
xms-dns list gmbros.net
|
|
110
|
+
xms-dns -o csv list gmbros.net
|
|
111
|
+
xms-dns -o json domains
|
|
112
|
+
xms-dns -v list gmbros.net
|
|
113
|
+
xms-dns --debug upsert gmbros.net dyndns-test A 203.0.113.10
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
DNS commands use the cached session only. If not logged in or session expired:
|
|
117
|
+
|
|
118
|
+
```
|
|
119
|
+
error: Not logged in. Run: xms-dns login
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Server (dyndns2 + checkip)
|
|
123
|
+
|
|
124
|
+
Start the dynamic DNS server:
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
xms-dns-server
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Environment variables:
|
|
131
|
+
|
|
132
|
+
| Variable | Default | Purpose |
|
|
133
|
+
|----------|---------|---------|
|
|
134
|
+
| `XMS_BASE_URL` | `https://xms.digitalvalue.es` | XMS panel URL |
|
|
135
|
+
| `XMS_SERVER_HOST` | `127.0.0.1` | Bind address |
|
|
136
|
+
| `XMS_SERVER_PORT` | `8080` | Bind port |
|
|
137
|
+
| `XMS_TRUST_PROXY` | unset | Set to `1` to trust `X-Forwarded-For` for client IP |
|
|
138
|
+
| `XMS_CACHE_DIR` | `~/.cache/xms-dns` | Session cache directory |
|
|
139
|
+
|
|
140
|
+
> **Reverse proxy (production):** if `xms-dns-server` runs behind nginx, Caddy, Traefik, etc., set **`XMS_TRUST_PROXY=1`**. Otherwise `/checkip` and updates without `myip` see the proxy IP (`127.0.0.1`) instead of the client. Only enable this when a trusted proxy overwrites `X-Forwarded-For` — do not expose the server directly to the internet with this flag on.
|
|
141
|
+
|
|
142
|
+
### Authentication
|
|
143
|
+
|
|
144
|
+
The server does **not** use `xms-dns login`. Each dyndns2 client sends XMS credentials via HTTP Basic auth:
|
|
145
|
+
|
|
146
|
+
- `username` → XMS login (e.g. `admin#example.test`)
|
|
147
|
+
- `password` → XMS password
|
|
148
|
+
|
|
149
|
+
The session cookie is cached per user in `~/.cache/xms-dns/server/{sha256}.cache` (separate from the CLI `auth.cache`). If the cached session expires, the server re-authenticates with the credentials from the current request.
|
|
150
|
+
|
|
151
|
+
### Endpoints
|
|
152
|
+
|
|
153
|
+
- `GET /nic/update?hostname=home.example.test&myip=...` — dyndns2 update. Returns `good {value}`, `nochg {value}`, `badauth`, etc. Always HTTP 200.
|
|
154
|
+
- Record types: **A** (default), **AAAA**, **TXT**.
|
|
155
|
+
- A / AAAA: pass the address in `myip`, or omit it to use the client IP (`X-Forwarded-For` when `XMS_TRUST_PROXY=1`).
|
|
156
|
+
- AAAA: set `type=AAAA`, or pass an IPv6 address in `myip` (auto-detected).
|
|
157
|
+
- TXT: set `type=TXT` and pass the record data in `txt=` (or `value=`).
|
|
158
|
+
- `GET /checkip` — returns the caller's public IPv4. Optional `?format=pfsense` for legacy pfSense.
|
|
159
|
+
|
|
160
|
+
If `myip` is omitted, the client IP is used (see `X-Forwarded-For` when `XMS_TRUST_PROXY=1`).
|
|
161
|
+
|
|
162
|
+
### ddclient example
|
|
163
|
+
|
|
164
|
+
```
|
|
165
|
+
protocol=dyndns2
|
|
166
|
+
server=dyndns.example.test
|
|
167
|
+
login=admin#example.test
|
|
168
|
+
password=your-xms-password
|
|
169
|
+
home.example.test
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Put TLS termination on a reverse proxy in production; bind locally by default. Remember **`XMS_TRUST_PROXY=1`** on the server process (see above).
|
|
173
|
+
|
|
174
|
+
## Library
|
|
175
|
+
|
|
176
|
+
```python
|
|
177
|
+
from xms_dns import XmsClient
|
|
178
|
+
|
|
179
|
+
# Login once (CLI does this via `xms-dns login`)
|
|
180
|
+
with XmsClient(base_url="...", login="...", password="...") as client:
|
|
181
|
+
client.authenticate()
|
|
182
|
+
|
|
183
|
+
# Later operations reuse auth.cache
|
|
184
|
+
with XmsClient.from_env() as client:
|
|
185
|
+
client.ensure_session()
|
|
186
|
+
client.upsert_record("gmbros.net", "home", "A", "203.0.113.10")
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
## Tests
|
|
190
|
+
|
|
191
|
+
All default tests use mocked HTTP responses and **do not** contact the live XMS panel:
|
|
192
|
+
|
|
193
|
+
```bash
|
|
194
|
+
pytest
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
## Live smoke test
|
|
198
|
+
|
|
199
|
+
```bash
|
|
200
|
+
xms-dns login # or set XMS_PASSWORD for one-shot login inside the script
|
|
201
|
+
XMS_LIVE=1 python scripts/smoke_test.py
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
## Security
|
|
205
|
+
|
|
206
|
+
- Do not store passwords in the config file
|
|
207
|
+
- Keep `~/.cache/xms-dns/auth.cache` private (`chmod 600`)
|
|
208
|
+
- Keep server session caches private too: `~/.cache/xms-dns/server/*.cache` (`chmod 600`)
|
|
209
|
+
|
|
210
|
+
## License
|
|
211
|
+
|
|
212
|
+
Copyright (c) 2026 Sergio Garcia.
|
|
213
|
+
|
|
214
|
+
Licensed under [CC BY-NC 4.0](https://creativecommons.org/licenses/by-nc/4.0/): non-commercial use with attribution required.
|
xms_dns-0.1.0/README.md
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# xms-dns
|
|
2
|
+
|
|
3
|
+
Cliente Python para gestionar DNS en el panel XMS (Digital Value). Incluye CLI (`xms-dns`), biblioteca y servidor dyndns2/checkip (`xms-dns-server`) compatible con ddclient. Soporta registros A, AAAA y TXT.
|
|
4
|
+
|
|
5
|
+
## Setup
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install xms-dns
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
For development:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
git clone https://github.com/soukron/xms-dns.git
|
|
15
|
+
cd xms-dns
|
|
16
|
+
python3 -m venv .venv
|
|
17
|
+
source .venv/bin/activate
|
|
18
|
+
pip install -e ".[dev]"
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
### Config file (optional, no secrets)
|
|
22
|
+
|
|
23
|
+
Create `~/.config/xms-dns/config` with non-sensitive settings only:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
mkdir -p ~/.config/xms-dns
|
|
27
|
+
cat > ~/.config/xms-dns/config <<'EOF'
|
|
28
|
+
XMS_BASE_URL=https://xms.digitalvalue.es
|
|
29
|
+
XMS_LOGIN=admin#example.test
|
|
30
|
+
EOF
|
|
31
|
+
chmod 600 ~/.config/xms-dns/config
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Passwords are **not** read from this file. Use `xms-dns login` instead.
|
|
35
|
+
|
|
36
|
+
Environment variables override the config file. Override paths with `XMS_CONFIG_DIR` or `XMS_CACHE_DIR`.
|
|
37
|
+
|
|
38
|
+
### Login
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
xms-dns login
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Resolves settings in this order:
|
|
45
|
+
|
|
46
|
+
1. Environment variables (`XMS_BASE_URL`, `XMS_LOGIN`, `XMS_PASSWORD`)
|
|
47
|
+
2. Config file (`XMS_BASE_URL`, `XMS_LOGIN` only)
|
|
48
|
+
3. Interactive prompts for anything still missing (password always prompted unless in env)
|
|
49
|
+
|
|
50
|
+
Connection type is always `siempre` (permanent session). On success, the session cookie is saved to `~/.cache/xms-dns/auth.cache`.
|
|
51
|
+
|
|
52
|
+
### HTTP calls
|
|
53
|
+
|
|
54
|
+
Login and operations are separate executions:
|
|
55
|
+
|
|
56
|
+
- **`xms-dns login`** — authenticates against the panel and saves the session (its own HTTP calls; not counted against other commands).
|
|
57
|
+
- **Any other command** — exactly **one** HTTP request to the panel, using the cached session. Credentials are never sent again.
|
|
58
|
+
|
|
59
|
+
Example: `xms-dns domains` is a single `GET` to `Operacion=Dominios`. `xms-dns list` is a single `GET` to load the zone.
|
|
60
|
+
|
|
61
|
+
The only exception is **`upsert` when the value changes**: XMS has no inline edit, so the client deletes and recreates the record (two or three requests).
|
|
62
|
+
|
|
63
|
+
## CLI
|
|
64
|
+
|
|
65
|
+
Global flags:
|
|
66
|
+
|
|
67
|
+
- `-o` / `--output` — output format: `table` (default), `csv`, `json`, or `yaml`
|
|
68
|
+
- `-v` / `--verbose` — log HTTP requests to stderr
|
|
69
|
+
- `--debug` — log requests and response bodies to stderr (includes verbose)
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
xms-dns login
|
|
73
|
+
xms-dns list gmbros.net
|
|
74
|
+
xms-dns -o csv list gmbros.net
|
|
75
|
+
xms-dns -o json domains
|
|
76
|
+
xms-dns -v list gmbros.net
|
|
77
|
+
xms-dns --debug upsert gmbros.net dyndns-test A 203.0.113.10
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
DNS commands use the cached session only. If not logged in or session expired:
|
|
81
|
+
|
|
82
|
+
```
|
|
83
|
+
error: Not logged in. Run: xms-dns login
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Server (dyndns2 + checkip)
|
|
87
|
+
|
|
88
|
+
Start the dynamic DNS server:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
xms-dns-server
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Environment variables:
|
|
95
|
+
|
|
96
|
+
| Variable | Default | Purpose |
|
|
97
|
+
|----------|---------|---------|
|
|
98
|
+
| `XMS_BASE_URL` | `https://xms.digitalvalue.es` | XMS panel URL |
|
|
99
|
+
| `XMS_SERVER_HOST` | `127.0.0.1` | Bind address |
|
|
100
|
+
| `XMS_SERVER_PORT` | `8080` | Bind port |
|
|
101
|
+
| `XMS_TRUST_PROXY` | unset | Set to `1` to trust `X-Forwarded-For` for client IP |
|
|
102
|
+
| `XMS_CACHE_DIR` | `~/.cache/xms-dns` | Session cache directory |
|
|
103
|
+
|
|
104
|
+
> **Reverse proxy (production):** if `xms-dns-server` runs behind nginx, Caddy, Traefik, etc., set **`XMS_TRUST_PROXY=1`**. Otherwise `/checkip` and updates without `myip` see the proxy IP (`127.0.0.1`) instead of the client. Only enable this when a trusted proxy overwrites `X-Forwarded-For` — do not expose the server directly to the internet with this flag on.
|
|
105
|
+
|
|
106
|
+
### Authentication
|
|
107
|
+
|
|
108
|
+
The server does **not** use `xms-dns login`. Each dyndns2 client sends XMS credentials via HTTP Basic auth:
|
|
109
|
+
|
|
110
|
+
- `username` → XMS login (e.g. `admin#example.test`)
|
|
111
|
+
- `password` → XMS password
|
|
112
|
+
|
|
113
|
+
The session cookie is cached per user in `~/.cache/xms-dns/server/{sha256}.cache` (separate from the CLI `auth.cache`). If the cached session expires, the server re-authenticates with the credentials from the current request.
|
|
114
|
+
|
|
115
|
+
### Endpoints
|
|
116
|
+
|
|
117
|
+
- `GET /nic/update?hostname=home.example.test&myip=...` — dyndns2 update. Returns `good {value}`, `nochg {value}`, `badauth`, etc. Always HTTP 200.
|
|
118
|
+
- Record types: **A** (default), **AAAA**, **TXT**.
|
|
119
|
+
- A / AAAA: pass the address in `myip`, or omit it to use the client IP (`X-Forwarded-For` when `XMS_TRUST_PROXY=1`).
|
|
120
|
+
- AAAA: set `type=AAAA`, or pass an IPv6 address in `myip` (auto-detected).
|
|
121
|
+
- TXT: set `type=TXT` and pass the record data in `txt=` (or `value=`).
|
|
122
|
+
- `GET /checkip` — returns the caller's public IPv4. Optional `?format=pfsense` for legacy pfSense.
|
|
123
|
+
|
|
124
|
+
If `myip` is omitted, the client IP is used (see `X-Forwarded-For` when `XMS_TRUST_PROXY=1`).
|
|
125
|
+
|
|
126
|
+
### ddclient example
|
|
127
|
+
|
|
128
|
+
```
|
|
129
|
+
protocol=dyndns2
|
|
130
|
+
server=dyndns.example.test
|
|
131
|
+
login=admin#example.test
|
|
132
|
+
password=your-xms-password
|
|
133
|
+
home.example.test
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Put TLS termination on a reverse proxy in production; bind locally by default. Remember **`XMS_TRUST_PROXY=1`** on the server process (see above).
|
|
137
|
+
|
|
138
|
+
## Library
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
from xms_dns import XmsClient
|
|
142
|
+
|
|
143
|
+
# Login once (CLI does this via `xms-dns login`)
|
|
144
|
+
with XmsClient(base_url="...", login="...", password="...") as client:
|
|
145
|
+
client.authenticate()
|
|
146
|
+
|
|
147
|
+
# Later operations reuse auth.cache
|
|
148
|
+
with XmsClient.from_env() as client:
|
|
149
|
+
client.ensure_session()
|
|
150
|
+
client.upsert_record("gmbros.net", "home", "A", "203.0.113.10")
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
## Tests
|
|
154
|
+
|
|
155
|
+
All default tests use mocked HTTP responses and **do not** contact the live XMS panel:
|
|
156
|
+
|
|
157
|
+
```bash
|
|
158
|
+
pytest
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
## Live smoke test
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
xms-dns login # or set XMS_PASSWORD for one-shot login inside the script
|
|
165
|
+
XMS_LIVE=1 python scripts/smoke_test.py
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## Security
|
|
169
|
+
|
|
170
|
+
- Do not store passwords in the config file
|
|
171
|
+
- Keep `~/.cache/xms-dns/auth.cache` private (`chmod 600`)
|
|
172
|
+
- Keep server session caches private too: `~/.cache/xms-dns/server/*.cache` (`chmod 600`)
|
|
173
|
+
|
|
174
|
+
## License
|
|
175
|
+
|
|
176
|
+
Copyright (c) 2026 Sergio Garcia.
|
|
177
|
+
|
|
178
|
+
Licensed under [CC BY-NC 4.0](https://creativecommons.org/licenses/by-nc/4.0/): non-commercial use with attribution required.
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "xms-dns"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Cliente Python para gestionar DNS en el panel XMS (Digital Value). CLI, biblioteca y servidor dyndns2/checkip compatible con ddclient. A, AAAA y TXT."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "CC-BY-NC-4.0"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.9"
|
|
13
|
+
authors = [
|
|
14
|
+
{ name = "Sergio Garcia", email = "soukron@gmbros.net" },
|
|
15
|
+
]
|
|
16
|
+
maintainers = [
|
|
17
|
+
{ name = "Sergio Garcia", email = "soukron@gmbros.net" },
|
|
18
|
+
]
|
|
19
|
+
keywords = ["dns", "dyndns", "xms", "digitalvalue", "ddclient"]
|
|
20
|
+
classifiers = [
|
|
21
|
+
"Development Status :: 4 - Beta",
|
|
22
|
+
"Environment :: Console",
|
|
23
|
+
"Intended Audience :: System Administrators",
|
|
24
|
+
"License :: Free for non-commercial use",
|
|
25
|
+
"Operating System :: OS Independent",
|
|
26
|
+
"Programming Language :: Python :: 3",
|
|
27
|
+
"Programming Language :: Python :: 3.9",
|
|
28
|
+
"Programming Language :: Python :: 3.10",
|
|
29
|
+
"Programming Language :: Python :: 3.11",
|
|
30
|
+
"Programming Language :: Python :: 3.12",
|
|
31
|
+
"Topic :: Internet :: Name Service (DNS)",
|
|
32
|
+
"Topic :: System :: Networking",
|
|
33
|
+
]
|
|
34
|
+
dependencies = [
|
|
35
|
+
"httpx>=0.27",
|
|
36
|
+
"beautifulsoup4>=4.12",
|
|
37
|
+
"lxml>=5.0",
|
|
38
|
+
"PyYAML>=6.0",
|
|
39
|
+
"fastapi>=0.100",
|
|
40
|
+
"uvicorn>=0.23",
|
|
41
|
+
"typing_extensions>=4.0; python_version < '3.11'",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
[project.urls]
|
|
45
|
+
Homepage = "https://github.com/soukron/xms-dns"
|
|
46
|
+
Repository = "https://github.com/soukron/xms-dns"
|
|
47
|
+
Issues = "https://github.com/soukron/xms-dns/issues"
|
|
48
|
+
|
|
49
|
+
[project.optional-dependencies]
|
|
50
|
+
dev = [
|
|
51
|
+
"pytest>=8.0",
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
[project.scripts]
|
|
55
|
+
xms-dns = "xms_dns.cli:main"
|
|
56
|
+
xms-dns-server = "xms_dns.server.main:main"
|
|
57
|
+
|
|
58
|
+
[tool.hatch.build.targets.wheel]
|
|
59
|
+
only-include = ["src/xms_dns"]
|
|
60
|
+
|
|
61
|
+
[tool.hatch.build.targets.wheel.force-include]
|
|
62
|
+
"src/xms_dns" = "xms_dns"
|
|
63
|
+
|
|
64
|
+
[tool.pytest.ini_options]
|
|
65
|
+
testpaths = ["tests"]
|
|
66
|
+
markers = [
|
|
67
|
+
"live: hits the real XMS panel (requires XMS_LIVE=1)",
|
|
68
|
+
]
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Manual smoke test against the live XMS panel.
|
|
3
|
+
|
|
4
|
+
Requires explicit opt-in to avoid accidental hits to the real panel:
|
|
5
|
+
XMS_LIVE=1 python scripts/smoke_test.py
|
|
6
|
+
|
|
7
|
+
Run `xms-dns login` first, or set XMS_PASSWORD for this script to log in.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
from xms_dns import XmsClient
|
|
16
|
+
from xms_dns.config import load_config
|
|
17
|
+
from xms_dns.credentials import resolve_login_credentials
|
|
18
|
+
|
|
19
|
+
DOMAIN = os.environ.get("XMS_SMOKE_DOMAIN", "gmbros.net")
|
|
20
|
+
HOST = os.environ.get("XMS_SMOKE_HOST", "dyndns-test")
|
|
21
|
+
IP1 = os.environ.get("XMS_SMOKE_IP1", "1.2.3.4")
|
|
22
|
+
IP2 = os.environ.get("XMS_SMOKE_IP2", "5.6.7.8")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def main() -> int:
|
|
26
|
+
load_config()
|
|
27
|
+
|
|
28
|
+
if os.environ.get("XMS_LIVE") != "1":
|
|
29
|
+
print(
|
|
30
|
+
"Refusing to contact the live XMS panel.\n"
|
|
31
|
+
"Run with: XMS_LIVE=1 python scripts/smoke_test.py",
|
|
32
|
+
file=sys.stderr,
|
|
33
|
+
)
|
|
34
|
+
return 2
|
|
35
|
+
|
|
36
|
+
print(f"Smoke test on {HOST}.{DOMAIN}")
|
|
37
|
+
|
|
38
|
+
with XmsClient.from_env() as client:
|
|
39
|
+
try:
|
|
40
|
+
client.ensure_session()
|
|
41
|
+
except Exception:
|
|
42
|
+
base_url, login, password = resolve_login_credentials()
|
|
43
|
+
client.base_url = base_url.rstrip("/")
|
|
44
|
+
client.login = login
|
|
45
|
+
client.password = password
|
|
46
|
+
client._client.base_url = base_url.rstrip("/")
|
|
47
|
+
client.authenticate()
|
|
48
|
+
|
|
49
|
+
domains = client.list_domains()
|
|
50
|
+
print(f"domains ({len(domains)}): {', '.join(item.domain for item in domains[:5])}...")
|
|
51
|
+
assert any(item.domain == DOMAIN for item in domains), f"{DOMAIN} not in account"
|
|
52
|
+
|
|
53
|
+
print(f"upsert {HOST} A -> {IP1}")
|
|
54
|
+
record = client.upsert_record(DOMAIN, HOST, "A", IP1)
|
|
55
|
+
print(f" -> {record.host} {record.type} {record.value} ({record.reg_id})")
|
|
56
|
+
|
|
57
|
+
print(f"upsert {HOST} A -> {IP2} (delete+create)")
|
|
58
|
+
record = client.upsert_record(DOMAIN, HOST, "A", IP2)
|
|
59
|
+
print(f" -> {record.host} {record.type} {record.value} ({record.reg_id})")
|
|
60
|
+
|
|
61
|
+
print(f"upsert {HOST} A -> {IP2} (no-op)")
|
|
62
|
+
record = client.upsert_record(DOMAIN, HOST, "A", IP2)
|
|
63
|
+
assert record.value == IP2
|
|
64
|
+
|
|
65
|
+
print(f"delete {HOST} A")
|
|
66
|
+
client.delete_record(DOMAIN, HOST, "A")
|
|
67
|
+
print("done")
|
|
68
|
+
return 0
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
if __name__ == "__main__":
|
|
72
|
+
try:
|
|
73
|
+
raise SystemExit(main())
|
|
74
|
+
except Exception as exc:
|
|
75
|
+
print(f"smoke test failed: {exc}", file=sys.stderr)
|
|
76
|
+
raise SystemExit(1) from exc
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from xms_dns.client import XmsClient
|
|
2
|
+
from xms_dns.exceptions import AuthError, OperationError, RecordNotFoundError, XmsError
|
|
3
|
+
from xms_dns.records import DnsRecord, DomainInfo
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"AuthError",
|
|
7
|
+
"DnsRecord",
|
|
8
|
+
"DomainInfo",
|
|
9
|
+
"OperationError",
|
|
10
|
+
"RecordNotFoundError",
|
|
11
|
+
"XmsClient",
|
|
12
|
+
"XmsError",
|
|
13
|
+
]
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
# Captured from real XMS panel usage (Chrome on macOS).
|
|
4
|
+
BROWSER_USER_AGENT = (
|
|
5
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
|
6
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
7
|
+
"Chrome/149.0.0.0 Safari/537.36"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def browser_headers(base_url: str) -> dict[str, str]:
|
|
12
|
+
return {
|
|
13
|
+
"User-Agent": BROWSER_USER_AGENT,
|
|
14
|
+
"Accept": (
|
|
15
|
+
"text/html,application/xhtml+xml,application/xml;q=0.9,"
|
|
16
|
+
"image/avif,image/webp,image/apng,*/*;q=0.8"
|
|
17
|
+
),
|
|
18
|
+
"Accept-Language": "en-US,en;q=0.7",
|
|
19
|
+
"Accept-Encoding": "gzip, deflate",
|
|
20
|
+
# httpx needs this for the old XMS Apache; browsers use keep-alive.
|
|
21
|
+
"Connection": "close",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def form_post_headers(base_url: str) -> dict[str, str]:
|
|
26
|
+
return {
|
|
27
|
+
"Origin": base_url,
|
|
28
|
+
"Referer": f"{base_url}/cgi-bin/xms3.cgi",
|
|
29
|
+
"Cache-Control": "max-age=0",
|
|
30
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from typing import TextIO
|
|
5
|
+
from urllib.parse import parse_qsl, urlencode
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
SENSITIVE_KEYS = {"passwd", "password", "token", "secret"}
|
|
10
|
+
REDACTED = "***"
|
|
11
|
+
MAX_DEBUG_BODY = 4000
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _redact_form_body(body: str) -> str:
|
|
15
|
+
pairs = parse_qsl(body, keep_blank_values=True)
|
|
16
|
+
redacted = [
|
|
17
|
+
(key, REDACTED if key.lower() in SENSITIVE_KEYS else value)
|
|
18
|
+
for key, value in pairs
|
|
19
|
+
]
|
|
20
|
+
return urlencode(redacted)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def format_request(request: httpx.Request) -> str:
|
|
24
|
+
lines = [f">>> {request.method} {request.url}"]
|
|
25
|
+
content_type = request.headers.get("content-type", "")
|
|
26
|
+
if request.content:
|
|
27
|
+
body = request.content.decode("utf-8", errors="replace")
|
|
28
|
+
if "application/x-www-form-urlencoded" in content_type:
|
|
29
|
+
body = _redact_form_body(body)
|
|
30
|
+
lines.append(f">>> body: {body}")
|
|
31
|
+
cookie = request.headers.get("cookie")
|
|
32
|
+
if cookie:
|
|
33
|
+
lines.append(f">>> cookie: {cookie}")
|
|
34
|
+
return "\n".join(lines)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def format_response(response: httpx.Response) -> str:
|
|
38
|
+
lines = [f"<<< {response.status_code} {response.request.method} {response.request.url}"]
|
|
39
|
+
body = response.text
|
|
40
|
+
if len(body) > MAX_DEBUG_BODY:
|
|
41
|
+
body = body[:MAX_DEBUG_BODY] + f"\n... ({len(response.text)} bytes total)"
|
|
42
|
+
lines.append(f"<<< body:\n{body}")
|
|
43
|
+
return "\n".join(lines)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def emit_http_log(message: str, stream: TextIO | None = None) -> None:
|
|
47
|
+
target = stream or sys.stderr
|
|
48
|
+
print(message, file=target)
|