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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ nios