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 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
+ [![PyPI version](https://img.shields.io/pypi/v/gcnspack.svg)](https://pypi.org/project/gcnspack/)
22
+ [![Python 3.13+](https://img.shields.io/badge/python-3.13%2B-blue.svg)](https://python.org)
23
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
24
+
25
+ Simple DNS resolver and file-over-DNS transfer toolkit.
26
+
27
+ ![gcnspack overview](docs/overview.svg)
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
+ ![resolve demo](docs/resolve-demo.svg)
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
+ ![encode demo](docs/encode-demo.svg)
178
+
179
+ ### Fetch a File from DNS
180
+
181
+ ```bash
182
+ gcnspack fetch files.example.com -o output.bin
183
+ ```
184
+
185
+ ![fetch demo](docs/fetch-demo.svg)
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
+ [![PyPI version](https://img.shields.io/pypi/v/gcnspack.svg)](https://pypi.org/project/gcnspack/)
4
+ [![Python 3.13+](https://img.shields.io/badge/python-3.13%2B-blue.svg)](https://python.org)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
6
+
7
+ Simple DNS resolver and file-over-DNS transfer toolkit.
8
+
9
+ ![gcnspack overview](docs/overview.svg)
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
+ ![resolve demo](docs/resolve-demo.svg)
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
+ ![encode demo](docs/encode-demo.svg)
160
+
161
+ ### Fetch a File from DNS
162
+
163
+ ```bash
164
+ gcnspack fetch files.example.com -o output.bin
165
+ ```
166
+
167
+ ![fetch demo](docs/fetch-demo.svg)
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}