curlinate 0.0.1__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.
@@ -0,0 +1,14 @@
1
+ Metadata-Version: 2.1
2
+ Name: curlinate
3
+ Version: 0.0.1
4
+ Summary: Command-line utility and Python library to simplify TLS fingerprint forgery
5
+ Home-page: https://github.com/radian-software/curlinate
6
+ Author: Radian LLC
7
+ Maintainer: Radian LLC
8
+ Maintainer-email: contact+curlinate@radian.codes
9
+ License: MIT
10
+ Download-URL: https://pypi.python.org/pypi/curlinate
11
+ Project-URL: Bug Tracker, https://github.com/radian-software/curlinate/issues
12
+ Project-URL: Documentation, https://github.com/radian-software/curlinate
13
+ Project-URL: Source Code, https://github.com/radian-software/curlinate
14
+ Requires-Python: >=3.8,<4.0
@@ -0,0 +1,253 @@
1
+ package main
2
+
3
+ import (
4
+ "bufio"
5
+ "bytes"
6
+ "encoding/base64"
7
+ "encoding/json"
8
+ "errors"
9
+ "fmt"
10
+ "io"
11
+ "net"
12
+ "net/http"
13
+ "net/url"
14
+ "os"
15
+ "strings"
16
+
17
+ "github.com/alecthomas/kong"
18
+ "github.com/refraction-networking/utls"
19
+ "golang.org/x/net/http2"
20
+ )
21
+
22
+ type argsType struct {
23
+ URL string `arg:"" help:"Fully qualified URL to contact" required:"" json:"url"`
24
+ Method string `short:"X" name:"method" help:"HTTP method to use" default:"GET" json:"method"`
25
+ Headers []string `sep:"none" short:"H" name:"header" help:"Additional http headers in format 'Header: Value'" json:"headers"`
26
+ Body string `name:"body" help:"Request body, must be UTF-8 due to limitation in argument parser" json:"body"`
27
+ BodyBase64 bool `name:"body-base64" help:"Assume request body is base64 encoded, so null bytes can be used" json:"body_base64"`
28
+ ClientHello string `name:"clienthello" help:"Base64 encoded raw ClientHello message to emulate" env:"CLIENTHELLO" json:"clienthello"`
29
+ ConnId string `kong:"-" json:"conn_id"`
30
+ multiple bool
31
+ useJson bool
32
+ }
33
+
34
+ type sessionResp struct {
35
+ Status int `json:"status"`
36
+ Headers []string `json:"headers"`
37
+ Body []byte `json:"body"`
38
+ }
39
+
40
+ func getMapKeys(m map[string]string) []string {
41
+ keys := []string{}
42
+ for key := range m {
43
+ keys = append(keys, key)
44
+ }
45
+ return keys
46
+ }
47
+
48
+ var savedConns = map[string]*tls.UConn{}
49
+
50
+ func mainE(args *argsType) error {
51
+ // First check if we are in session mode, if so, read new args
52
+ // from stdin and recurse.
53
+ if args.multiple {
54
+ fmt.Fprintln(os.Stderr, "ignoring args and reading commands from stdin")
55
+ scanner := bufio.NewScanner(os.Stdin)
56
+ for scanner.Scan() {
57
+ subargs := argsType{}
58
+ err := json.Unmarshal(scanner.Bytes(), &subargs)
59
+ if err != nil {
60
+ return err
61
+ }
62
+ subargs.useJson = true
63
+ err = mainE(&subargs)
64
+ if err != nil {
65
+ return err
66
+ }
67
+ }
68
+ return nil
69
+ }
70
+ // Need to validate args again because it is possible to
71
+ // bypass the cmdline validation in session mode
72
+ if args.URL == "" {
73
+ return errors.New("url is required")
74
+ }
75
+ // Kong doesn't seem to want to read bytes as base64 like
76
+ // json, so we have to do it manually
77
+ if args.ClientHello == "" {
78
+ args.ClientHello = os.Getenv("CLIENTHELLO")
79
+ }
80
+ clienthello := []byte{}
81
+ if args.ClientHello != "" {
82
+ encoder := base64.NewDecoder(base64.StdEncoding, bytes.NewReader([]byte(args.ClientHello)))
83
+ var err error
84
+ clienthello, err = io.ReadAll(encoder)
85
+ if err != nil {
86
+ return err
87
+ }
88
+ }
89
+ // Proceed to main logic
90
+ parsedURL, err := url.Parse(args.URL)
91
+ if err != nil {
92
+ return err
93
+ }
94
+ // Parse the headers into a map
95
+ parsedHeaders := http.Header{}
96
+ for _, header := range args.Headers {
97
+ splits := strings.SplitN(header, ": ", 2)
98
+ if len(splits) != 2 {
99
+ return fmt.Errorf("bad header format: %v", header)
100
+ }
101
+ key := splits[0]
102
+ value := splits[1]
103
+ parsedHeaders.Add(key, value)
104
+ }
105
+ if err != nil {
106
+ return err
107
+ }
108
+ // Parse the body into a byte array
109
+ parsedBody := []byte(args.Body)
110
+ if args.BodyBase64 {
111
+ encoder := base64.NewDecoder(base64.StdEncoding, bytes.NewReader(parsedBody))
112
+ parsedBody, err = io.ReadAll(encoder)
113
+ if err != nil {
114
+ return err
115
+ }
116
+ }
117
+ var tlsConn *tls.UConn = nil
118
+ if args.ConnId != "" {
119
+ tlsConn = savedConns[args.ConnId]
120
+ }
121
+ if tlsConn == nil {
122
+ var tlsId tls.ClientHelloID
123
+ var tlsSpec *tls.ClientHelloSpec
124
+ if len(clienthello) > 0 {
125
+ tlsId = tls.HelloCustom
126
+ tlsFingerprinter := &tls.Fingerprinter{
127
+ AllowBluntMimicry: true,
128
+ }
129
+ tlsSpec, err = tlsFingerprinter.FingerprintClientHello(clienthello)
130
+ if err != nil {
131
+ return err
132
+ }
133
+ } else {
134
+ tlsId = tls.HelloFirefox_56
135
+ tlsSpec = nil
136
+ }
137
+ if parsedURL.Scheme != "https" {
138
+ return fmt.Errorf("unsupported scheme %s, only https is allowed", parsedURL.Scheme)
139
+ }
140
+ host := parsedURL.Host
141
+ if !strings.Contains(host, ":") {
142
+ host += ":443"
143
+ }
144
+ tcpConn, err := net.Dial("tcp", host)
145
+ if err != nil {
146
+ return err
147
+ }
148
+ tlsConfig := tls.Config{ServerName: parsedURL.Hostname()}
149
+ tlsConn = tls.UClient(tcpConn, &tlsConfig, tlsId)
150
+ if tlsSpec != nil {
151
+ err = tlsConn.ApplyPreset(tlsSpec)
152
+ if err != nil {
153
+ return err
154
+ }
155
+ }
156
+ err = tlsConn.Handshake()
157
+ if err != nil {
158
+ return err
159
+ }
160
+ if args.ConnId == "" {
161
+ defer tlsConn.Close()
162
+ } else {
163
+ savedConns[args.ConnId] = tlsConn
164
+ }
165
+ }
166
+ req := &http.Request{
167
+ Method: args.Method,
168
+ URL: parsedURL,
169
+ Header: parsedHeaders,
170
+ Body: io.NopCloser(bytes.NewReader(parsedBody)),
171
+ ContentLength: int64(len(parsedBody)),
172
+ }
173
+ var resp *http.Response
174
+ alpn := tlsConn.HandshakeState.ServerHello.AlpnProtocol
175
+ switch alpn {
176
+ case "h2":
177
+ req.Proto = "HTTP/2.0"
178
+ req.ProtoMajor = 2
179
+ req.ProtoMinor = 0
180
+
181
+ tr := http2.Transport{}
182
+ clientConn, err := tr.NewClientConn(tlsConn)
183
+ if err != nil {
184
+ return err
185
+ }
186
+ resp, err = clientConn.RoundTrip(req)
187
+ if err != nil {
188
+ return err
189
+ }
190
+ case "http/1.1", "":
191
+ req.Proto = "HTTP/1.1"
192
+ req.ProtoMajor = 1
193
+ req.ProtoMinor = 1
194
+ err := req.Write(tlsConn)
195
+ if err != nil {
196
+ return err
197
+ }
198
+ resp, err = http.ReadResponse(bufio.NewReader(tlsConn), req)
199
+ if err != nil {
200
+ return err
201
+ }
202
+ default:
203
+ return fmt.Errorf("unexpected ALPN: %s", alpn)
204
+ }
205
+ if !args.useJson {
206
+ fmt.Fprintf(os.Stderr, "status %s\n", resp.Status)
207
+ for key, vals := range resp.Header {
208
+ for _, val := range vals {
209
+ fmt.Fprintf(os.Stderr, "header %s: %s\n", key, val)
210
+ }
211
+ }
212
+ }
213
+ respBody, err := io.ReadAll(resp.Body)
214
+ if err != nil {
215
+ return err
216
+ }
217
+ defer resp.Body.Close()
218
+ if !args.useJson {
219
+ fmt.Fprintf(os.Stderr, "body %d bytes\n", len(respBody))
220
+ fmt.Printf("%s", respBody)
221
+ } else {
222
+ headers := []string{}
223
+ for key, vals := range resp.Header {
224
+ for _, val := range vals {
225
+ headers = append(headers, fmt.Sprintf("%s: %s", key, val))
226
+ }
227
+ }
228
+ msg, err := json.Marshal(sessionResp{
229
+ Status: resp.StatusCode,
230
+ Headers: headers,
231
+ Body: respBody,
232
+ })
233
+ if err != nil {
234
+ return err
235
+ }
236
+ fmt.Println(string(msg))
237
+ }
238
+ return nil
239
+ }
240
+
241
+ func main() {
242
+ args := argsType{}
243
+ if len(os.Args) == 2 && os.Args[1] == "multiple" {
244
+ args.multiple = true
245
+ } else {
246
+ kong.Parse(&args)
247
+ }
248
+ err := mainE(&args)
249
+ if err != nil {
250
+ fmt.Fprintf(os.Stderr, "fatal: %s\n", err.Error())
251
+ os.Exit(1)
252
+ }
253
+ }
@@ -0,0 +1,443 @@
1
+ import base64
2
+ from collections.abc import Mapping, MutableMapping
3
+ from collections import OrderedDict
4
+ from dataclasses import dataclass
5
+ import gzip
6
+ import io
7
+ import json
8
+ import re
9
+ import subprocess
10
+ from typing import Tuple
11
+ import urllib.parse
12
+
13
+
14
+ # Cribbed from requests
15
+ class CaseInsensitiveDict(MutableMapping):
16
+ def __init__(self, data=None, **kwargs):
17
+ self._store = OrderedDict()
18
+ if data is None:
19
+ data = {}
20
+ self.update(data, **kwargs)
21
+
22
+ def __setitem__(self, key, value):
23
+ self._store[key.lower()] = (key, value)
24
+
25
+ def __getitem__(self, key):
26
+ return self._store[key.lower()][1]
27
+
28
+ def __delitem__(self, key):
29
+ del self._store[key.lower()]
30
+
31
+ def __iter__(self):
32
+ return (casedkey for casedkey, _ in self._store.values())
33
+
34
+ def __len__(self):
35
+ return len(self._store)
36
+
37
+ def lower_items(self):
38
+ return ((lowerkey, keyval[1]) for (lowerkey, keyval) in self._store.items())
39
+
40
+ def __eq__(self, other):
41
+ if isinstance(other, Mapping):
42
+ other = CaseInsensitiveDict(other)
43
+ else:
44
+ return NotImplemented
45
+ return dict(self.lower_items()) == dict(other.lower_items())
46
+
47
+ def copy(self):
48
+ return CaseInsensitiveDict(self._store.values())
49
+
50
+ def __repr__(self):
51
+ return str(dict(self.items()))
52
+
53
+
54
+ class HTTPError(Exception):
55
+ def __init__(self, msg, response):
56
+ super().__init__(msg)
57
+ self.response = response
58
+
59
+
60
+ @dataclass
61
+ class Response:
62
+ status_code: int
63
+ headers: CaseInsensitiveDict
64
+ content: bytes
65
+
66
+ @property
67
+ def ok(self):
68
+ return not (400 < self.status_code < 600)
69
+
70
+ def raise_for_status(self):
71
+ if not self.ok:
72
+ raise HTTPError(f"{self.status_code} Server Error", response=self)
73
+
74
+ @property
75
+ def _content(self):
76
+ content = self.content
77
+ if self.headers.get("content-encoding") == "gzip":
78
+ with gzip.open(io.BytesIO(content)) as f:
79
+ content = f.read()
80
+ return content
81
+
82
+ @property
83
+ def text(self):
84
+ # Todo: decode using appropriate charset from content type
85
+ return self._content.decode()
86
+
87
+ def json(self):
88
+ return json.loads(self._content)
89
+
90
+
91
+ def _fixup_request_args(
92
+ method: str,
93
+ url: str,
94
+ data: bytes | dict[str, str],
95
+ headers: dict[str, str],
96
+ cookies: dict[str, str],
97
+ params: dict[str, str],
98
+ clienthello: str,
99
+ ) -> Tuple[str, str, bytes, dict[str, str], dict[str, str], dict[str, str], str]:
100
+ if isinstance(data, dict):
101
+ data = urllib.parse.urlencode(data).encode()
102
+ if not CaseInsensitiveDict(headers).get("content-type"):
103
+ headers = {
104
+ **headers,
105
+ "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
106
+ }
107
+ if cookies:
108
+ if CaseInsensitiveDict(headers).get("cookie"):
109
+ raise RuntimeError(
110
+ "can't use extra cookies with request that already has cookie header"
111
+ )
112
+ headers = {
113
+ **headers,
114
+ "Cookie": "; ".join(f"{key}={val}" for key, val in cookies.items()),
115
+ }
116
+ if params:
117
+ if "?" in url:
118
+ raise RuntimeError(
119
+ "can't use extra query params with url that already has query string"
120
+ )
121
+ url += "?" + urllib.parse.urlencode(params)
122
+ method = method.upper()
123
+ return method, url, data, headers, cookies, params, clienthello
124
+
125
+
126
+ def request(
127
+ method,
128
+ url,
129
+ *,
130
+ data: (bytes | dict[str, str]) = b"",
131
+ headers: dict[str, str] = {},
132
+ cookies: dict[str, str] = {},
133
+ params: dict[str, str] = {},
134
+ clienthello: str = "",
135
+ ):
136
+ method, url, data, headers, cookies, params, clienthello = _fixup_request_args(
137
+ method, url, data, headers, cookies, params, clienthello
138
+ )
139
+ result = subprocess.run(
140
+ [
141
+ "curlinate",
142
+ "-X",
143
+ method.upper(),
144
+ url,
145
+ *[f"-H{key}: {value}" for key, value in headers.items()],
146
+ *(["--body", base64.b64encode(data), "--body-base64"] if data else []),
147
+ *(["--clienthello", clienthello] if clienthello else []),
148
+ ],
149
+ stdout=subprocess.PIPE,
150
+ stderr=subprocess.PIPE,
151
+ )
152
+ stdout = result.stdout
153
+ stderr = result.stderr.decode()
154
+ if result.returncode != 0:
155
+ error = (
156
+ stderr.strip().splitlines()[-1].strip()
157
+ or f"exit status {result.returncode}"
158
+ )
159
+ raise RuntimeError(f"got error from curlinate subprocess: {error}")
160
+ status_code_match = re.search(r"(?m)^status ([0-9]+)", stderr)
161
+ try:
162
+ if not status_code_match:
163
+ raise ValueError
164
+ status_code = int(status_code_match.group(1))
165
+ except (AttributeError, ValueError) as _:
166
+ raise RuntimeError(
167
+ f"unable to parse output from curlinate subprocess: {repr(stderr)}"
168
+ ) from None
169
+ resp_headers = CaseInsensitiveDict()
170
+ for key, value in re.findall(r"(?m)^header ([^:]+): (.+)", stderr):
171
+ resp_headers[key] = value
172
+ return Response(status_code, resp_headers, stdout)
173
+
174
+
175
+ def delete(
176
+ url,
177
+ *,
178
+ data: (bytes | dict[str, str]) = b"",
179
+ headers: dict[str, str] = {},
180
+ cookies: dict[str, str] = {},
181
+ params: dict[str, str] = {},
182
+ clienthello: str = "",
183
+ ):
184
+ return request(
185
+ "DELETE",
186
+ url,
187
+ data=data,
188
+ headers=headers,
189
+ cookies=cookies,
190
+ params=params,
191
+ clienthello=clienthello,
192
+ )
193
+
194
+
195
+ def get(
196
+ url,
197
+ *,
198
+ headers: dict[str, str] = {},
199
+ cookies: dict[str, str] = {},
200
+ params: dict[str, str] = {},
201
+ clienthello: str = "",
202
+ ):
203
+ return request(
204
+ "GET",
205
+ url,
206
+ headers=headers,
207
+ cookies=cookies,
208
+ params=params,
209
+ clienthello=clienthello,
210
+ )
211
+
212
+
213
+ def patch(
214
+ url,
215
+ *,
216
+ data: (bytes | dict[str, str]) = b"",
217
+ headers: dict[str, str] = {},
218
+ cookies: dict[str, str] = {},
219
+ params: dict[str, str] = {},
220
+ clienthello: str = "",
221
+ ):
222
+ return request(
223
+ "PATCH",
224
+ url,
225
+ data=data,
226
+ headers=headers,
227
+ cookies=cookies,
228
+ params=params,
229
+ clienthello=clienthello,
230
+ )
231
+
232
+
233
+ def post(
234
+ url,
235
+ *,
236
+ data: (bytes | dict[str, str]) = b"",
237
+ headers: dict[str, str] = {},
238
+ cookies: dict[str, str] = {},
239
+ params: dict[str, str] = {},
240
+ clienthello: str = "",
241
+ ):
242
+ return request(
243
+ "POST",
244
+ url,
245
+ data=data,
246
+ headers=headers,
247
+ cookies=cookies,
248
+ params=params,
249
+ clienthello=clienthello,
250
+ )
251
+
252
+
253
+ def put(
254
+ url,
255
+ *,
256
+ data: (bytes | dict[str, str]) = b"",
257
+ headers: dict[str, str] = {},
258
+ cookies: dict[str, str] = {},
259
+ params: dict[str, str] = {},
260
+ clienthello: str = "",
261
+ ):
262
+ return request(
263
+ "PUT",
264
+ url,
265
+ data=data,
266
+ headers=headers,
267
+ cookies=cookies,
268
+ params=params,
269
+ clienthello=clienthello,
270
+ )
271
+
272
+
273
+ class Session:
274
+ def __init__(self, clienthello: str | None = None):
275
+ self.proc = subprocess.Popen(
276
+ ["curlinate", "multiple"],
277
+ stdin=subprocess.PIPE,
278
+ stdout=subprocess.PIPE,
279
+ stderr=subprocess.PIPE,
280
+ bufsize=0,
281
+ )
282
+ self.clienthello = clienthello or ""
283
+
284
+ def __del__(self):
285
+ assert self.proc.stdin
286
+ self.proc.kill()
287
+
288
+ def __enter__(self):
289
+ return self
290
+
291
+ def __exit__(self, *_):
292
+ self.__del__()
293
+
294
+ def request(
295
+ self,
296
+ method,
297
+ url,
298
+ *,
299
+ data: (bytes | dict[str, str]) = b"",
300
+ headers: dict[str, str] = {},
301
+ cookies: dict[str, str] = {},
302
+ params: dict[str, str] = {},
303
+ clienthello: str = "",
304
+ ):
305
+ if not clienthello:
306
+ clienthello = self.clienthello
307
+ method, url, data, headers, cookies, params, clienthello = _fixup_request_args(
308
+ method, url, data, headers, cookies, params, clienthello
309
+ )
310
+ assert self.proc.stdin
311
+ assert self.proc.stdout
312
+ assert self.proc.stderr
313
+ self.proc.stdin.write(
314
+ json.dumps(
315
+ {
316
+ "url": url,
317
+ "method": method,
318
+ "headers": [f"{key}: {value}" for key, value in headers.items()],
319
+ **(
320
+ {"body": base64.b64encode(data).decode(), "body_base64": True}
321
+ if data
322
+ else {}
323
+ ),
324
+ "clienthello": clienthello,
325
+ "conn_id": "curlinate",
326
+ }
327
+ ).encode()
328
+ + b"\n"
329
+ )
330
+ line = self.proc.stdout.readline()
331
+ if not line:
332
+ try:
333
+ error = (
334
+ self.proc.stderr.read().decode().strip().splitlines()[-1].strip()
335
+ or "unknown error"
336
+ )
337
+ except Exception:
338
+ error = "unknown error"
339
+ raise RuntimeError(f"got error from curlinate subprocess: {error}")
340
+ resp = json.loads(line)
341
+ resp_headers = CaseInsensitiveDict()
342
+ for header in resp["headers"]:
343
+ key, value = header.split(": ", maxsplit=1)
344
+ resp_headers[key] = value
345
+ return Response(resp["status"], resp_headers, base64.b64decode(resp["body"]))
346
+
347
+ def delete(
348
+ self,
349
+ url,
350
+ *,
351
+ data: (bytes | dict[str, str]) = b"",
352
+ headers: dict[str, str] = {},
353
+ cookies: dict[str, str] = {},
354
+ params: dict[str, str] = {},
355
+ clienthello: str = "",
356
+ ):
357
+ return self.request(
358
+ "DELETE",
359
+ url,
360
+ data=data,
361
+ headers=headers,
362
+ cookies=cookies,
363
+ params=params,
364
+ clienthello=clienthello,
365
+ )
366
+
367
+ def get(
368
+ self,
369
+ url,
370
+ *,
371
+ headers: dict[str, str] = {},
372
+ cookies: dict[str, str] = {},
373
+ params: dict[str, str] = {},
374
+ clienthello: str = "",
375
+ ):
376
+ return self.request(
377
+ "GET",
378
+ url,
379
+ headers=headers,
380
+ cookies=cookies,
381
+ params=params,
382
+ clienthello=clienthello,
383
+ )
384
+
385
+ def patch(
386
+ self,
387
+ url,
388
+ *,
389
+ data: (bytes | dict[str, str]) = b"",
390
+ headers: dict[str, str] = {},
391
+ cookies: dict[str, str] = {},
392
+ params: dict[str, str] = {},
393
+ clienthello: str = "",
394
+ ):
395
+ return self.request(
396
+ "PATCH",
397
+ url,
398
+ data=data,
399
+ headers=headers,
400
+ cookies=cookies,
401
+ params=params,
402
+ clienthello=clienthello,
403
+ )
404
+
405
+ def post(
406
+ self,
407
+ url,
408
+ *,
409
+ data: (bytes | dict[str, str]) = b"",
410
+ headers: dict[str, str] = {},
411
+ cookies: dict[str, str] = {},
412
+ params: dict[str, str] = {},
413
+ clienthello: str = "",
414
+ ):
415
+ return self.request(
416
+ "POST",
417
+ url,
418
+ data=data,
419
+ headers=headers,
420
+ cookies=cookies,
421
+ params=params,
422
+ clienthello=clienthello,
423
+ )
424
+
425
+ def put(
426
+ self,
427
+ url,
428
+ *,
429
+ data: (bytes | dict[str, str]) = b"",
430
+ headers: dict[str, str] = {},
431
+ cookies: dict[str, str] = {},
432
+ params: dict[str, str] = {},
433
+ clienthello: str = "",
434
+ ):
435
+ return self.request(
436
+ "PUT",
437
+ url,
438
+ data=data,
439
+ headers=headers,
440
+ cookies=cookies,
441
+ params=params,
442
+ clienthello=clienthello,
443
+ )
curlinate-0.0.1/go.mod ADDED
@@ -0,0 +1,18 @@
1
+ module github.com/radian-software/curlinate
2
+
3
+ go 1.20
4
+
5
+ require github.com/alecthomas/kong v0.7.1
6
+
7
+ require (
8
+ github.com/andybalholm/brotli v1.0.5 // indirect
9
+ github.com/cloudflare/circl v1.3.3 // indirect
10
+ github.com/gaukas/godicttls v0.0.4 // indirect
11
+ github.com/klauspost/compress v1.16.7 // indirect
12
+ github.com/quic-go/quic-go v0.37.4 // indirect
13
+ github.com/refraction-networking/utls v1.5.2 // indirect
14
+ golang.org/x/crypto v0.12.0 // indirect
15
+ golang.org/x/net v0.14.0 // indirect
16
+ golang.org/x/sys v0.11.0 // indirect
17
+ golang.org/x/text v0.12.0 // indirect
18
+ )
curlinate-0.0.1/go.sum ADDED
@@ -0,0 +1,25 @@
1
+ github.com/alecthomas/assert/v2 v2.1.0 h1:tbredtNcQnoSd3QBhQWI7QZ3XHOVkw1Moklp2ojoH/0=
2
+ github.com/alecthomas/kong v0.7.1 h1:azoTh0IOfwlAX3qN9sHWTxACE2oV8Bg2gAwBsMwDQY4=
3
+ github.com/alecthomas/kong v0.7.1/go.mod h1:n1iCIO2xS46oE8ZfYCNDqdR0b0wZNrXAIAqro/2132U=
4
+ github.com/alecthomas/repr v0.1.0 h1:ENn2e1+J3k09gyj2shc0dHr/yjaWSHRlrJ4DPMevDqE=
5
+ github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs=
6
+ github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
7
+ github.com/cloudflare/circl v1.3.3 h1:fE/Qz0QdIGqeWfnwq0RE0R7MI51s0M2E4Ga9kq5AEMs=
8
+ github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA=
9
+ github.com/gaukas/godicttls v0.0.4 h1:NlRaXb3J6hAnTmWdsEKb9bcSBD6BvcIjdGdeb0zfXbk=
10
+ github.com/gaukas/godicttls v0.0.4/go.mod h1:l6EenT4TLWgTdwslVb4sEMOCf7Bv0JAK67deKr9/NCI=
11
+ github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
12
+ github.com/klauspost/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I=
13
+ github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
14
+ github.com/quic-go/quic-go v0.37.4 h1:ke8B73yMCWGq9MfrCCAw0Uzdm7GaViC3i39dsIdDlH4=
15
+ github.com/quic-go/quic-go v0.37.4/go.mod h1:YsbH1r4mSHPJcLF4k4zruUkLBqctEMBDR6VPvcYjIsU=
16
+ github.com/refraction-networking/utls v1.5.2 h1:l6diiLbEoRqdQ+/osPDO0z0lTc8O8VZV+p82N+Hi+ws=
17
+ github.com/refraction-networking/utls v1.5.2/go.mod h1:SPuDbBmgLGp8s+HLNc83FuavwZCFoMmExj+ltUHiHUw=
18
+ golang.org/x/crypto v0.12.0 h1:tFM/ta59kqch6LlvYnPa0yx5a83cL2nHflFhYKvv9Yk=
19
+ golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
20
+ golang.org/x/net v0.14.0 h1:BONx9s002vGdD9umnlX1Po8vOZmrgH34qlHcD1MfK14=
21
+ golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=
22
+ golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM=
23
+ golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
24
+ golang.org/x/text v0.12.0 h1:k+n5B8goJNdU7hSvEtMUz3d1Q6D/XW4COJSJR6fN0mc=
25
+ golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
@@ -0,0 +1,152 @@
1
+ # Yeah, I wrote my own fucking PEP517 build backend. Because after
2
+ # spending too many goddamn hours figuring out how to coax setuptools
3
+ # into installing binaries properly it turned out to be less work to
4
+ # reimplement the entire fucking thing. https://xkcd.com/1987/ seems
5
+ # to be as true as ever. And don't even get me started on trying to do
6
+ # it with Poetry, that managed to be even worse.
7
+ #
8
+ # I will say the folks who wrote https://peps.python.org/pep-0517/ did
9
+ # an amazing job though. That was the only sane document I read in this
10
+ # entire many-hour journey. https://pypa-build.readthedocs.io/en/stable/
11
+ # is great too, and https://peps.python.org/pep-0660/ is eminently reasonable.
12
+
13
+ import base64
14
+ import hashlib
15
+ import os
16
+ import pathlib
17
+ import shlex
18
+ import shutil
19
+ import subprocess
20
+ import tarfile
21
+ import tempfile
22
+
23
+
24
+ VERSION = "0.0.1"
25
+ TAG = "py3-none-manylinux2014"
26
+
27
+
28
+ def _get_pkg_info():
29
+ return (
30
+ f"""
31
+ Metadata-Version: 2.1
32
+ Name: curlinate
33
+ Version: {VERSION}
34
+ Summary: Command-line utility and Python library to simplify TLS fingerprint forgery
35
+ Home-page: https://github.com/radian-software/curlinate
36
+ Author: Radian LLC
37
+ Maintainer: Radian LLC
38
+ Maintainer-email: contact+curlinate@radian.codes
39
+ License: MIT
40
+ Download-URL: https://pypi.python.org/pypi/curlinate
41
+ Project-URL: Bug Tracker, https://github.com/radian-software/curlinate/issues
42
+ Project-URL: Documentation, https://github.com/radian-software/curlinate
43
+ Project-URL: Source Code, https://github.com/radian-software/curlinate
44
+ Requires-Python: >=3.8,<4.0
45
+ """.strip()
46
+ + "\n"
47
+ )
48
+
49
+
50
+ def build_sdist(sdist_directory, *_):
51
+ with tempfile.TemporaryDirectory() as tmpdir:
52
+ workdir = pathlib.Path(tmpdir) / f"curlinate-{VERSION}"
53
+ workdir.mkdir()
54
+ for fname in (
55
+ "pyproject.toml",
56
+ "pybuild/build.py",
57
+ "curlinate.py",
58
+ "curlinate.go",
59
+ "go.mod",
60
+ "go.sum",
61
+ ):
62
+ (workdir / fname).parent.mkdir(exist_ok=True, parents=True)
63
+ shutil.copyfile(fname, workdir / fname)
64
+ with open(workdir / "PKG-INFO", "w") as f:
65
+ f.write(_get_pkg_info())
66
+ tar_path = pathlib.Path(sdist_directory) / f"curlinate-{VERSION}.tar.gz"
67
+ with tarfile.open(tar_path, "w:gz") as tar:
68
+ tar.add(workdir, arcname=workdir.name)
69
+ return tar_path.name
70
+
71
+
72
+ def walk(root: pathlib.Path):
73
+ yield root
74
+ if root.is_dir():
75
+ for child in root.iterdir():
76
+ yield from walk(child)
77
+
78
+
79
+ def hash_file(fname: os.PathLike):
80
+ h = hashlib.sha256()
81
+ with open(fname, "rb") as f:
82
+ for chunk in iter(lambda: f.read(4096), b""):
83
+ h.update(chunk)
84
+ return base64.urlsafe_b64encode(h.digest()).decode().rstrip("=")
85
+
86
+
87
+ def build_wheel(wheel_directory, *_, editable=False):
88
+ with tempfile.TemporaryDirectory() as tmpdir:
89
+ workdir = pathlib.Path(tmpdir)
90
+ dist_info = workdir / f"curlinate-{VERSION}.dist-info"
91
+ dist_info.mkdir()
92
+ scripts_dir = workdir / f"curlinate-{VERSION}.data" / "scripts"
93
+ scripts_dir.mkdir(parents=True)
94
+ if editable:
95
+ with open(workdir / "curlinate_editable.pth", "w") as f:
96
+ f.write(os.getcwd() + "\n")
97
+ subprocess.run(["go", "build", "."], check=True)
98
+ with open(scripts_dir / "curlinate", "w") as f:
99
+ f.write(
100
+ f"""
101
+ #!/bin/sh
102
+ exec {shlex.quote(os.getcwd())}/curlinate "$@"
103
+ """.strip()
104
+ + "\n"
105
+ )
106
+ (scripts_dir / "curlinate").chmod(0o755)
107
+ else:
108
+ shutil.copy("curlinate.py", workdir / "curlinate.py")
109
+ subprocess.run(
110
+ ["go", "build", "-o", scripts_dir / "curlinate", "."], check=True
111
+ )
112
+ with open(dist_info / "METADATA", "w") as f:
113
+ f.write(_get_pkg_info())
114
+ with open(dist_info / "WHEEL", "w") as f:
115
+ f.write(
116
+ f"""
117
+ Wheel-Version: 1.0
118
+ Generator: some horrifying bullshit you do not want to know about
119
+ Root-Is-Purelib: false
120
+ Tag: {TAG}
121
+ """.strip()
122
+ + "\n"
123
+ )
124
+ with open(dist_info / "RECORD", "w") as f:
125
+ for item in walk(workdir):
126
+ if not item.is_file():
127
+ continue
128
+ if item.name == "RECORD":
129
+ continue
130
+ f.write(
131
+ ",".join(
132
+ [
133
+ str(item.relative_to(workdir)),
134
+ "sha256=" + hash_file(item),
135
+ str(item.stat().st_size),
136
+ ]
137
+ )
138
+ + "\n"
139
+ )
140
+ f.write(f"{dist_info.relative_to(workdir)}/RECORD,,\n")
141
+ zip_base = pathlib.Path(wheel_directory) / f"curlinate-{VERSION}-{TAG}"
142
+ shutil.make_archive(str(zip_base), "zip", workdir)
143
+ # No way to set the output filename for shutil, must rename
144
+ # afterwards.
145
+ zip_path = zip_base.with_name(zip_base.name + ".zip")
146
+ whl_path = zip_base.with_name(zip_base.name + ".whl")
147
+ zip_path.rename(whl_path)
148
+ return whl_path.name
149
+
150
+
151
+ def build_editable(wheel_directory, *args, **kwargs):
152
+ return build_wheel(wheel_directory, *args, **kwargs, editable=True)
@@ -0,0 +1,5 @@
1
+ [build-system]
2
+
3
+ requires = []
4
+ build-backend = "build"
5
+ backend-path = ["pybuild"]