nios-apps 0.1.0__py3-none-any.whl
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.
nios/__init__.py
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import base64
|
|
3
|
+
import http.client
|
|
4
|
+
import json
|
|
5
|
+
import struct
|
|
6
|
+
import threading
|
|
7
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
8
|
+
|
|
9
|
+
import websockets
|
|
10
|
+
|
|
11
|
+
__all__ = ["app", "polling", "App", "NiosError"]
|
|
12
|
+
__version__ = "0.1.0"
|
|
13
|
+
|
|
14
|
+
DEFAULT_SERVER = "wss://ni-os.ru/apps/_/connect"
|
|
15
|
+
|
|
16
|
+
T_OPEN, T_DATA, T_CLOSE, T_UDP, T_WSOPEN, T_WSTEXT, T_WSBIN, T_WSCLOSE = range(1, 9)
|
|
17
|
+
HEADER = struct.Struct(">BI")
|
|
18
|
+
LOOPBACK = {"127.0.0.1", "localhost", "::1"}
|
|
19
|
+
CHUNK = 16384
|
|
20
|
+
|
|
21
|
+
_default = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class NiosError(Exception):
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def pack(kind, sid, payload=b""):
|
|
29
|
+
return HEADER.pack(kind, sid) + payload
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class App:
|
|
33
|
+
def __init__(self, key, port=8000, tcp=None, udp=None, host="127.0.0.1",
|
|
34
|
+
server=DEFAULT_SERVER, workers=16, quiet=False):
|
|
35
|
+
if not isinstance(key, str) or not key.startswith("nios_app_"):
|
|
36
|
+
raise NiosError("API key must start with nios_app_")
|
|
37
|
+
if host not in LOOPBACK:
|
|
38
|
+
raise NiosError("only localhost targets are allowed")
|
|
39
|
+
self.key = key
|
|
40
|
+
self.port = int(port) if port else None
|
|
41
|
+
self.tcp = int(tcp) if tcp else None
|
|
42
|
+
self.udp = int(udp) if udp else None
|
|
43
|
+
self.host = host
|
|
44
|
+
self.server = server
|
|
45
|
+
self.quiet = quiet
|
|
46
|
+
self.url = None
|
|
47
|
+
self.tcp_address = None
|
|
48
|
+
self.udp_address = None
|
|
49
|
+
self._pool = ThreadPoolExecutor(max_workers=workers)
|
|
50
|
+
self._stop = threading.Event()
|
|
51
|
+
self._streams = {}
|
|
52
|
+
self._ws = None
|
|
53
|
+
|
|
54
|
+
def _log(self, *parts):
|
|
55
|
+
if not self.quiet:
|
|
56
|
+
print("[nios]", *parts, flush=True)
|
|
57
|
+
|
|
58
|
+
def _forward(self, msg):
|
|
59
|
+
path = msg.get("path") or "/"
|
|
60
|
+
if msg.get("query"):
|
|
61
|
+
path += "?" + msg["query"]
|
|
62
|
+
headers = dict(msg.get("headers") or {})
|
|
63
|
+
headers["Host"] = f"{self.host}:{self.port}"
|
|
64
|
+
headers["Accept-Encoding"] = "identity"
|
|
65
|
+
body = base64.b64decode(msg.get("body") or "")
|
|
66
|
+
conn = http.client.HTTPConnection(self.host, self.port, timeout=25)
|
|
67
|
+
try:
|
|
68
|
+
conn.request(msg["method"], path, body=body or None, headers=headers)
|
|
69
|
+
res = conn.getresponse()
|
|
70
|
+
limit = int(msg.get("max") or 0)
|
|
71
|
+
data = res.read(limit + 1) if limit else res.read()
|
|
72
|
+
if limit and len(data) > limit:
|
|
73
|
+
return {"t": "err", "id": msg["id"], "msg": "response exceeds the remaining traffic budget"}
|
|
74
|
+
out = {}
|
|
75
|
+
for name, value in res.getheaders():
|
|
76
|
+
out[name] = f"{out[name]}, {value}" if name in out else value
|
|
77
|
+
return {"t": "res", "id": msg["id"], "status": res.status, "headers": out,
|
|
78
|
+
"body": base64.b64encode(data).decode("ascii")}
|
|
79
|
+
except OSError as exc:
|
|
80
|
+
return {"t": "err", "id": msg["id"], "msg": f"localhost:{self.port} unreachable ({exc.__class__.__name__})"}
|
|
81
|
+
finally:
|
|
82
|
+
conn.close()
|
|
83
|
+
|
|
84
|
+
async def _send(self, kind, sid, payload=b""):
|
|
85
|
+
if self._ws is not None:
|
|
86
|
+
await self._ws.send(pack(kind, sid, payload))
|
|
87
|
+
|
|
88
|
+
async def _tcp_open(self, sid, queue):
|
|
89
|
+
if not self.tcp:
|
|
90
|
+
self._streams.pop(sid, None)
|
|
91
|
+
await self._send(T_CLOSE, sid)
|
|
92
|
+
return
|
|
93
|
+
try:
|
|
94
|
+
reader, writer = await asyncio.open_connection(self.host, self.tcp)
|
|
95
|
+
except OSError:
|
|
96
|
+
self._streams.pop(sid, None)
|
|
97
|
+
await self._send(T_CLOSE, sid)
|
|
98
|
+
return
|
|
99
|
+
|
|
100
|
+
async def pump_in():
|
|
101
|
+
while True:
|
|
102
|
+
data = await reader.read(CHUNK)
|
|
103
|
+
if not data:
|
|
104
|
+
return
|
|
105
|
+
await self._send(T_DATA, sid, data)
|
|
106
|
+
|
|
107
|
+
async def pump_out():
|
|
108
|
+
while True:
|
|
109
|
+
kind, payload = await queue.get()
|
|
110
|
+
if kind != T_DATA:
|
|
111
|
+
return
|
|
112
|
+
writer.write(payload)
|
|
113
|
+
await writer.drain()
|
|
114
|
+
|
|
115
|
+
tasks = [asyncio.ensure_future(pump_in()), asyncio.ensure_future(pump_out())]
|
|
116
|
+
try:
|
|
117
|
+
await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
|
|
118
|
+
finally:
|
|
119
|
+
for task in tasks:
|
|
120
|
+
task.cancel()
|
|
121
|
+
self._streams.pop(sid, None)
|
|
122
|
+
writer.close()
|
|
123
|
+
await self._send(T_CLOSE, sid)
|
|
124
|
+
|
|
125
|
+
async def _udp_peer(self, sid, first):
|
|
126
|
+
if not self.udp:
|
|
127
|
+
self._streams.pop(sid, None)
|
|
128
|
+
return
|
|
129
|
+
loop = asyncio.get_running_loop()
|
|
130
|
+
queue = self._streams[sid]
|
|
131
|
+
outer = self
|
|
132
|
+
|
|
133
|
+
class Relay(asyncio.DatagramProtocol):
|
|
134
|
+
def datagram_received(self, data, addr):
|
|
135
|
+
asyncio.ensure_future(outer._send(T_UDP, sid, data))
|
|
136
|
+
|
|
137
|
+
transport, _ = await loop.create_datagram_endpoint(Relay, remote_addr=(self.host, self.udp))
|
|
138
|
+
try:
|
|
139
|
+
transport.sendto(first)
|
|
140
|
+
while True:
|
|
141
|
+
kind, payload = await queue.get()
|
|
142
|
+
if kind != T_UDP:
|
|
143
|
+
return
|
|
144
|
+
transport.sendto(payload)
|
|
145
|
+
finally:
|
|
146
|
+
transport.close()
|
|
147
|
+
self._streams.pop(sid, None)
|
|
148
|
+
|
|
149
|
+
async def _ws_open(self, sid, info):
|
|
150
|
+
if not self.port:
|
|
151
|
+
await self._send(T_WSOPEN, sid, b'{"ok":false}')
|
|
152
|
+
return
|
|
153
|
+
uri = f"ws://{self.host}:{self.port}{info.get('path') or '/'}"
|
|
154
|
+
if info.get("query"):
|
|
155
|
+
uri += "?" + info["query"]
|
|
156
|
+
try:
|
|
157
|
+
local = await websockets.connect(uri, subprotocols=info.get("subprotocols") or None,
|
|
158
|
+
max_size=1024 * 1024, open_timeout=8)
|
|
159
|
+
except Exception:
|
|
160
|
+
await self._send(T_WSOPEN, sid, b'{"ok":false}')
|
|
161
|
+
return
|
|
162
|
+
queue = asyncio.Queue()
|
|
163
|
+
self._streams[sid] = queue
|
|
164
|
+
await self._send(T_WSOPEN, sid, json.dumps({"ok": True, "subprotocol": local.subprotocol}).encode())
|
|
165
|
+
|
|
166
|
+
async def pump_in():
|
|
167
|
+
async for message in local:
|
|
168
|
+
if isinstance(message, str):
|
|
169
|
+
await self._send(T_WSTEXT, sid, message.encode())
|
|
170
|
+
else:
|
|
171
|
+
await self._send(T_WSBIN, sid, message)
|
|
172
|
+
|
|
173
|
+
async def pump_out():
|
|
174
|
+
while True:
|
|
175
|
+
kind, payload = await queue.get()
|
|
176
|
+
if kind == T_WSTEXT:
|
|
177
|
+
await local.send(payload.decode("utf-8", "replace"))
|
|
178
|
+
elif kind == T_WSBIN:
|
|
179
|
+
await local.send(payload)
|
|
180
|
+
else:
|
|
181
|
+
return
|
|
182
|
+
|
|
183
|
+
tasks = [asyncio.ensure_future(pump_in()), asyncio.ensure_future(pump_out())]
|
|
184
|
+
try:
|
|
185
|
+
await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
|
|
186
|
+
finally:
|
|
187
|
+
for task in tasks:
|
|
188
|
+
task.cancel()
|
|
189
|
+
self._streams.pop(sid, None)
|
|
190
|
+
await local.close()
|
|
191
|
+
await self._send(T_WSCLOSE, sid)
|
|
192
|
+
|
|
193
|
+
def _binary(self, raw):
|
|
194
|
+
kind, sid = HEADER.unpack_from(raw)
|
|
195
|
+
payload = raw[HEADER.size:]
|
|
196
|
+
if kind == T_OPEN:
|
|
197
|
+
queue = self._streams[sid] = asyncio.Queue()
|
|
198
|
+
asyncio.ensure_future(self._tcp_open(sid, queue))
|
|
199
|
+
elif kind == T_WSOPEN:
|
|
200
|
+
asyncio.ensure_future(self._ws_open(sid, json.loads(payload)))
|
|
201
|
+
elif kind == T_UDP and sid not in self._streams:
|
|
202
|
+
self._streams[sid] = asyncio.Queue()
|
|
203
|
+
asyncio.ensure_future(self._udp_peer(sid, payload))
|
|
204
|
+
else:
|
|
205
|
+
queue = self._streams.get(sid)
|
|
206
|
+
if queue is not None:
|
|
207
|
+
if kind in (T_CLOSE, T_WSCLOSE):
|
|
208
|
+
queue.put_nowait((T_CLOSE, b""))
|
|
209
|
+
else:
|
|
210
|
+
queue.put_nowait((kind, payload))
|
|
211
|
+
|
|
212
|
+
async def _session(self):
|
|
213
|
+
async with websockets.connect(self.server, max_size=16 * 1024 * 1024,
|
|
214
|
+
ping_interval=20, ping_timeout=30) as ws:
|
|
215
|
+
self._ws = ws
|
|
216
|
+
await ws.send(json.dumps({"t": "auth", "key": self.key}))
|
|
217
|
+
loop = asyncio.get_running_loop()
|
|
218
|
+
try:
|
|
219
|
+
async for raw in ws:
|
|
220
|
+
if isinstance(raw, bytes):
|
|
221
|
+
self._binary(raw)
|
|
222
|
+
continue
|
|
223
|
+
msg = json.loads(raw)
|
|
224
|
+
kind = msg.get("t")
|
|
225
|
+
if kind == "hello":
|
|
226
|
+
self.url = msg["url"]
|
|
227
|
+
self.tcp_address = msg.get("tcp")
|
|
228
|
+
self.udp_address = msg.get("udp")
|
|
229
|
+
self._log(f"online: {self.url}")
|
|
230
|
+
if self.tcp_address:
|
|
231
|
+
self._log(f"tcp: {self.tcp_address['host']}:{self.tcp_address['port']}")
|
|
232
|
+
if self.udp_address:
|
|
233
|
+
self._log(f"udp: {self.udp_address['host']}:{self.udp_address['port']}")
|
|
234
|
+
elif kind == "error":
|
|
235
|
+
raise NiosError(msg.get("msg", "connection refused"))
|
|
236
|
+
elif kind == "req":
|
|
237
|
+
loop.create_task(self._serve(ws, msg))
|
|
238
|
+
finally:
|
|
239
|
+
self._ws = None
|
|
240
|
+
for queue in list(self._streams.values()):
|
|
241
|
+
queue.put_nowait((T_CLOSE, b""))
|
|
242
|
+
|
|
243
|
+
async def _serve(self, ws, msg):
|
|
244
|
+
loop = asyncio.get_running_loop()
|
|
245
|
+
reply = await loop.run_in_executor(self._pool, self._forward, msg)
|
|
246
|
+
await ws.send(json.dumps(reply))
|
|
247
|
+
|
|
248
|
+
async def _run(self):
|
|
249
|
+
delay = 1.0
|
|
250
|
+
while not self._stop.is_set():
|
|
251
|
+
try:
|
|
252
|
+
await self._session()
|
|
253
|
+
delay = 1.0
|
|
254
|
+
except NiosError as exc:
|
|
255
|
+
self._log("refused:", exc)
|
|
256
|
+
raise
|
|
257
|
+
except websockets.exceptions.ConnectionClosed as exc:
|
|
258
|
+
if exc.rcvd and exc.rcvd.code in (4401, 4404):
|
|
259
|
+
raise NiosError(exc.rcvd.reason or "key is invalid or the app was deleted") from None
|
|
260
|
+
self._log("connection closed, reconnecting")
|
|
261
|
+
except (OSError, websockets.exceptions.WebSocketException) as exc:
|
|
262
|
+
self._log(f"no connection ({exc.__class__.__name__}), retry in {delay:.0f}s")
|
|
263
|
+
await asyncio.sleep(delay)
|
|
264
|
+
delay = min(delay * 2, 30.0)
|
|
265
|
+
|
|
266
|
+
def polling(self, background=False):
|
|
267
|
+
if background:
|
|
268
|
+
thread = threading.Thread(target=lambda: asyncio.run(self._run()), daemon=True, name="nios-polling")
|
|
269
|
+
thread.start()
|
|
270
|
+
return thread
|
|
271
|
+
try:
|
|
272
|
+
asyncio.run(self._run())
|
|
273
|
+
except KeyboardInterrupt:
|
|
274
|
+
self._log("stopped")
|
|
275
|
+
|
|
276
|
+
def stop(self):
|
|
277
|
+
self._stop.set()
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def app(key, port=8000, **options):
|
|
281
|
+
global _default
|
|
282
|
+
_default = App(key, port, **options)
|
|
283
|
+
return _default
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def polling(background=False):
|
|
287
|
+
if _default is None:
|
|
288
|
+
raise NiosError("call nios.app(key, port=...) first")
|
|
289
|
+
return _default.polling(background)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nios-apps
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Expose localhost at ni-os.ru/apps/<name> (Nios Apps tunnel client)
|
|
5
|
+
Project-URL: Homepage, https://ni-os.ru/apps
|
|
6
|
+
Classifier: Programming Language :: Python :: 3
|
|
7
|
+
Classifier: Topic :: Internet :: Proxy Servers
|
|
8
|
+
Classifier: Operating System :: OS Independent
|
|
9
|
+
Requires-Python: >=3.9
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
Requires-Dist: websockets>=12
|
|
12
|
+
|
|
13
|
+
# nios-apps
|
|
14
|
+
|
|
15
|
+
Expose a local server at `https://ni-os.ru/apps/<name>`.
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
import nios
|
|
19
|
+
|
|
20
|
+
nios.app("nios_app_...", port=8000, tcp=25565, udp=19132)
|
|
21
|
+
nios.polling()
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
HTTP and WebSocket requests arrive at `http://127.0.0.1:<port>`. Raw TCP and UDP traffic is forwarded to the `tcp` and `udp` local ports once a public port is opened in the cabinet at https://ni-os.ru/appsdev.
|
|
25
|
+
|
|
26
|
+
Traffic limits are per Nios ID account. Rules: https://ni-os.ru/apps#policy
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
nios/__init__.py,sha256=0NLYLShg7_UOqHgRTay8rBUdDCS09ujFyO-c5k5E_tk,10808
|
|
2
|
+
nios_apps-0.1.0.dist-info/METADATA,sha256=d_Qg-EIuCgHoBDzLwByv0Qv_wAKiXsL4N-VNeSSE0zE,869
|
|
3
|
+
nios_apps-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
4
|
+
nios_apps-0.1.0.dist-info/top_level.txt,sha256=q9mlf8qzgqxzD0MpiuNoLCrtyUvcQxkr9VgJjd5qoJw,5
|
|
5
|
+
nios_apps-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
nios
|