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.
@@ -0,0 +1,4 @@
1
+ uv.lock
2
+ __pycache__
3
+ .env
4
+ dist/
@@ -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
+ "
@@ -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.
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: funnel-cli
3
+ Version: 0.1.0
4
+ Summary: Expose your local services to the internet
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.14
8
+ Requires-Dist: textual
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,16 @@
1
+ services:
2
+
3
+ reverse_proxy:
4
+ build: infra/
5
+ ports:
6
+ - "443:443"
7
+ - "80:80"
8
+ volumes:
9
+ - /etc/letsencrypt:/etc/letsencrypt:ro
10
+
11
+ funnel_server:
12
+ build:
13
+ context: .
14
+ dockerfile: server/Dockerfile
15
+ ports:
16
+ - "8080:8080"
@@ -0,0 +1,4 @@
1
+ funnel.delivery, *.funnel.delivery {
2
+ tls /etc/letsencrypt/live/funnel.delivery/fullchain.pem /etc/letsencrypt/live/funnel.delivery/privkey.pem
3
+ reverse_proxy funnel_server:80
4
+ }
@@ -0,0 +1,2 @@
1
+ FROM caddy:latest
2
+ COPY Caddyfile /etc/caddy/Caddyfile
@@ -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"
@@ -0,0 +1,5 @@
1
+ FROM python:3.12-slim
2
+ WORKDIR /app
3
+ COPY server/ ./server/
4
+ COPY shared/ ./shared/
5
+ CMD ["python3", "-m", "server.server"]
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()