nspack 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.
- nspack-0.1.0/PKG-INFO +215 -0
- nspack-0.1.0/README.md +197 -0
- nspack-0.1.0/pyproject.toml +52 -0
- nspack-0.1.0/src/gcnspack/__init__.py +16 -0
- nspack-0.1.0/src/gcnspack/cli.py +63 -0
- nspack-0.1.0/src/gcnspack/filedns.py +166 -0
- nspack-0.1.0/src/gcnspack/py.typed +0 -0
- nspack-0.1.0/src/gcnspack/resolver.py +73 -0
nspack-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nspack
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Simple DNS resolver and file-over-DNS transfer toolkit
|
|
5
|
+
Keywords: dns,resolver,file-transfer,txt-records
|
|
6
|
+
Author: geekennedy
|
|
7
|
+
Author-email: geekennedy <gkennedy@gcloudns.net>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
13
|
+
Classifier: Topic :: Internet :: Name Service (DNS)
|
|
14
|
+
Requires-Dist: dnspython>=2.7.0
|
|
15
|
+
Requires-Dist: click>=8.1.0
|
|
16
|
+
Requires-Python: >=3.13
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# gcnspack
|
|
20
|
+
|
|
21
|
+
[](https://pypi.org/project/gcnspack/)
|
|
22
|
+
[](https://python.org)
|
|
23
|
+
[](https://opensource.org/licenses/MIT)
|
|
24
|
+
|
|
25
|
+
Simple DNS resolver and file-over-DNS transfer toolkit.
|
|
26
|
+
|
|
27
|
+

|
|
28
|
+
|
|
29
|
+
## Features
|
|
30
|
+
|
|
31
|
+
- **DNS Resolver** - Simple, batteries-included DNS resolution with sensible defaults (Cloudflare, Google, Quad9 fallbacks)
|
|
32
|
+
- **File-over-DNS** - Encode arbitrary files into DNS TXT records and reconstruct them from DNS queries
|
|
33
|
+
- **CLI** - Command-line interface for resolving DNS and managing file transfers
|
|
34
|
+
- **Protocol** - Chunked base32/base64 encoding scheme with SHA-256 integrity verification
|
|
35
|
+
|
|
36
|
+
## Installation
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install gcnspack
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Or with [uv](https://docs.astral.sh/uv/):
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
uv add gcnspack
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Quick Start
|
|
49
|
+
|
|
50
|
+
### DNS Resolution
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from gcnspack import resolve, resolve_all, create_resolver
|
|
54
|
+
|
|
55
|
+
# Simple A record lookup
|
|
56
|
+
ips = resolve("example.com")
|
|
57
|
+
# ['93.184.216.34']
|
|
58
|
+
|
|
59
|
+
# Query specific record types
|
|
60
|
+
mx = resolve("example.com", "MX")
|
|
61
|
+
txt = resolve("example.com", "TXT")
|
|
62
|
+
|
|
63
|
+
# Query multiple record types at once
|
|
64
|
+
all_records = resolve_all("example.com")
|
|
65
|
+
# {'A': [...], 'AAAA': [...], 'MX': [...], 'TXT': [...], 'NS': [...], 'CNAME': [...]}
|
|
66
|
+
|
|
67
|
+
# Use a custom resolver
|
|
68
|
+
r = create_resolver(nameservers=["1.1.1.1"], timeout=10.0)
|
|
69
|
+
result = resolve("example.com", "A", resolver=r)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### File-over-DNS Transfer
|
|
73
|
+
|
|
74
|
+
#### Encoding a File
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
from gcnspack import encode_file, generate_zone_records
|
|
78
|
+
|
|
79
|
+
# Read a file and generate DNS zone records
|
|
80
|
+
with open("payload.bin", "rb") as f:
|
|
81
|
+
data = f.read()
|
|
82
|
+
|
|
83
|
+
# Generate zone-file TXT records
|
|
84
|
+
records = generate_zone_records(data, "files.example.com")
|
|
85
|
+
for record in records:
|
|
86
|
+
print(record)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Output:
|
|
90
|
+
|
|
91
|
+
```
|
|
92
|
+
0.files.example.com IN TXT "GAYTEMZUGU3DOOB..."
|
|
93
|
+
1.files.example.com IN TXT "GAYTEMZUGU3DOOC..."
|
|
94
|
+
_meta.files.example.com IN TXT "ON2HE2LOM4QHO..."
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
#### Fetching a File from DNS
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
from gcnspack import save_txt_file
|
|
101
|
+
|
|
102
|
+
# Pull TXT records, decode, verify hash, and save
|
|
103
|
+
save_txt_file("files.example.com", "output.bin")
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Protocol
|
|
107
|
+
|
|
108
|
+
Files are transferred over DNS using this encoding scheme:
|
|
109
|
+
|
|
110
|
+
```
|
|
111
|
+
+-------------------+
|
|
112
|
+
| Original File |
|
|
113
|
+
+-------------------+
|
|
114
|
+
|
|
|
115
|
+
Split into chunks
|
|
116
|
+
(150 bytes each)
|
|
117
|
+
|
|
|
118
|
+
+-------+-------+
|
|
119
|
+
| | |
|
|
120
|
+
v v v
|
|
121
|
+
Chunk 0 Chunk 1 Chunk N
|
|
122
|
+
| | |
|
|
123
|
+
Base64 encode each chunk
|
|
124
|
+
| | |
|
|
125
|
+
Add header: "idx:total:base64data"
|
|
126
|
+
| | |
|
|
127
|
+
Base32 encode entire string
|
|
128
|
+
| | |
|
|
129
|
+
v v v
|
|
130
|
+
+-------------------------------------------+
|
|
131
|
+
| DNS TXT Records |
|
|
132
|
+
| 0.sub.example.com TXT "<base32>" |
|
|
133
|
+
| 1.sub.example.com TXT "<base32>" |
|
|
134
|
+
| N.sub.example.com TXT "<base32>" |
|
|
135
|
+
| _meta.sub.example.com TXT "<meta_b32>" |
|
|
136
|
+
+-------------------------------------------+
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
**Metadata record** (`_meta.<subdomain>`): Contains `sha256:<hash>:chunks:<count>`, base32-encoded.
|
|
140
|
+
|
|
141
|
+
**Chunk records** (`<n>.<subdomain>`): Each contains `<index>:<total>:<base64_data>`, base32-encoded.
|
|
142
|
+
|
|
143
|
+
Reconstruction:
|
|
144
|
+
1. Fetch `_meta.<subdomain>` to get chunk count and expected SHA-256 hash
|
|
145
|
+
2. Fetch chunks `0` through `N-1` from `<n>.<subdomain>`
|
|
146
|
+
3. Decode each chunk (base32 -> parse header -> base64 decode payload)
|
|
147
|
+
4. Sort by index, concatenate
|
|
148
|
+
5. Verify SHA-256 hash matches
|
|
149
|
+
|
|
150
|
+
## CLI Usage
|
|
151
|
+
|
|
152
|
+
### Resolve DNS Records
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
# A record (default)
|
|
156
|
+
gcnspack resolve example.com
|
|
157
|
+
|
|
158
|
+
# Specific record type
|
|
159
|
+
gcnspack resolve example.com -t MX
|
|
160
|
+
|
|
161
|
+
# Custom nameserver
|
|
162
|
+
gcnspack resolve example.com -n 1.1.1.1
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+

|
|
166
|
+
|
|
167
|
+
### Encode a File for DNS
|
|
168
|
+
|
|
169
|
+
```bash
|
|
170
|
+
# Print zone records to stdout
|
|
171
|
+
gcnspack encode payload.bin -s files.example.com
|
|
172
|
+
|
|
173
|
+
# Write to a zone file
|
|
174
|
+
gcnspack encode payload.bin -s files.example.com -o records.zone
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+

|
|
178
|
+
|
|
179
|
+
### Fetch a File from DNS
|
|
180
|
+
|
|
181
|
+
```bash
|
|
182
|
+
gcnspack fetch files.example.com -o output.bin
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+

|
|
186
|
+
|
|
187
|
+
## Development
|
|
188
|
+
|
|
189
|
+
```bash
|
|
190
|
+
# Clone and install
|
|
191
|
+
git clone https://github.com/geekennedy/gcnspack.git
|
|
192
|
+
cd gcnspack
|
|
193
|
+
uv sync
|
|
194
|
+
|
|
195
|
+
# Run unit tests
|
|
196
|
+
uv run pytest -q -m "not integration"
|
|
197
|
+
|
|
198
|
+
# Run integration tests (requires live DNS)
|
|
199
|
+
uv run pytest -q -m integration
|
|
200
|
+
|
|
201
|
+
# Run all tests
|
|
202
|
+
uv run pytest -q
|
|
203
|
+
|
|
204
|
+
# Lint and format
|
|
205
|
+
uv run ruff check src/ tests/
|
|
206
|
+
uv run ruff format src/ tests/
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
## License
|
|
210
|
+
|
|
211
|
+
MIT License - see [LICENSE](LICENSE) for details.
|
|
212
|
+
|
|
213
|
+
## Author
|
|
214
|
+
|
|
215
|
+
**geekennedy** - [gkennedy@gcloudns.net](mailto:gkennedy@gcloudns.net)
|
nspack-0.1.0/README.md
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# gcnspack
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/gcnspack/)
|
|
4
|
+
[](https://python.org)
|
|
5
|
+
[](https://opensource.org/licenses/MIT)
|
|
6
|
+
|
|
7
|
+
Simple DNS resolver and file-over-DNS transfer toolkit.
|
|
8
|
+
|
|
9
|
+

|
|
10
|
+
|
|
11
|
+
## Features
|
|
12
|
+
|
|
13
|
+
- **DNS Resolver** - Simple, batteries-included DNS resolution with sensible defaults (Cloudflare, Google, Quad9 fallbacks)
|
|
14
|
+
- **File-over-DNS** - Encode arbitrary files into DNS TXT records and reconstruct them from DNS queries
|
|
15
|
+
- **CLI** - Command-line interface for resolving DNS and managing file transfers
|
|
16
|
+
- **Protocol** - Chunked base32/base64 encoding scheme with SHA-256 integrity verification
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install gcnspack
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Or with [uv](https://docs.astral.sh/uv/):
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
uv add gcnspack
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Quick Start
|
|
31
|
+
|
|
32
|
+
### DNS Resolution
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from gcnspack import resolve, resolve_all, create_resolver
|
|
36
|
+
|
|
37
|
+
# Simple A record lookup
|
|
38
|
+
ips = resolve("example.com")
|
|
39
|
+
# ['93.184.216.34']
|
|
40
|
+
|
|
41
|
+
# Query specific record types
|
|
42
|
+
mx = resolve("example.com", "MX")
|
|
43
|
+
txt = resolve("example.com", "TXT")
|
|
44
|
+
|
|
45
|
+
# Query multiple record types at once
|
|
46
|
+
all_records = resolve_all("example.com")
|
|
47
|
+
# {'A': [...], 'AAAA': [...], 'MX': [...], 'TXT': [...], 'NS': [...], 'CNAME': [...]}
|
|
48
|
+
|
|
49
|
+
# Use a custom resolver
|
|
50
|
+
r = create_resolver(nameservers=["1.1.1.1"], timeout=10.0)
|
|
51
|
+
result = resolve("example.com", "A", resolver=r)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### File-over-DNS Transfer
|
|
55
|
+
|
|
56
|
+
#### Encoding a File
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from gcnspack import encode_file, generate_zone_records
|
|
60
|
+
|
|
61
|
+
# Read a file and generate DNS zone records
|
|
62
|
+
with open("payload.bin", "rb") as f:
|
|
63
|
+
data = f.read()
|
|
64
|
+
|
|
65
|
+
# Generate zone-file TXT records
|
|
66
|
+
records = generate_zone_records(data, "files.example.com")
|
|
67
|
+
for record in records:
|
|
68
|
+
print(record)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Output:
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
0.files.example.com IN TXT "GAYTEMZUGU3DOOB..."
|
|
75
|
+
1.files.example.com IN TXT "GAYTEMZUGU3DOOC..."
|
|
76
|
+
_meta.files.example.com IN TXT "ON2HE2LOM4QHO..."
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
#### Fetching a File from DNS
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
from gcnspack import save_txt_file
|
|
83
|
+
|
|
84
|
+
# Pull TXT records, decode, verify hash, and save
|
|
85
|
+
save_txt_file("files.example.com", "output.bin")
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Protocol
|
|
89
|
+
|
|
90
|
+
Files are transferred over DNS using this encoding scheme:
|
|
91
|
+
|
|
92
|
+
```
|
|
93
|
+
+-------------------+
|
|
94
|
+
| Original File |
|
|
95
|
+
+-------------------+
|
|
96
|
+
|
|
|
97
|
+
Split into chunks
|
|
98
|
+
(150 bytes each)
|
|
99
|
+
|
|
|
100
|
+
+-------+-------+
|
|
101
|
+
| | |
|
|
102
|
+
v v v
|
|
103
|
+
Chunk 0 Chunk 1 Chunk N
|
|
104
|
+
| | |
|
|
105
|
+
Base64 encode each chunk
|
|
106
|
+
| | |
|
|
107
|
+
Add header: "idx:total:base64data"
|
|
108
|
+
| | |
|
|
109
|
+
Base32 encode entire string
|
|
110
|
+
| | |
|
|
111
|
+
v v v
|
|
112
|
+
+-------------------------------------------+
|
|
113
|
+
| DNS TXT Records |
|
|
114
|
+
| 0.sub.example.com TXT "<base32>" |
|
|
115
|
+
| 1.sub.example.com TXT "<base32>" |
|
|
116
|
+
| N.sub.example.com TXT "<base32>" |
|
|
117
|
+
| _meta.sub.example.com TXT "<meta_b32>" |
|
|
118
|
+
+-------------------------------------------+
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
**Metadata record** (`_meta.<subdomain>`): Contains `sha256:<hash>:chunks:<count>`, base32-encoded.
|
|
122
|
+
|
|
123
|
+
**Chunk records** (`<n>.<subdomain>`): Each contains `<index>:<total>:<base64_data>`, base32-encoded.
|
|
124
|
+
|
|
125
|
+
Reconstruction:
|
|
126
|
+
1. Fetch `_meta.<subdomain>` to get chunk count and expected SHA-256 hash
|
|
127
|
+
2. Fetch chunks `0` through `N-1` from `<n>.<subdomain>`
|
|
128
|
+
3. Decode each chunk (base32 -> parse header -> base64 decode payload)
|
|
129
|
+
4. Sort by index, concatenate
|
|
130
|
+
5. Verify SHA-256 hash matches
|
|
131
|
+
|
|
132
|
+
## CLI Usage
|
|
133
|
+
|
|
134
|
+
### Resolve DNS Records
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
# A record (default)
|
|
138
|
+
gcnspack resolve example.com
|
|
139
|
+
|
|
140
|
+
# Specific record type
|
|
141
|
+
gcnspack resolve example.com -t MX
|
|
142
|
+
|
|
143
|
+
# Custom nameserver
|
|
144
|
+
gcnspack resolve example.com -n 1.1.1.1
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+

|
|
148
|
+
|
|
149
|
+
### Encode a File for DNS
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
# Print zone records to stdout
|
|
153
|
+
gcnspack encode payload.bin -s files.example.com
|
|
154
|
+
|
|
155
|
+
# Write to a zone file
|
|
156
|
+
gcnspack encode payload.bin -s files.example.com -o records.zone
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+

|
|
160
|
+
|
|
161
|
+
### Fetch a File from DNS
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
gcnspack fetch files.example.com -o output.bin
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+

|
|
168
|
+
|
|
169
|
+
## Development
|
|
170
|
+
|
|
171
|
+
```bash
|
|
172
|
+
# Clone and install
|
|
173
|
+
git clone https://github.com/geekennedy/gcnspack.git
|
|
174
|
+
cd gcnspack
|
|
175
|
+
uv sync
|
|
176
|
+
|
|
177
|
+
# Run unit tests
|
|
178
|
+
uv run pytest -q -m "not integration"
|
|
179
|
+
|
|
180
|
+
# Run integration tests (requires live DNS)
|
|
181
|
+
uv run pytest -q -m integration
|
|
182
|
+
|
|
183
|
+
# Run all tests
|
|
184
|
+
uv run pytest -q
|
|
185
|
+
|
|
186
|
+
# Lint and format
|
|
187
|
+
uv run ruff check src/ tests/
|
|
188
|
+
uv run ruff format src/ tests/
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
## License
|
|
192
|
+
|
|
193
|
+
MIT License - see [LICENSE](LICENSE) for details.
|
|
194
|
+
|
|
195
|
+
## Author
|
|
196
|
+
|
|
197
|
+
**geekennedy** - [gkennedy@gcloudns.net](mailto:gkennedy@gcloudns.net)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "nspack"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Simple DNS resolver and file-over-DNS transfer toolkit"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "geekennedy", email = "gkennedy@gcloudns.net" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.13"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
keywords = ["dns", "resolver", "file-transfer", "txt-records"]
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 3 - Alpha",
|
|
14
|
+
"Intended Audience :: Developers",
|
|
15
|
+
"License :: OSI Approved :: MIT License",
|
|
16
|
+
"Programming Language :: Python :: 3.13",
|
|
17
|
+
"Topic :: Internet :: Name Service (DNS)",
|
|
18
|
+
]
|
|
19
|
+
dependencies = [
|
|
20
|
+
"dnspython>=2.7.0",
|
|
21
|
+
"click>=8.1.0",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.scripts]
|
|
25
|
+
gcnspack = "gcnspack.cli:main"
|
|
26
|
+
|
|
27
|
+
[build-system]
|
|
28
|
+
requires = ["uv_build>=0.10.7,<0.11.0"]
|
|
29
|
+
build-backend = "uv_build"
|
|
30
|
+
|
|
31
|
+
[tool.uv.build-backend]
|
|
32
|
+
module-name = "gcnspack"
|
|
33
|
+
|
|
34
|
+
[dependency-groups]
|
|
35
|
+
dev = [
|
|
36
|
+
"pytest>=8.0.0",
|
|
37
|
+
"pytest-mock>=3.14.0",
|
|
38
|
+
"ruff>=0.15.6",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
[tool.pytest.ini_options]
|
|
42
|
+
testpaths = ["tests"]
|
|
43
|
+
markers = [
|
|
44
|
+
"integration: marks tests requiring live DNS (deselect with '-m \"not integration\"')",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
[tool.ruff]
|
|
48
|
+
line-length = 100
|
|
49
|
+
target-version = "py313"
|
|
50
|
+
|
|
51
|
+
[tool.ruff.lint]
|
|
52
|
+
select = ["E", "F", "W", "I", "N", "UP"]
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""gcnspack - Simple DNS resolver and file-over-DNS transfer toolkit."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0"
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"create_resolver",
|
|
7
|
+
"decode_chunks",
|
|
8
|
+
"encode_file",
|
|
9
|
+
"generate_zone_records",
|
|
10
|
+
"resolve",
|
|
11
|
+
"resolve_all",
|
|
12
|
+
"save_txt_file",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
from gcnspack.filedns import decode_chunks, encode_file, generate_zone_records, save_txt_file
|
|
16
|
+
from gcnspack.resolver import create_resolver, resolve, resolve_all
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""CLI for gcnspack - DNS resolver and file-over-DNS toolkit."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
|
|
8
|
+
from gcnspack.filedns import generate_zone_records, save_txt_file
|
|
9
|
+
from gcnspack.resolver import create_resolver, resolve
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@click.group()
|
|
13
|
+
@click.version_option()
|
|
14
|
+
def main():
|
|
15
|
+
"""gcnspack - Simple DNS resolver and file-over-DNS transfer toolkit."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@main.command()
|
|
19
|
+
@click.argument("domain")
|
|
20
|
+
@click.option("-t", "--type", "rdtype", default="A", help="DNS record type")
|
|
21
|
+
@click.option("-n", "--nameserver", multiple=True, help="Custom nameserver(s)")
|
|
22
|
+
def resolve_cmd(domain: str, rdtype: str, nameserver: tuple[str, ...]):
|
|
23
|
+
"""Resolve DNS records for DOMAIN."""
|
|
24
|
+
r = create_resolver(nameservers=list(nameserver)) if nameserver else None
|
|
25
|
+
results = resolve(domain, rdtype, resolver=r)
|
|
26
|
+
if not results:
|
|
27
|
+
click.echo(f"No {rdtype} records found for {domain}", err=True)
|
|
28
|
+
sys.exit(1)
|
|
29
|
+
for record in results:
|
|
30
|
+
click.echo(record)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# Register with the name "resolve" (click doesn't allow reusing Python names)
|
|
34
|
+
resolve_cmd.name = "resolve"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@main.command()
|
|
38
|
+
@click.argument("file", type=click.Path(exists=True))
|
|
39
|
+
@click.option("--subdomain", "-s", required=True, help="Base subdomain for TXT records")
|
|
40
|
+
@click.option("--output", "-o", type=click.Path(), help="Write zone records to file")
|
|
41
|
+
def encode(file: str, subdomain: str, output: str | None):
|
|
42
|
+
"""Encode FILE into DNS TXT zone records."""
|
|
43
|
+
data = Path(file).read_bytes()
|
|
44
|
+
records = generate_zone_records(data, subdomain)
|
|
45
|
+
text = "\n".join(records) + "\n"
|
|
46
|
+
if output:
|
|
47
|
+
Path(output).write_text(text)
|
|
48
|
+
click.echo(f"Wrote {len(records)} records to {output}")
|
|
49
|
+
else:
|
|
50
|
+
click.echo(text)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@main.command()
|
|
54
|
+
@click.argument("subdomain")
|
|
55
|
+
@click.option("--output", "-o", required=True, help="Output file path")
|
|
56
|
+
def fetch(subdomain: str, output: str):
|
|
57
|
+
"""Fetch and reconstruct a file from DNS TXT records at SUBDOMAIN."""
|
|
58
|
+
try:
|
|
59
|
+
save_txt_file(subdomain, output)
|
|
60
|
+
click.echo(f"Saved file to {output}")
|
|
61
|
+
except RuntimeError as exc:
|
|
62
|
+
click.echo(f"Error: {exc}", err=True)
|
|
63
|
+
sys.exit(1)
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""File-over-DNS encoding and decoding via TXT records.
|
|
2
|
+
|
|
3
|
+
Protocol:
|
|
4
|
+
Each file chunk is stored as a TXT record at <chunk_idx>.<subdomain>.
|
|
5
|
+
The TXT record value is base32-encoded, containing: "idx:total:base64data"
|
|
6
|
+
A metadata record at _meta.<subdomain> stores: "sha256:<hash>:chunks:<count>"
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import base64
|
|
10
|
+
import hashlib
|
|
11
|
+
import math
|
|
12
|
+
|
|
13
|
+
from gcnspack.resolver import resolve
|
|
14
|
+
|
|
15
|
+
# Max raw bytes per chunk before base64 encoding.
|
|
16
|
+
# TXT records can hold ~255 bytes per string. After base64 + base32 encoding
|
|
17
|
+
# with header overhead, 150 bytes of raw data per chunk is safe.
|
|
18
|
+
CHUNK_SIZE = 150
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def encode_file(data: bytes) -> list[str]:
|
|
22
|
+
"""Encode file bytes into a list of base32 TXT record values.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
data: Raw file bytes to encode.
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
List of base32-encoded chunk strings, ordered by index.
|
|
29
|
+
"""
|
|
30
|
+
if len(data) == 0:
|
|
31
|
+
inner = f"0:1:{base64.b64encode(b'').decode()}"
|
|
32
|
+
return [base64.b32encode(inner.encode()).decode()]
|
|
33
|
+
|
|
34
|
+
total = math.ceil(len(data) / CHUNK_SIZE)
|
|
35
|
+
chunks = []
|
|
36
|
+
for i in range(total):
|
|
37
|
+
start = i * CHUNK_SIZE
|
|
38
|
+
end = start + CHUNK_SIZE
|
|
39
|
+
chunk_data = data[start:end]
|
|
40
|
+
b64 = base64.b64encode(chunk_data).decode()
|
|
41
|
+
inner = f"{i}:{total}:{b64}"
|
|
42
|
+
chunks.append(base64.b32encode(inner.encode()).decode())
|
|
43
|
+
return chunks
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def parse_txt_record(record: str) -> tuple[int, int, bytes]:
|
|
47
|
+
"""Parse a single base32-encoded TXT record value.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
record: Base32-encoded string from a TXT record.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
Tuple of (chunk_index, total_chunks, raw_payload_bytes).
|
|
54
|
+
|
|
55
|
+
Raises:
|
|
56
|
+
ValueError: If the record cannot be decoded or has invalid format.
|
|
57
|
+
"""
|
|
58
|
+
try:
|
|
59
|
+
decoded = base64.b32decode(record).decode("ascii")
|
|
60
|
+
except Exception as exc:
|
|
61
|
+
raise ValueError(f"Failed to decode base32 TXT record: {exc}") from exc
|
|
62
|
+
|
|
63
|
+
parts = decoded.split(":", 2)
|
|
64
|
+
if len(parts) != 3:
|
|
65
|
+
raise ValueError(f"Invalid chunk format: expected 'idx:total:data', got '{decoded}'")
|
|
66
|
+
|
|
67
|
+
idx = int(parts[0])
|
|
68
|
+
total = int(parts[1])
|
|
69
|
+
payload = base64.b64decode(parts[2])
|
|
70
|
+
return idx, total, payload
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def decode_chunks(chunks: list[str]) -> bytes:
|
|
74
|
+
"""Decode a list of base32 TXT record values back to file bytes.
|
|
75
|
+
|
|
76
|
+
Chunks can be in any order; they are sorted by index.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
chunks: List of base32-encoded chunk strings.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
Reconstructed file bytes.
|
|
83
|
+
"""
|
|
84
|
+
parsed = [parse_txt_record(chunk) for chunk in chunks]
|
|
85
|
+
parsed.sort(key=lambda x: x[0])
|
|
86
|
+
return b"".join(payload for _, _, payload in parsed)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def generate_zone_records(data: bytes, subdomain: str) -> list[str]:
|
|
90
|
+
"""Generate DNS zone-file TXT records for a file.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
data: Raw file bytes to encode.
|
|
94
|
+
subdomain: Base subdomain (e.g., "files.example.com").
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
List of zone-file lines, including chunk records and a _meta record.
|
|
98
|
+
"""
|
|
99
|
+
chunks = encode_file(data)
|
|
100
|
+
total = len(chunks)
|
|
101
|
+
sha256 = hashlib.sha256(data).hexdigest()
|
|
102
|
+
|
|
103
|
+
records = []
|
|
104
|
+
for i, chunk in enumerate(chunks):
|
|
105
|
+
records.append(f'{i}.{subdomain}\tIN\tTXT\t"{chunk}"')
|
|
106
|
+
|
|
107
|
+
meta = f"sha256:{sha256}:chunks:{total}"
|
|
108
|
+
meta_b32 = base64.b32encode(meta.encode()).decode()
|
|
109
|
+
records.append(f'_meta.{subdomain}\tIN\tTXT\t"{meta_b32}"')
|
|
110
|
+
return records
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def fetch_file(subdomain: str) -> bytes:
|
|
114
|
+
"""Fetch and reconstruct a file from DNS TXT records.
|
|
115
|
+
|
|
116
|
+
Reads _meta.<subdomain> for chunk count, then fetches each chunk.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
subdomain: The subdomain where chunks are stored.
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
Reconstructed file bytes.
|
|
123
|
+
|
|
124
|
+
Raises:
|
|
125
|
+
RuntimeError: If metadata or chunks cannot be fetched.
|
|
126
|
+
"""
|
|
127
|
+
meta_records = resolve(f"_meta.{subdomain}", "TXT")
|
|
128
|
+
if not meta_records:
|
|
129
|
+
raise RuntimeError(f"No metadata found at _meta.{subdomain}")
|
|
130
|
+
|
|
131
|
+
meta_raw = meta_records[0].strip('"')
|
|
132
|
+
try:
|
|
133
|
+
meta_decoded = base64.b32decode(meta_raw).decode("ascii")
|
|
134
|
+
except Exception as exc:
|
|
135
|
+
raise RuntimeError(f"Failed to decode metadata: {exc}") from exc
|
|
136
|
+
|
|
137
|
+
parts = meta_decoded.split(":")
|
|
138
|
+
# Format: sha256:<hash>:chunks:<count>
|
|
139
|
+
sha256_expected = parts[1]
|
|
140
|
+
total_chunks = int(parts[3])
|
|
141
|
+
|
|
142
|
+
chunks = []
|
|
143
|
+
for i in range(total_chunks):
|
|
144
|
+
txt = resolve(f"{i}.{subdomain}", "TXT")
|
|
145
|
+
if not txt:
|
|
146
|
+
raise RuntimeError(f"Missing chunk {i} at {i}.{subdomain}")
|
|
147
|
+
chunks.append(txt[0].strip('"'))
|
|
148
|
+
|
|
149
|
+
data = decode_chunks(chunks)
|
|
150
|
+
|
|
151
|
+
sha256_actual = hashlib.sha256(data).hexdigest()
|
|
152
|
+
if sha256_actual != sha256_expected:
|
|
153
|
+
raise RuntimeError(f"Hash mismatch: expected {sha256_expected}, got {sha256_actual}")
|
|
154
|
+
return data
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def save_txt_file(subdomain: str, file_name: str) -> None:
|
|
158
|
+
"""Fetch a file from DNS TXT records and save it locally.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
subdomain: The subdomain where chunks are stored.
|
|
162
|
+
file_name: Local path to write the reconstructed file.
|
|
163
|
+
"""
|
|
164
|
+
data = fetch_file(subdomain)
|
|
165
|
+
with open(file_name, "wb") as f:
|
|
166
|
+
f.write(data)
|
|
File without changes
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""DNS resolver with sensible defaults."""
|
|
2
|
+
|
|
3
|
+
import dns.resolver
|
|
4
|
+
|
|
5
|
+
DEFAULT_NAMESERVERS = ["1.1.1.1", "8.8.8.8", "9.9.9.9"]
|
|
6
|
+
DEFAULT_TIMEOUT = 5.0
|
|
7
|
+
DEFAULT_RECORD_TYPES = ["A", "AAAA", "MX", "TXT", "NS", "CNAME"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def create_resolver(
|
|
11
|
+
nameservers: list[str] | None = None,
|
|
12
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
13
|
+
) -> dns.resolver.Resolver:
|
|
14
|
+
"""Create a configured DNS resolver.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
nameservers: List of nameserver IPs. Defaults to public resolvers.
|
|
18
|
+
timeout: Query lifetime in seconds.
|
|
19
|
+
"""
|
|
20
|
+
r = dns.resolver.Resolver()
|
|
21
|
+
r.nameservers = nameservers or DEFAULT_NAMESERVERS
|
|
22
|
+
r.lifetime = timeout
|
|
23
|
+
return r
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
_default_resolver = create_resolver()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def resolve(
|
|
30
|
+
domain: str,
|
|
31
|
+
rdtype: str = "A",
|
|
32
|
+
resolver: dns.resolver.Resolver | None = None,
|
|
33
|
+
) -> list[str]:
|
|
34
|
+
"""Resolve a DNS query, returning a list of string answers.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
domain: The domain name to query.
|
|
38
|
+
rdtype: DNS record type (A, AAAA, MX, TXT, etc.).
|
|
39
|
+
resolver: Optional custom resolver. Uses default if None.
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
List of string representations of DNS answers, or empty list on failure.
|
|
43
|
+
"""
|
|
44
|
+
r = resolver or _default_resolver
|
|
45
|
+
try:
|
|
46
|
+
answer = r.resolve(domain, rdtype)
|
|
47
|
+
return [str(rdata) for rdata in answer]
|
|
48
|
+
except (
|
|
49
|
+
dns.resolver.NXDOMAIN,
|
|
50
|
+
dns.resolver.NoAnswer,
|
|
51
|
+
dns.resolver.LifetimeTimeout,
|
|
52
|
+
dns.resolver.NoNameservers,
|
|
53
|
+
):
|
|
54
|
+
return []
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def resolve_all(
|
|
58
|
+
domain: str,
|
|
59
|
+
rdtypes: list[str] | None = None,
|
|
60
|
+
resolver: dns.resolver.Resolver | None = None,
|
|
61
|
+
) -> dict[str, list[str]]:
|
|
62
|
+
"""Resolve multiple record types for a domain.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
domain: The domain name to query.
|
|
66
|
+
rdtypes: List of record types. Defaults to common types.
|
|
67
|
+
resolver: Optional custom resolver.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
Dict mapping record type to list of answers.
|
|
71
|
+
"""
|
|
72
|
+
types = rdtypes or DEFAULT_RECORD_TYPES
|
|
73
|
+
return {rdtype: resolve(domain, rdtype, resolver) for rdtype in types}
|