funnel-cli 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.
- funnel_cli-0.1.0/.gitignore +4 -0
- funnel_cli-0.1.0/.python-version +1 -0
- funnel_cli-0.1.0/Justfile +24 -0
- funnel_cli-0.1.0/LICENSE +21 -0
- funnel_cli-0.1.0/PKG-INFO +8 -0
- funnel_cli-0.1.0/README.md +0 -0
- funnel_cli-0.1.0/client/__init__.py +0 -0
- funnel_cli-0.1.0/client/client.py +289 -0
- funnel_cli-0.1.0/docker-compose.yml +16 -0
- funnel_cli-0.1.0/infra/Caddyfile +4 -0
- funnel_cli-0.1.0/infra/Dockerfile +2 -0
- funnel_cli-0.1.0/pyproject.toml +18 -0
- funnel_cli-0.1.0/server/Dockerfile +5 -0
- funnel_cli-0.1.0/server/__init__.py +0 -0
- funnel_cli-0.1.0/server/server.py +233 -0
- funnel_cli-0.1.0/server/templates/404.html +97 -0
- funnel_cli-0.1.0/server/templates/home.html +133 -0
- funnel_cli-0.1.0/server/util.py +88 -0
- funnel_cli-0.1.0/shared/__init__.py +0 -0
- funnel_cli-0.1.0/shared/lib.py +58 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.14
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
DROPLET_IP := "165.22.130.48"
|
|
2
|
+
USER := "root@" + DROPLET_IP
|
|
3
|
+
|
|
4
|
+
build-client:
|
|
5
|
+
rm -rf dist/
|
|
6
|
+
uv build
|
|
7
|
+
|
|
8
|
+
publish-client: build-client
|
|
9
|
+
uv publish
|
|
10
|
+
|
|
11
|
+
login:
|
|
12
|
+
ssh -i ~/.ssh/funnel {{USER}}
|
|
13
|
+
|
|
14
|
+
sync:
|
|
15
|
+
rsync -e "ssh -i ~/.ssh/funnel" -r . root@165.22.130.48:~/funnel
|
|
16
|
+
|
|
17
|
+
configure-ssl:
|
|
18
|
+
ssh {{USER}} " \
|
|
19
|
+
sudo apt update && \
|
|
20
|
+
sudo apt install -y certbot python3-certbot-dns-digitalocean && \
|
|
21
|
+
echo 'dns_digitalocean_token=$DO_API_TOKEN' > /root/certbot-creds.ini && \
|
|
22
|
+
chmod go-rwx ~/certbot-creds.ini && \
|
|
23
|
+
sudo certbot certonly -v --dns-digitalocean --dns-digitalocean-credentials ~/certbot-creds.ini -d funnel.delivery -d '*.funnel.delivery' --non-interactive --agree-tos -m aydinschwa@gmail.com
|
|
24
|
+
"
|
funnel_cli-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Aydin
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import asyncio
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
|
|
5
|
+
from rich.text import Text
|
|
6
|
+
from textual import work
|
|
7
|
+
from textual.app import App, ComposeResult
|
|
8
|
+
from textual.reactive import reactive
|
|
9
|
+
from textual.widgets import DataTable, Static
|
|
10
|
+
|
|
11
|
+
from shared.lib import (
|
|
12
|
+
FunnelMessage,
|
|
13
|
+
FunnelMessageType,
|
|
14
|
+
forward,
|
|
15
|
+
read_message,
|
|
16
|
+
write_message,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
HOST = "funnel.delivery"
|
|
20
|
+
PORT = 8080
|
|
21
|
+
DEFAULT_LOCAL_PORT = 8000
|
|
22
|
+
|
|
23
|
+
METHOD_COLORS: dict[str, str] = {
|
|
24
|
+
"GET": "green",
|
|
25
|
+
"POST": "#3b82f6",
|
|
26
|
+
"PUT": "yellow",
|
|
27
|
+
"DELETE": "red",
|
|
28
|
+
"PATCH": "cyan",
|
|
29
|
+
"HEAD": "magenta",
|
|
30
|
+
"OPTIONS": "dim white",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class TunnelInfo(Static):
|
|
35
|
+
"""Header panel showing tunnel URL, forwarding target, and connection status."""
|
|
36
|
+
|
|
37
|
+
subdomain = reactive("")
|
|
38
|
+
local_port = reactive(DEFAULT_LOCAL_PORT)
|
|
39
|
+
connected = reactive(False)
|
|
40
|
+
|
|
41
|
+
waiting_for_local = reactive(False)
|
|
42
|
+
|
|
43
|
+
def render(self) -> str:
|
|
44
|
+
if self.waiting_for_local:
|
|
45
|
+
status = "[yellow]● Waiting for local service…[/yellow]"
|
|
46
|
+
url = f"[dim]nothing on localhost:{self.local_port}[/dim]"
|
|
47
|
+
elif not self.subdomain:
|
|
48
|
+
status = "[yellow]● Connecting…[/yellow]"
|
|
49
|
+
url = "[dim]waiting for server…[/dim]"
|
|
50
|
+
elif self.connected:
|
|
51
|
+
status = "[green]● Connected[/green]"
|
|
52
|
+
url = f"[bold]https://{self.subdomain}.{HOST}[/bold]"
|
|
53
|
+
else:
|
|
54
|
+
status = "[red]● Disconnected[/red]"
|
|
55
|
+
url = f"[dim]{self.subdomain}.{HOST}[/dim]"
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
f" Tunnel {url}\n"
|
|
59
|
+
f" Forward [bold]http://localhost:{self.local_port}[/bold]\n"
|
|
60
|
+
f" Status {status}"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class RequestCount(Static):
|
|
65
|
+
"""Section header that tracks total number of forwarded requests."""
|
|
66
|
+
|
|
67
|
+
count = reactive(0)
|
|
68
|
+
|
|
69
|
+
def render(self) -> str:
|
|
70
|
+
return f" Requests ({self.count} total)"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class FunnelApp(App):
|
|
74
|
+
"""Funnel tunnel client TUI."""
|
|
75
|
+
|
|
76
|
+
TITLE = "Funnel"
|
|
77
|
+
ENABLE_COMMAND_PALETTE = False
|
|
78
|
+
|
|
79
|
+
CSS = """
|
|
80
|
+
#tunnel-info {
|
|
81
|
+
height: auto;
|
|
82
|
+
padding: 1 0;
|
|
83
|
+
background: $surface;
|
|
84
|
+
border-bottom: solid $primary;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
#request-count {
|
|
88
|
+
height: 1;
|
|
89
|
+
background: $boost;
|
|
90
|
+
text-style: bold;
|
|
91
|
+
color: $text-muted;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
#request-table {
|
|
95
|
+
height: 1fr;
|
|
96
|
+
}
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
def __init__(
|
|
100
|
+
self,
|
|
101
|
+
subdomain: str | None = None,
|
|
102
|
+
local_port: int = DEFAULT_LOCAL_PORT,
|
|
103
|
+
):
|
|
104
|
+
super().__init__()
|
|
105
|
+
self._subdomain_request = subdomain
|
|
106
|
+
self._local_port = local_port
|
|
107
|
+
self._control_writer: asyncio.StreamWriter | None = None
|
|
108
|
+
|
|
109
|
+
def compose(self) -> ComposeResult:
|
|
110
|
+
info = TunnelInfo(id="tunnel-info")
|
|
111
|
+
info.local_port = self._local_port
|
|
112
|
+
yield info
|
|
113
|
+
yield RequestCount(id="request-count")
|
|
114
|
+
yield DataTable(id="request-table")
|
|
115
|
+
|
|
116
|
+
def on_mount(self) -> None:
|
|
117
|
+
table = self.query_one("#request-table", DataTable)
|
|
118
|
+
table.add_columns("Time", "Method", "Path", "ID")
|
|
119
|
+
table.cursor_type = "none"
|
|
120
|
+
self._run_tunnel()
|
|
121
|
+
self._monitor_local_service()
|
|
122
|
+
|
|
123
|
+
@work(exclusive=True)
|
|
124
|
+
async def _run_tunnel(self) -> None:
|
|
125
|
+
"""Background worker managing the tunnel control connection."""
|
|
126
|
+
info = self.query_one("#tunnel-info", TunnelInfo)
|
|
127
|
+
try:
|
|
128
|
+
await self._wait_for_local_service(info)
|
|
129
|
+
reader, writer = await asyncio.open_connection(HOST, PORT)
|
|
130
|
+
self._control_writer = writer
|
|
131
|
+
|
|
132
|
+
await write_message(
|
|
133
|
+
writer,
|
|
134
|
+
FunnelMessage(
|
|
135
|
+
type=FunnelMessageType.CONNECT,
|
|
136
|
+
proxy_subdomain=self._subdomain_request,
|
|
137
|
+
),
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
accept = await read_message(reader)
|
|
141
|
+
if accept.type == FunnelMessageType.ACCEPT:
|
|
142
|
+
info.subdomain = accept.proxy_subdomain or ""
|
|
143
|
+
info.connected = True
|
|
144
|
+
|
|
145
|
+
while True:
|
|
146
|
+
msg = await read_message(reader)
|
|
147
|
+
if msg.type == FunnelMessageType.HEARTBEAT:
|
|
148
|
+
continue
|
|
149
|
+
elif msg.type == FunnelMessageType.INCOMING:
|
|
150
|
+
self._record_request(msg)
|
|
151
|
+
|
|
152
|
+
except Exception:
|
|
153
|
+
info.connected = False
|
|
154
|
+
|
|
155
|
+
def _record_request(self, message: FunnelMessage) -> None:
|
|
156
|
+
"""Parse an INCOMING message and append it to the request table."""
|
|
157
|
+
method, path = "???", "/"
|
|
158
|
+
if message.request_line:
|
|
159
|
+
parts = message.request_line.split()
|
|
160
|
+
if len(parts) >= 2:
|
|
161
|
+
method, path = parts[0], parts[1]
|
|
162
|
+
|
|
163
|
+
timestamp = datetime.now().strftime("%H:%M:%S")
|
|
164
|
+
color = METHOD_COLORS.get(method.upper(), "white")
|
|
165
|
+
styled_method = Text(f" {method:<7}", style=f"bold {color}")
|
|
166
|
+
conn_short = (message.connection_id or "")[:8]
|
|
167
|
+
|
|
168
|
+
table = self.query_one("#request-table", DataTable)
|
|
169
|
+
table.add_row(timestamp, styled_method, path, conn_short)
|
|
170
|
+
table.scroll_end(animate=False)
|
|
171
|
+
|
|
172
|
+
counter = self.query_one("#request-count", RequestCount)
|
|
173
|
+
counter.count += 1
|
|
174
|
+
|
|
175
|
+
if message.connection_id:
|
|
176
|
+
asyncio.create_task(self._handle_data_connection(message.connection_id))
|
|
177
|
+
|
|
178
|
+
async def _check_local_port(self) -> bool:
|
|
179
|
+
"""Return True if something is listening on the local forwarding port."""
|
|
180
|
+
try:
|
|
181
|
+
_, w = await asyncio.open_connection("localhost", self._local_port)
|
|
182
|
+
w.close()
|
|
183
|
+
await w.wait_closed()
|
|
184
|
+
return True
|
|
185
|
+
except OSError:
|
|
186
|
+
return False
|
|
187
|
+
|
|
188
|
+
async def _wait_for_local_service(self, info: TunnelInfo) -> None:
|
|
189
|
+
"""Block until something is listening on the local forwarding port."""
|
|
190
|
+
while not await self._check_local_port():
|
|
191
|
+
info.waiting_for_local = True
|
|
192
|
+
await asyncio.sleep(2)
|
|
193
|
+
info.waiting_for_local = False
|
|
194
|
+
|
|
195
|
+
@work(exclusive=True, group="local_monitor")
|
|
196
|
+
async def _monitor_local_service(self) -> None:
|
|
197
|
+
"""Periodically check that the local service is still reachable."""
|
|
198
|
+
info = self.query_one("#tunnel-info", TunnelInfo)
|
|
199
|
+
while True:
|
|
200
|
+
await asyncio.sleep(5)
|
|
201
|
+
alive = await self._check_local_port()
|
|
202
|
+
info.waiting_for_local = not alive
|
|
203
|
+
|
|
204
|
+
async def _handle_data_connection(self, connection_id: str) -> None:
|
|
205
|
+
"""Open a data connection to forward traffic for a single request."""
|
|
206
|
+
try:
|
|
207
|
+
local_r, local_w = await asyncio.open_connection(
|
|
208
|
+
"localhost", self._local_port
|
|
209
|
+
)
|
|
210
|
+
remote_r, remote_w = await asyncio.open_connection(HOST, PORT)
|
|
211
|
+
|
|
212
|
+
await write_message(
|
|
213
|
+
remote_w,
|
|
214
|
+
FunnelMessage(
|
|
215
|
+
type=FunnelMessageType.RECEIVE,
|
|
216
|
+
connection_id=connection_id,
|
|
217
|
+
),
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
await asyncio.gather(
|
|
221
|
+
forward(remote_r, local_w),
|
|
222
|
+
forward(local_r, remote_w),
|
|
223
|
+
)
|
|
224
|
+
except Exception:
|
|
225
|
+
pass
|
|
226
|
+
|
|
227
|
+
def action_help_quit(self) -> None:
|
|
228
|
+
self.exit()
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
HELP_TEXT = """\
|
|
232
|
+
\033[1mfunnel\033[0m - expose your local services to the internet
|
|
233
|
+
|
|
234
|
+
\033[1mUSAGE\033[0m
|
|
235
|
+
funnel [OPTIONS] <port>
|
|
236
|
+
|
|
237
|
+
\033[1mEXAMPLES\033[0m
|
|
238
|
+
funnel 3000
|
|
239
|
+
funnel 8080 --subdomain my-app
|
|
240
|
+
|
|
241
|
+
\033[1mOPTIONS\033[0m
|
|
242
|
+
-s, --subdomain <name> Request a specific subdomain (default: random)
|
|
243
|
+
-h, --help Show this help message
|
|
244
|
+
|
|
245
|
+
\033[1mHOW IT WORKS\033[0m
|
|
246
|
+
Funnel opens a tunnel to funnel.delivery and assigns you a public
|
|
247
|
+
URL like https://<subdomain>.funnel.delivery. Any HTTP traffic to
|
|
248
|
+
that URL is forwarded to your local service on the given port.
|
|
249
|
+
|
|
250
|
+
Press Ctrl+C to disconnect.
|
|
251
|
+
"""
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def main() -> None:
|
|
255
|
+
parser = argparse.ArgumentParser(
|
|
256
|
+
usage="funnel [OPTIONS] <port>",
|
|
257
|
+
add_help=False,
|
|
258
|
+
)
|
|
259
|
+
parser.add_argument(
|
|
260
|
+
"port",
|
|
261
|
+
nargs="?",
|
|
262
|
+
type=int,
|
|
263
|
+
default=DEFAULT_LOCAL_PORT,
|
|
264
|
+
help=argparse.SUPPRESS,
|
|
265
|
+
)
|
|
266
|
+
parser.add_argument(
|
|
267
|
+
"-s", "--subdomain",
|
|
268
|
+
type=str,
|
|
269
|
+
default=None,
|
|
270
|
+
help=argparse.SUPPRESS,
|
|
271
|
+
)
|
|
272
|
+
parser.add_argument(
|
|
273
|
+
"-h", "--help",
|
|
274
|
+
action="store_true",
|
|
275
|
+
default=False,
|
|
276
|
+
help=argparse.SUPPRESS,
|
|
277
|
+
)
|
|
278
|
+
args = parser.parse_args()
|
|
279
|
+
|
|
280
|
+
if args.help:
|
|
281
|
+
print(HELP_TEXT)
|
|
282
|
+
return
|
|
283
|
+
|
|
284
|
+
app = FunnelApp(subdomain=args.subdomain, local_port=args.port)
|
|
285
|
+
app.run()
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
if __name__ == "__main__":
|
|
289
|
+
main()
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "funnel-cli"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Expose your local services to the internet"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
requires-python = ">=3.14"
|
|
8
|
+
dependencies = ["textual"]
|
|
9
|
+
|
|
10
|
+
[build-system]
|
|
11
|
+
requires = ["hatchling"]
|
|
12
|
+
build-backend = "hatchling.build"
|
|
13
|
+
|
|
14
|
+
[tool.hatch.build.targets.wheel]
|
|
15
|
+
packages = ["client", "shared"]
|
|
16
|
+
|
|
17
|
+
[project.scripts]
|
|
18
|
+
funnel = "client.client:main"
|
|
File without changes
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
import uuid
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from shared.lib import (
|
|
7
|
+
forward,
|
|
8
|
+
FunnelMessage,
|
|
9
|
+
FunnelMessageType,
|
|
10
|
+
read_message,
|
|
11
|
+
write_message,
|
|
12
|
+
)
|
|
13
|
+
from server.util import generate_random_subdomain, send_html_response
|
|
14
|
+
|
|
15
|
+
logging.basicConfig(level=logging.INFO)
|
|
16
|
+
logger = logging.getLogger("funnel_server")
|
|
17
|
+
|
|
18
|
+
HOST = "0.0.0.0"
|
|
19
|
+
HTTP_PORT = 80
|
|
20
|
+
CLIENT_PORT = 8080
|
|
21
|
+
BASE_DOMAIN = "funnel.delivery"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class Tunnel:
|
|
26
|
+
reader: asyncio.StreamReader
|
|
27
|
+
writer: asyncio.StreamWriter
|
|
28
|
+
lock: asyncio.Lock
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class PendingConnection:
|
|
33
|
+
reader: asyncio.StreamReader
|
|
34
|
+
writer: asyncio.StreamWriter
|
|
35
|
+
proxy_subdomain: str
|
|
36
|
+
identifier_bytes: bytes
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
TUNNELS: dict[str, Tunnel] = {}
|
|
40
|
+
PENDING_CONNECTIONS: dict[str, PendingConnection] = {}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
async def handle_heartbeat(
|
|
44
|
+
proxy_subdomain: str, writer: asyncio.StreamWriter, lock: asyncio.Lock
|
|
45
|
+
) -> None:
|
|
46
|
+
"""
|
|
47
|
+
Periodically sends heartbeat messages to the funnel client to ensure the connection
|
|
48
|
+
is still alive. Cleans up the connection if the client becomes unreachable.
|
|
49
|
+
"""
|
|
50
|
+
try:
|
|
51
|
+
while True:
|
|
52
|
+
async with lock:
|
|
53
|
+
logger.info("Sending heartbeat")
|
|
54
|
+
await write_message(
|
|
55
|
+
writer,
|
|
56
|
+
FunnelMessage(type=FunnelMessageType.HEARTBEAT),
|
|
57
|
+
)
|
|
58
|
+
await asyncio.sleep(10)
|
|
59
|
+
|
|
60
|
+
# if heartbeat fails, clean up connection
|
|
61
|
+
except Exception as e:
|
|
62
|
+
logger.error(e)
|
|
63
|
+
writer.close()
|
|
64
|
+
del TUNNELS[proxy_subdomain]
|
|
65
|
+
to_remove = [
|
|
66
|
+
cid
|
|
67
|
+
for cid, conn in PENDING_CONNECTIONS.items()
|
|
68
|
+
if conn.proxy_subdomain == proxy_subdomain
|
|
69
|
+
]
|
|
70
|
+
for cid in to_remove:
|
|
71
|
+
PENDING_CONNECTIONS[cid].writer.close()
|
|
72
|
+
del PENDING_CONNECTIONS[cid]
|
|
73
|
+
logger.info(f"Cleaned up connection associated with {proxy_subdomain}")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
async def check_pending_connection(connection_id: str) -> None:
|
|
77
|
+
"""
|
|
78
|
+
Checks if a connection ID is still in the pool of pending connections.
|
|
79
|
+
Deletes the connection and shuts the writer down if it hasn't been
|
|
80
|
+
claimed within 10 seconds.
|
|
81
|
+
"""
|
|
82
|
+
await asyncio.sleep(10)
|
|
83
|
+
if connection_id in PENDING_CONNECTIONS:
|
|
84
|
+
logger.info(f"connection {connection_id} is unclaimed, cleaning it up")
|
|
85
|
+
pending_connection = PENDING_CONNECTIONS[connection_id]
|
|
86
|
+
pending_connection.writer.close()
|
|
87
|
+
del PENDING_CONNECTIONS[connection_id]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
async def handle_public(
|
|
91
|
+
public_reader: asyncio.StreamReader, public_writer: asyncio.StreamWriter
|
|
92
|
+
) -> None:
|
|
93
|
+
"""
|
|
94
|
+
Sets up a short-lived connection between a public request and the funnel server
|
|
95
|
+
and notifies the client of the connection
|
|
96
|
+
"""
|
|
97
|
+
try:
|
|
98
|
+
header_bytes = await public_reader.readuntil(b"\r\n\r\n")
|
|
99
|
+
header_lines = header_bytes.decode().split("\r\n")
|
|
100
|
+
request_line = header_lines[0]
|
|
101
|
+
headers = dict(line.split(": ", 1) for line in header_lines if ": " in line)
|
|
102
|
+
host = headers["Host"].split(":")[0].lower()
|
|
103
|
+
proxy_subdomain = host.split(".")[0]
|
|
104
|
+
|
|
105
|
+
if host == BASE_DOMAIN:
|
|
106
|
+
await send_html_response(public_writer, "home.html")
|
|
107
|
+
return
|
|
108
|
+
|
|
109
|
+
client_writer = TUNNELS[proxy_subdomain].writer
|
|
110
|
+
lock = TUNNELS[proxy_subdomain].lock
|
|
111
|
+
|
|
112
|
+
except Exception as e:
|
|
113
|
+
logger.error(f"failed to parse proxy subdomain with error {e}")
|
|
114
|
+
await send_html_response(
|
|
115
|
+
public_writer, "404.html", status=404, subdomain=proxy_subdomain
|
|
116
|
+
)
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
connection_id = str(uuid.uuid4())
|
|
120
|
+
PENDING_CONNECTIONS[connection_id] = PendingConnection(
|
|
121
|
+
reader=public_reader,
|
|
122
|
+
writer=public_writer,
|
|
123
|
+
proxy_subdomain=proxy_subdomain,
|
|
124
|
+
identifier_bytes=header_bytes,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
asyncio.create_task(check_pending_connection(connection_id))
|
|
128
|
+
|
|
129
|
+
logger.info(f"Created temporary request identifier {connection_id}")
|
|
130
|
+
# lock the client writer since the control connection has multiple things
|
|
131
|
+
# being written to it at once
|
|
132
|
+
async with lock:
|
|
133
|
+
await write_message(
|
|
134
|
+
client_writer,
|
|
135
|
+
FunnelMessage(
|
|
136
|
+
type=FunnelMessageType.INCOMING,
|
|
137
|
+
connection_id=connection_id,
|
|
138
|
+
request_line=request_line,
|
|
139
|
+
),
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# request will be to open up a long-running TCP connection
|
|
144
|
+
# this is the control connection, raw bytes do not flow over this connection
|
|
145
|
+
async def handle_client(
|
|
146
|
+
client_reader: asyncio.StreamReader, client_writer: asyncio.StreamWriter
|
|
147
|
+
) -> None:
|
|
148
|
+
"""
|
|
149
|
+
Handles the control connection between the funnel client and funnel server.
|
|
150
|
+
The initial connection is established here, as well as setting up new connections
|
|
151
|
+
when a client requests data to be forwarded to it.
|
|
152
|
+
"""
|
|
153
|
+
try:
|
|
154
|
+
message = await read_message(client_reader)
|
|
155
|
+
match message.type:
|
|
156
|
+
case FunnelMessageType.CONNECT:
|
|
157
|
+
logger.info("Received connection request from client")
|
|
158
|
+
proxy_subdomain = message.proxy_subdomain
|
|
159
|
+
if not proxy_subdomain:
|
|
160
|
+
proxy_subdomain = generate_random_subdomain()
|
|
161
|
+
proxy_subdomain = proxy_subdomain.lower()
|
|
162
|
+
if proxy_subdomain in TUNNELS:
|
|
163
|
+
raise Exception(
|
|
164
|
+
f"subdomain {proxy_subdomain} has already been claimed"
|
|
165
|
+
)
|
|
166
|
+
lock = asyncio.Lock()
|
|
167
|
+
logger.info(f"assigning subdomain {proxy_subdomain} to client")
|
|
168
|
+
TUNNELS[proxy_subdomain] = Tunnel(
|
|
169
|
+
reader=client_reader, writer=client_writer, lock=lock
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
async with lock:
|
|
173
|
+
await write_message(
|
|
174
|
+
client_writer,
|
|
175
|
+
FunnelMessage(
|
|
176
|
+
type=FunnelMessageType.ACCEPT,
|
|
177
|
+
proxy_subdomain=proxy_subdomain,
|
|
178
|
+
),
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
asyncio.create_task(
|
|
182
|
+
handle_heartbeat(proxy_subdomain, client_writer, lock)
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
case FunnelMessageType.RECEIVE:
|
|
186
|
+
connection_id = message.connection_id
|
|
187
|
+
if not connection_id:
|
|
188
|
+
raise Exception("no connection ID present in receive message")
|
|
189
|
+
connection = PENDING_CONNECTIONS[connection_id]
|
|
190
|
+
public_reader, public_writer = (
|
|
191
|
+
connection.reader,
|
|
192
|
+
connection.writer,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
# write the initial bytes that were read for subdomain routing to the client
|
|
196
|
+
client_writer.write(connection.identifier_bytes)
|
|
197
|
+
await client_writer.drain()
|
|
198
|
+
|
|
199
|
+
await asyncio.gather(
|
|
200
|
+
asyncio.create_task(forward(public_reader, client_writer)),
|
|
201
|
+
asyncio.create_task(forward(client_reader, public_writer)),
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
logger.info(
|
|
205
|
+
f"Request closed, terminating connection ID {connection_id}"
|
|
206
|
+
)
|
|
207
|
+
del PENDING_CONNECTIONS[connection_id]
|
|
208
|
+
|
|
209
|
+
case _:
|
|
210
|
+
logger.info(message.type)
|
|
211
|
+
raise Exception("Invalid connection request")
|
|
212
|
+
|
|
213
|
+
except Exception as e:
|
|
214
|
+
logger.error(f"Error connecting to client: {e}")
|
|
215
|
+
client_writer.close()
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
async def start_client_handler():
|
|
219
|
+
server = await asyncio.start_server(handle_client, HOST, CLIENT_PORT)
|
|
220
|
+
await server.serve_forever()
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
async def start_public_handler():
|
|
224
|
+
server = await asyncio.start_server(handle_public, HOST, HTTP_PORT)
|
|
225
|
+
await server.serve_forever()
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
async def main():
|
|
229
|
+
await asyncio.gather(start_client_handler(), start_public_handler())
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
if __name__ == "__main__":
|
|
233
|
+
asyncio.run(main())
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>Tunnel Not Found — Funnel</title>
|
|
7
|
+
<style>
|
|
8
|
+
* {
|
|
9
|
+
margin: 0;
|
|
10
|
+
padding: 0;
|
|
11
|
+
box-sizing: border-box;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
body {
|
|
15
|
+
background-color: #0a0a0a;
|
|
16
|
+
color: #e0e0e0;
|
|
17
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
18
|
+
min-height: 100vh;
|
|
19
|
+
display: flex;
|
|
20
|
+
align-items: center;
|
|
21
|
+
justify-content: center;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.container {
|
|
25
|
+
text-align: center;
|
|
26
|
+
padding: 2rem;
|
|
27
|
+
max-width: 480px;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.status-code {
|
|
31
|
+
font-size: 6rem;
|
|
32
|
+
font-weight: 700;
|
|
33
|
+
letter-spacing: -0.04em;
|
|
34
|
+
color: #fff;
|
|
35
|
+
line-height: 1;
|
|
36
|
+
margin-bottom: 1rem;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.divider {
|
|
40
|
+
width: 48px;
|
|
41
|
+
height: 2px;
|
|
42
|
+
background: #333;
|
|
43
|
+
margin: 0 auto 1.5rem;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
h1 {
|
|
47
|
+
font-size: 1.25rem;
|
|
48
|
+
font-weight: 500;
|
|
49
|
+
color: #ccc;
|
|
50
|
+
margin-bottom: 0.75rem;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
p {
|
|
54
|
+
font-size: 0.9rem;
|
|
55
|
+
color: #666;
|
|
56
|
+
line-height: 1.6;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.subdomain {
|
|
60
|
+
color: #888;
|
|
61
|
+
font-family: "SF Mono", "Fira Code", "Fira Mono", Menlo, Consolas, monospace;
|
|
62
|
+
font-size: 0.85rem;
|
|
63
|
+
background: #161616;
|
|
64
|
+
border: 1px solid #222;
|
|
65
|
+
padding: 0.15rem 0.5rem;
|
|
66
|
+
border-radius: 4px;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.home-link {
|
|
70
|
+
display: inline-block;
|
|
71
|
+
margin-top: 2rem;
|
|
72
|
+
color: #555;
|
|
73
|
+
text-decoration: none;
|
|
74
|
+
font-size: 0.8rem;
|
|
75
|
+
letter-spacing: 0.05em;
|
|
76
|
+
text-transform: uppercase;
|
|
77
|
+
transition: color 0.2s;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.home-link:hover {
|
|
81
|
+
color: #aaa;
|
|
82
|
+
}
|
|
83
|
+
</style>
|
|
84
|
+
</head>
|
|
85
|
+
<body>
|
|
86
|
+
<div class="container">
|
|
87
|
+
<div class="status-code">404</div>
|
|
88
|
+
<div class="divider"></div>
|
|
89
|
+
<h1>Tunnel not found</h1>
|
|
90
|
+
<p>
|
|
91
|
+
No active tunnel for <span class="subdomain">{subdomain}</span><br>
|
|
92
|
+
The tunnel may have disconnected or the subdomain was never claimed.
|
|
93
|
+
</p>
|
|
94
|
+
<a class="home-link" href="https://funnel.delivery">← funnel.delivery</a>
|
|
95
|
+
</div>
|
|
96
|
+
</body>
|
|
97
|
+
</html>
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>Funnel — Expose your local server to the internet</title>
|
|
7
|
+
<style>
|
|
8
|
+
* {
|
|
9
|
+
margin: 0;
|
|
10
|
+
padding: 0;
|
|
11
|
+
box-sizing: border-box;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
body {
|
|
15
|
+
background-color: #0a0a0a;
|
|
16
|
+
color: #e0e0e0;
|
|
17
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
18
|
+
min-height: 100vh;
|
|
19
|
+
display: flex;
|
|
20
|
+
align-items: center;
|
|
21
|
+
justify-content: center;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.container {
|
|
25
|
+
text-align: center;
|
|
26
|
+
padding: 2rem;
|
|
27
|
+
max-width: 540px;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.logo {
|
|
31
|
+
font-size: 3rem;
|
|
32
|
+
font-weight: 700;
|
|
33
|
+
letter-spacing: -0.03em;
|
|
34
|
+
color: #fff;
|
|
35
|
+
margin-bottom: 0.5rem;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.tagline {
|
|
39
|
+
font-size: 1rem;
|
|
40
|
+
color: #666;
|
|
41
|
+
margin-bottom: 2.5rem;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.terminal {
|
|
45
|
+
background: #111;
|
|
46
|
+
border: 1px solid #222;
|
|
47
|
+
border-radius: 8px;
|
|
48
|
+
padding: 1.25rem 1.5rem;
|
|
49
|
+
text-align: left;
|
|
50
|
+
margin-bottom: 2.5rem;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.terminal-bar {
|
|
54
|
+
display: flex;
|
|
55
|
+
gap: 6px;
|
|
56
|
+
margin-bottom: 1rem;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.terminal-dot {
|
|
60
|
+
width: 10px;
|
|
61
|
+
height: 10px;
|
|
62
|
+
border-radius: 50%;
|
|
63
|
+
background: #333;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.terminal-line {
|
|
67
|
+
font-family: "SF Mono", "Fira Code", "Fira Mono", Menlo, Consolas, monospace;
|
|
68
|
+
font-size: 0.85rem;
|
|
69
|
+
line-height: 1.8;
|
|
70
|
+
color: #888;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.terminal-line .prompt {
|
|
74
|
+
color: #555;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.terminal-line .cmd {
|
|
78
|
+
color: #ccc;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.terminal-line .url {
|
|
82
|
+
color: #6ec7e0;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.features {
|
|
86
|
+
display: flex;
|
|
87
|
+
gap: 2rem;
|
|
88
|
+
justify-content: center;
|
|
89
|
+
flex-wrap: wrap;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.feature {
|
|
93
|
+
flex: 1;
|
|
94
|
+
min-width: 140px;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
.feature-title {
|
|
98
|
+
font-size: 0.8rem;
|
|
99
|
+
font-weight: 600;
|
|
100
|
+
color: #999;
|
|
101
|
+
text-transform: uppercase;
|
|
102
|
+
letter-spacing: 0.06em;
|
|
103
|
+
margin-bottom: 0.35rem;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.feature-desc {
|
|
107
|
+
font-size: 0.8rem;
|
|
108
|
+
color: #555;
|
|
109
|
+
line-height: 1.5;
|
|
110
|
+
}
|
|
111
|
+
</style>
|
|
112
|
+
</head>
|
|
113
|
+
<body>
|
|
114
|
+
<div class="container">
|
|
115
|
+
<div class="logo">funnel</div>
|
|
116
|
+
<p class="tagline">Expose your local server to the internet.</p>
|
|
117
|
+
|
|
118
|
+
<div class="terminal">
|
|
119
|
+
<div class="terminal-bar">
|
|
120
|
+
<div class="terminal-dot"></div>
|
|
121
|
+
<div class="terminal-dot"></div>
|
|
122
|
+
<div class="terminal-dot"></div>
|
|
123
|
+
</div>
|
|
124
|
+
<div class="terminal-line">
|
|
125
|
+
<span class="prompt">$</span> <span class="cmd">funnel 8000</span>
|
|
126
|
+
</div>
|
|
127
|
+
<div class="terminal-line">
|
|
128
|
+
Forwarding <span class="url">https://my-subdomain.funnel.delivery</span> → localhost:8000
|
|
129
|
+
</div>
|
|
130
|
+
</div>
|
|
131
|
+
</div>
|
|
132
|
+
</body>
|
|
133
|
+
</html>
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import random
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
TEMPLATE_DIR = Path(__file__).resolve().parent / "templates"
|
|
6
|
+
|
|
7
|
+
STATUS_PHRASES = {
|
|
8
|
+
200: "OK",
|
|
9
|
+
404: "Not Found",
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def load_template(name: str, **kwargs: str) -> str:
|
|
14
|
+
text = (TEMPLATE_DIR / name).read_text()
|
|
15
|
+
for key, value in kwargs.items():
|
|
16
|
+
text = text.replace(f"{{{key}}}", value)
|
|
17
|
+
return text
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
async def send_html_response(
|
|
21
|
+
writer: asyncio.StreamWriter,
|
|
22
|
+
template: str,
|
|
23
|
+
status: int = 200,
|
|
24
|
+
**kwargs: str,
|
|
25
|
+
) -> None:
|
|
26
|
+
body = load_template(template, **kwargs)
|
|
27
|
+
phrase = STATUS_PHRASES.get(status, "OK")
|
|
28
|
+
response = (
|
|
29
|
+
f"HTTP/1.1 {status} {phrase}\r\n"
|
|
30
|
+
"Content-Type: text/html\r\n"
|
|
31
|
+
"Connection: close\r\n"
|
|
32
|
+
f"Content-Length: {len(body)}\r\n"
|
|
33
|
+
"\r\n" + body
|
|
34
|
+
)
|
|
35
|
+
writer.write(response.encode())
|
|
36
|
+
await writer.drain()
|
|
37
|
+
writer.close()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def generate_random_subdomain() -> str:
|
|
41
|
+
adjectives = [
|
|
42
|
+
"auspicious",
|
|
43
|
+
"diabolical",
|
|
44
|
+
"ominous",
|
|
45
|
+
"glib",
|
|
46
|
+
"crusty",
|
|
47
|
+
"swift",
|
|
48
|
+
"moldy",
|
|
49
|
+
"spicy",
|
|
50
|
+
"fuzzy",
|
|
51
|
+
"wonky",
|
|
52
|
+
"crispy",
|
|
53
|
+
"soggy",
|
|
54
|
+
"sneaky",
|
|
55
|
+
"famous",
|
|
56
|
+
"infamous",
|
|
57
|
+
"controversial",
|
|
58
|
+
"rusty",
|
|
59
|
+
"dusty",
|
|
60
|
+
"toasty",
|
|
61
|
+
"frosty",
|
|
62
|
+
"gloomy",
|
|
63
|
+
"zesty",
|
|
64
|
+
]
|
|
65
|
+
nouns = [
|
|
66
|
+
"yak",
|
|
67
|
+
"clam",
|
|
68
|
+
"waffle",
|
|
69
|
+
"toad",
|
|
70
|
+
"walrus",
|
|
71
|
+
"pickle",
|
|
72
|
+
"badger",
|
|
73
|
+
"turnip",
|
|
74
|
+
"eel",
|
|
75
|
+
"moose",
|
|
76
|
+
"squid",
|
|
77
|
+
"mango",
|
|
78
|
+
"puffin",
|
|
79
|
+
"ferret",
|
|
80
|
+
"shrimp",
|
|
81
|
+
"egret",
|
|
82
|
+
"parrot",
|
|
83
|
+
"trashcan",
|
|
84
|
+
"spatula",
|
|
85
|
+
"mitochondria",
|
|
86
|
+
"rat",
|
|
87
|
+
]
|
|
88
|
+
return f"{random.choice(adjectives)}-{random.choice(nouns)}"
|
|
File without changes
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import asyncio
|
|
3
|
+
import json
|
|
4
|
+
import struct
|
|
5
|
+
|
|
6
|
+
from dataclasses import asdict, dataclass
|
|
7
|
+
from enum import StrEnum
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class FunnelMessageType(StrEnum):
|
|
12
|
+
CONNECT = "connect"
|
|
13
|
+
ACCEPT = "accept"
|
|
14
|
+
HEARTBEAT = "heartbeat"
|
|
15
|
+
INCOMING = "incoming_connection"
|
|
16
|
+
RECEIVE = "receive_connection"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class FunnelMessage:
|
|
21
|
+
type: FunnelMessageType
|
|
22
|
+
connection_id: Optional[str] = None
|
|
23
|
+
proxy_subdomain: Optional[str] = None
|
|
24
|
+
method: Optional[str] = None
|
|
25
|
+
request_line: Optional[str] = None
|
|
26
|
+
|
|
27
|
+
def encode(self) -> bytes:
|
|
28
|
+
# filter out missing keys before sending the message
|
|
29
|
+
data = {k: v for k, v in asdict(self).items() if v is not None}
|
|
30
|
+
return json.dumps(data).encode()
|
|
31
|
+
|
|
32
|
+
@classmethod
|
|
33
|
+
def decode(cls, data: bytes) -> FunnelMessage:
|
|
34
|
+
return cls(**json.loads(data))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
async def read_message(reader: asyncio.StreamReader) -> FunnelMessage:
|
|
38
|
+
message_length = struct.unpack(">I", await reader.readexactly(4))[0]
|
|
39
|
+
data = await reader.readexactly(message_length)
|
|
40
|
+
return FunnelMessage.decode(data)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
async def write_message(
|
|
44
|
+
writer: asyncio.StreamWriter, funnel_message: FunnelMessage
|
|
45
|
+
) -> None:
|
|
46
|
+
data = funnel_message.encode()
|
|
47
|
+
writer.write(struct.pack(">I", len(data)))
|
|
48
|
+
writer.write(data)
|
|
49
|
+
await writer.drain()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def forward(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
|
53
|
+
data = await reader.read(4096)
|
|
54
|
+
while data:
|
|
55
|
+
writer.write(data)
|
|
56
|
+
await writer.drain()
|
|
57
|
+
data = await reader.read(4096)
|
|
58
|
+
writer.close()
|