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.
Files changed (39) hide show
  1. xms_dns-0.1.0/.gitignore +10 -0
  2. xms_dns-0.1.0/LICENSE +20 -0
  3. xms_dns-0.1.0/PKG-INFO +214 -0
  4. xms_dns-0.1.0/README.md +178 -0
  5. xms_dns-0.1.0/pyproject.toml +68 -0
  6. xms_dns-0.1.0/scripts/smoke_test.py +76 -0
  7. xms_dns-0.1.0/src/xms_dns/__init__.py +13 -0
  8. xms_dns-0.1.0/src/xms_dns/_browser.py +30 -0
  9. xms_dns-0.1.0/src/xms_dns/_http_log.py +48 -0
  10. xms_dns-0.1.0/src/xms_dns/_parsers.py +159 -0
  11. xms_dns-0.1.0/src/xms_dns/auth_cache.py +99 -0
  12. xms_dns-0.1.0/src/xms_dns/cli.py +283 -0
  13. xms_dns-0.1.0/src/xms_dns/client.py +346 -0
  14. xms_dns-0.1.0/src/xms_dns/config.py +42 -0
  15. xms_dns-0.1.0/src/xms_dns/credentials.py +55 -0
  16. xms_dns-0.1.0/src/xms_dns/exceptions.py +14 -0
  17. xms_dns-0.1.0/src/xms_dns/paths.py +24 -0
  18. xms_dns-0.1.0/src/xms_dns/records.py +98 -0
  19. xms_dns-0.1.0/src/xms_dns/server/__init__.py +1 -0
  20. xms_dns-0.1.0/src/xms_dns/server/app.py +40 -0
  21. xms_dns-0.1.0/src/xms_dns/server/checkip.py +80 -0
  22. xms_dns-0.1.0/src/xms_dns/server/dyndns2.py +123 -0
  23. xms_dns-0.1.0/src/xms_dns/server/main.py +18 -0
  24. xms_dns-0.1.0/src/xms_dns/server/session.py +85 -0
  25. xms_dns-0.1.0/tests/fixtures/dashboard.html +388 -0
  26. xms_dns-0.1.0/tests/fixtures/login.html +12 -0
  27. xms_dns-0.1.0/tests/fixtures/zone-gmbros.html +403 -0
  28. xms_dns-0.1.0/tests/mock_xms.py +154 -0
  29. xms_dns-0.1.0/tests/test_auth_cache.py +49 -0
  30. xms_dns-0.1.0/tests/test_auth_cache_client.py +52 -0
  31. xms_dns-0.1.0/tests/test_browser.py +33 -0
  32. xms_dns-0.1.0/tests/test_cli.py +99 -0
  33. xms_dns-0.1.0/tests/test_client.py +147 -0
  34. xms_dns-0.1.0/tests/test_config.py +45 -0
  35. xms_dns-0.1.0/tests/test_http_log.py +27 -0
  36. xms_dns-0.1.0/tests/test_parsers.py +51 -0
  37. xms_dns-0.1.0/tests/test_server.py +221 -0
  38. xms_dns-0.1.0/tests/test_server_session.py +77 -0
  39. xms_dns-0.1.0/tests/test_verbose.py +77 -0
@@ -0,0 +1,10 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ venv/
5
+ .env
6
+ *.egg-info/
7
+ dist/
8
+ build/
9
+ .pytest_cache/
10
+ .mypy_cache/
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.
@@ -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)