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.
- curlinate-0.0.1/PKG-INFO +14 -0
- curlinate-0.0.1/curlinate.go +253 -0
- curlinate-0.0.1/curlinate.py +443 -0
- curlinate-0.0.1/go.mod +18 -0
- curlinate-0.0.1/go.sum +25 -0
- curlinate-0.0.1/pybuild/build.py +152 -0
- curlinate-0.0.1/pyproject.toml +5 -0
curlinate-0.0.1/PKG-INFO
ADDED
|
@@ -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)
|