streamlit-remote 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.
- streamlit_remote/__init__.py +1 -0
- streamlit_remote/cli.py +386 -0
- streamlit_remote/https.py +210 -0
- streamlit_remote/process.py +93 -0
- streamlit_remote/providers/__init__.py +39 -0
- streamlit_remote/providers/cloudflare.py +43 -0
- streamlit_remote/providers/ngrok.py +88 -0
- streamlit_remote/server.py +40 -0
- streamlit_remote-0.1.0.dist-info/METADATA +225 -0
- streamlit_remote-0.1.0.dist-info/RECORD +13 -0
- streamlit_remote-0.1.0.dist-info/WHEEL +4 -0
- streamlit_remote-0.1.0.dist-info/entry_points.txt +4 -0
- streamlit_remote-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
streamlit_remote/cli.py
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import importlib.util
|
|
5
|
+
import shlex
|
|
6
|
+
import sys
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
import webbrowser
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Sequence
|
|
12
|
+
|
|
13
|
+
from streamlit_remote.https import (
|
|
14
|
+
HttpsError,
|
|
15
|
+
HttpsMaterial,
|
|
16
|
+
planned_self_signed_material,
|
|
17
|
+
prepare_https_material,
|
|
18
|
+
)
|
|
19
|
+
from streamlit_remote.process import (
|
|
20
|
+
ManagedProcess,
|
|
21
|
+
start_logged_process,
|
|
22
|
+
terminate_processes,
|
|
23
|
+
wait_for_process_exit,
|
|
24
|
+
)
|
|
25
|
+
from streamlit_remote.providers import get_provider
|
|
26
|
+
from streamlit_remote.server import LocalServerConfig, is_port_available, wait_until_listening
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
30
|
+
parser = argparse.ArgumentParser(
|
|
31
|
+
prog="st-remote",
|
|
32
|
+
description="Run a Streamlit app with optional remote HTTPS access.",
|
|
33
|
+
)
|
|
34
|
+
parser.add_argument("app", type=Path, metavar="APP", help="Streamlit app file path.")
|
|
35
|
+
parser.add_argument("--port", type=int, default=8501, help="Local Streamlit port.")
|
|
36
|
+
parser.add_argument("--host", default="localhost", help="Local Streamlit host.")
|
|
37
|
+
parser.add_argument(
|
|
38
|
+
"--provider",
|
|
39
|
+
default="cloudflare",
|
|
40
|
+
choices=["cloudflare", "ngrok"],
|
|
41
|
+
help="Remote tunnel provider.",
|
|
42
|
+
)
|
|
43
|
+
parser.add_argument(
|
|
44
|
+
"--tunnel-log-level",
|
|
45
|
+
default="info",
|
|
46
|
+
choices=["info", "warn", "error", "off"],
|
|
47
|
+
help="Tunnel provider log verbosity.",
|
|
48
|
+
)
|
|
49
|
+
parser.add_argument(
|
|
50
|
+
"--https",
|
|
51
|
+
dest="https_mode",
|
|
52
|
+
default="off",
|
|
53
|
+
choices=["off", "self-signed", "cert-files"],
|
|
54
|
+
help="Local Streamlit HTTPS mode.",
|
|
55
|
+
)
|
|
56
|
+
parser.add_argument(
|
|
57
|
+
"--ssl-cert-file",
|
|
58
|
+
type=Path,
|
|
59
|
+
help="Existing certificate file for `--https cert-files`.",
|
|
60
|
+
)
|
|
61
|
+
parser.add_argument(
|
|
62
|
+
"--ssl-key-file",
|
|
63
|
+
type=Path,
|
|
64
|
+
help="Existing private key file for `--https cert-files`.",
|
|
65
|
+
)
|
|
66
|
+
parser.add_argument(
|
|
67
|
+
"--cert-valid-days",
|
|
68
|
+
type=int,
|
|
69
|
+
default=30,
|
|
70
|
+
help="Validity period for managed self-signed certificates.",
|
|
71
|
+
)
|
|
72
|
+
parser.add_argument(
|
|
73
|
+
"--streamlit-arg",
|
|
74
|
+
action="append",
|
|
75
|
+
default=[],
|
|
76
|
+
help="Extra argument passed to Streamlit. Can be repeated.",
|
|
77
|
+
)
|
|
78
|
+
parser.add_argument(
|
|
79
|
+
"--no-remote",
|
|
80
|
+
action="store_true",
|
|
81
|
+
help="Run Streamlit only without starting a remote tunnel.",
|
|
82
|
+
)
|
|
83
|
+
parser.add_argument(
|
|
84
|
+
"--no-browser",
|
|
85
|
+
action="store_true",
|
|
86
|
+
help="Do not open the local or remote URL in a browser.",
|
|
87
|
+
)
|
|
88
|
+
parser.add_argument(
|
|
89
|
+
"--dry-run",
|
|
90
|
+
action="store_true",
|
|
91
|
+
help="Print subprocess commands without running them.",
|
|
92
|
+
)
|
|
93
|
+
parser.add_argument(
|
|
94
|
+
"--verbose",
|
|
95
|
+
action="store_true",
|
|
96
|
+
help="Print additional diagnostic information.",
|
|
97
|
+
)
|
|
98
|
+
return parser
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def parse_args(argv: Sequence[str]) -> argparse.Namespace:
|
|
102
|
+
cli_args, passthrough_args = split_streamlit_args(argv)
|
|
103
|
+
namespace = build_parser().parse_args(cli_args)
|
|
104
|
+
namespace.streamlit_args = [*namespace.streamlit_arg, *passthrough_args]
|
|
105
|
+
validate_cli_options(namespace)
|
|
106
|
+
return namespace
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def split_streamlit_args(argv: Sequence[str]) -> tuple[list[str], list[str]]:
|
|
110
|
+
args = list(argv)
|
|
111
|
+
if "--" not in args:
|
|
112
|
+
return args, []
|
|
113
|
+
|
|
114
|
+
separator_index = args.index("--")
|
|
115
|
+
return args[:separator_index], args[separator_index + 1 :]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def build_streamlit_command(
|
|
119
|
+
app_path: Path,
|
|
120
|
+
host: str,
|
|
121
|
+
port: int,
|
|
122
|
+
streamlit_args: Sequence[str] = (),
|
|
123
|
+
https_material: HttpsMaterial | None = None,
|
|
124
|
+
) -> list[str]:
|
|
125
|
+
command = [
|
|
126
|
+
sys.executable,
|
|
127
|
+
"-m",
|
|
128
|
+
"streamlit",
|
|
129
|
+
"run",
|
|
130
|
+
str(app_path),
|
|
131
|
+
"--server.address",
|
|
132
|
+
host,
|
|
133
|
+
"--server.port",
|
|
134
|
+
str(port),
|
|
135
|
+
"--server.headless",
|
|
136
|
+
"true",
|
|
137
|
+
]
|
|
138
|
+
|
|
139
|
+
if https_material is not None:
|
|
140
|
+
command.extend(
|
|
141
|
+
[
|
|
142
|
+
"--server.sslCertFile",
|
|
143
|
+
str(https_material.cert_file),
|
|
144
|
+
"--server.sslKeyFile",
|
|
145
|
+
str(https_material.key_file),
|
|
146
|
+
]
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
command.extend(streamlit_args)
|
|
150
|
+
return command
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def validate_app_path(app_path: Path) -> None:
|
|
154
|
+
if not app_path.exists():
|
|
155
|
+
raise CliError(f"Streamlit app not found: {app_path}")
|
|
156
|
+
|
|
157
|
+
if not app_path.is_file():
|
|
158
|
+
raise CliError(f"Streamlit app path is not a file: {app_path}")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def validate_cli_options(namespace: argparse.Namespace) -> None:
|
|
162
|
+
cert_files = [namespace.ssl_cert_file, namespace.ssl_key_file]
|
|
163
|
+
if namespace.https_mode != "cert-files" and any(path is not None for path in cert_files):
|
|
164
|
+
raise CliError(
|
|
165
|
+
"`--ssl-cert-file` and `--ssl-key-file` can only be used with "
|
|
166
|
+
"`--https cert-files`."
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def require_streamlit() -> None:
|
|
171
|
+
if importlib.util.find_spec("streamlit") is None:
|
|
172
|
+
raise CliError(
|
|
173
|
+
"Streamlit is not installed. Install it with `pip install streamlit` "
|
|
174
|
+
"or reinstall this package with its dependencies."
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def run_cli(argv: Sequence[str] | None = None) -> int:
|
|
179
|
+
try:
|
|
180
|
+
namespace = parse_args(sys.argv[1:] if argv is None else argv)
|
|
181
|
+
return run(namespace)
|
|
182
|
+
except (CliError, HttpsError) as exc:
|
|
183
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
184
|
+
return 2
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def run(namespace: argparse.Namespace) -> int:
|
|
188
|
+
validate_app_path(namespace.app)
|
|
189
|
+
|
|
190
|
+
scheme = "https" if namespace.https_mode != "off" else "http"
|
|
191
|
+
local_server = LocalServerConfig(
|
|
192
|
+
host=namespace.host,
|
|
193
|
+
port=namespace.port,
|
|
194
|
+
scheme=scheme,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
provider = None
|
|
198
|
+
tunnel_command: list[str] | None = None
|
|
199
|
+
if not namespace.no_remote:
|
|
200
|
+
provider = get_provider(namespace.provider)
|
|
201
|
+
tunnel_command = provider.build_command(
|
|
202
|
+
local_server.url,
|
|
203
|
+
origin_tls_verify=namespace.https_mode != "self-signed",
|
|
204
|
+
tunnel_log_level=namespace.tunnel_log_level,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
if namespace.dry_run:
|
|
208
|
+
https_material = prepare_cli_https_material(namespace)
|
|
209
|
+
streamlit_command = build_streamlit_command(
|
|
210
|
+
namespace.app,
|
|
211
|
+
local_server.host,
|
|
212
|
+
local_server.port,
|
|
213
|
+
namespace.streamlit_args,
|
|
214
|
+
https_material=https_material,
|
|
215
|
+
)
|
|
216
|
+
print(f"Streamlit command:\n {shlex.join(streamlit_command)}")
|
|
217
|
+
if tunnel_command is None:
|
|
218
|
+
print("Remote access: disabled")
|
|
219
|
+
else:
|
|
220
|
+
print(f"Tunnel command:\n {shlex.join(tunnel_command)}")
|
|
221
|
+
if namespace.https_mode == "self-signed":
|
|
222
|
+
print("HTTPS: managed self-signed certificate will be prepared at runtime")
|
|
223
|
+
return 0
|
|
224
|
+
|
|
225
|
+
require_streamlit()
|
|
226
|
+
if not is_port_available(local_server.host, local_server.port):
|
|
227
|
+
raise CliError(f"Port {local_server.port} is not available on {local_server.host}.")
|
|
228
|
+
|
|
229
|
+
if provider is not None and not provider.is_available():
|
|
230
|
+
raise CliError(provider.install_hint)
|
|
231
|
+
|
|
232
|
+
https_material = prepare_cli_https_material(namespace)
|
|
233
|
+
streamlit_command = build_streamlit_command(
|
|
234
|
+
namespace.app,
|
|
235
|
+
local_server.host,
|
|
236
|
+
local_server.port,
|
|
237
|
+
namespace.streamlit_args,
|
|
238
|
+
https_material=https_material,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
print("Streamlit local URL:")
|
|
242
|
+
print(f" {local_server.url}")
|
|
243
|
+
if namespace.no_remote:
|
|
244
|
+
print("\nRemote access: disabled")
|
|
245
|
+
if not namespace.no_browser:
|
|
246
|
+
open_browser(local_server.url)
|
|
247
|
+
else:
|
|
248
|
+
if namespace.https_mode == "self-signed" and namespace.provider == "ngrok":
|
|
249
|
+
print(
|
|
250
|
+
"\nNote: ngrok already provides HTTPS for the public URL. "
|
|
251
|
+
"Local self-signed HTTPS will be used only between ngrok and Streamlit."
|
|
252
|
+
)
|
|
253
|
+
print("\nStarting remote tunnel...")
|
|
254
|
+
|
|
255
|
+
remote_url_printed = threading.Event()
|
|
256
|
+
remote_url_lock = threading.Lock()
|
|
257
|
+
|
|
258
|
+
def report_remote_url(public_url: str) -> None:
|
|
259
|
+
with remote_url_lock:
|
|
260
|
+
if remote_url_printed.is_set():
|
|
261
|
+
return
|
|
262
|
+
|
|
263
|
+
remote_url_printed.set()
|
|
264
|
+
print("\nStreamlit local URL:")
|
|
265
|
+
print(f" {local_server.url}")
|
|
266
|
+
print("\nRemote HTTPS URL:")
|
|
267
|
+
print(f" {public_url}\n")
|
|
268
|
+
if not namespace.no_browser:
|
|
269
|
+
open_browser(public_url)
|
|
270
|
+
|
|
271
|
+
def on_tunnel_line(line: str) -> None:
|
|
272
|
+
if provider is None:
|
|
273
|
+
return
|
|
274
|
+
|
|
275
|
+
public_url = provider.parse_public_url(line)
|
|
276
|
+
if public_url is not None:
|
|
277
|
+
report_remote_url(public_url)
|
|
278
|
+
|
|
279
|
+
def poll_provider_public_url() -> None:
|
|
280
|
+
if provider is None:
|
|
281
|
+
return
|
|
282
|
+
|
|
283
|
+
while not remote_url_printed.is_set():
|
|
284
|
+
public_url = provider.get_public_url()
|
|
285
|
+
if public_url is not None:
|
|
286
|
+
report_remote_url(public_url)
|
|
287
|
+
return
|
|
288
|
+
time.sleep(0.5)
|
|
289
|
+
|
|
290
|
+
handles: list[ManagedProcess] = []
|
|
291
|
+
public_url_poll_thread: threading.Thread | None = None
|
|
292
|
+
try:
|
|
293
|
+
handles.append(start_logged_process(streamlit_command, "streamlit"))
|
|
294
|
+
if tunnel_command is not None and not wait_until_listening(
|
|
295
|
+
local_server.host,
|
|
296
|
+
local_server.port,
|
|
297
|
+
):
|
|
298
|
+
raise CliError(
|
|
299
|
+
f"Streamlit did not start listening on {local_server.url} within the timeout."
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
if tunnel_command is not None:
|
|
303
|
+
handles.append(
|
|
304
|
+
start_logged_process(
|
|
305
|
+
tunnel_command,
|
|
306
|
+
provider.log_prefix,
|
|
307
|
+
on_line=on_tunnel_line,
|
|
308
|
+
should_print_line=lambda line: should_print_tunnel_line(
|
|
309
|
+
line,
|
|
310
|
+
namespace.tunnel_log_level,
|
|
311
|
+
),
|
|
312
|
+
)
|
|
313
|
+
)
|
|
314
|
+
public_url_poll_thread = threading.Thread(
|
|
315
|
+
target=poll_provider_public_url,
|
|
316
|
+
name="streamlit-remote-public-url-poll",
|
|
317
|
+
daemon=True,
|
|
318
|
+
)
|
|
319
|
+
public_url_poll_thread.start()
|
|
320
|
+
|
|
321
|
+
exited = wait_for_process_exit(handles)
|
|
322
|
+
return exited.process.returncode if exited.process.returncode is not None else 1
|
|
323
|
+
except KeyboardInterrupt:
|
|
324
|
+
print("\nInterrupted. Shutting down child processes...", file=sys.stderr)
|
|
325
|
+
return 130
|
|
326
|
+
finally:
|
|
327
|
+
remote_url_printed.set()
|
|
328
|
+
terminate_processes(handles)
|
|
329
|
+
if public_url_poll_thread is not None:
|
|
330
|
+
public_url_poll_thread.join(timeout=1.0)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def main() -> None:
|
|
334
|
+
raise SystemExit(run_cli())
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
class CliError(Exception):
|
|
338
|
+
pass
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def prepare_cli_https_material(namespace: argparse.Namespace) -> HttpsMaterial | None:
|
|
342
|
+
if namespace.dry_run and namespace.https_mode == "self-signed":
|
|
343
|
+
return planned_self_signed_material(namespace.host)
|
|
344
|
+
|
|
345
|
+
return prepare_https_material(
|
|
346
|
+
mode=namespace.https_mode,
|
|
347
|
+
host=namespace.host,
|
|
348
|
+
cert_file=namespace.ssl_cert_file,
|
|
349
|
+
key_file=namespace.ssl_key_file,
|
|
350
|
+
valid_days=namespace.cert_valid_days,
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def should_print_tunnel_line(line: str, tunnel_log_level: str) -> bool:
|
|
355
|
+
if tunnel_log_level == "off":
|
|
356
|
+
return False
|
|
357
|
+
|
|
358
|
+
if tunnel_log_level == "info":
|
|
359
|
+
return True
|
|
360
|
+
|
|
361
|
+
severity = classify_tunnel_line(line)
|
|
362
|
+
if tunnel_log_level == "warn":
|
|
363
|
+
return severity in {"warn", "error"}
|
|
364
|
+
|
|
365
|
+
if tunnel_log_level == "error":
|
|
366
|
+
return severity == "error"
|
|
367
|
+
|
|
368
|
+
return True
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def classify_tunnel_line(line: str) -> str:
|
|
372
|
+
lowered = line.lower()
|
|
373
|
+
if any(marker in lowered for marker in ("lvl=error", "lvl=crit", " err ", " error", "fatal")):
|
|
374
|
+
return "error"
|
|
375
|
+
|
|
376
|
+
if any(marker in lowered for marker in ("lvl=warn", " wrn ", " warn", "warning")):
|
|
377
|
+
return "warn"
|
|
378
|
+
|
|
379
|
+
return "info"
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def open_browser(url: str) -> None:
|
|
383
|
+
try:
|
|
384
|
+
webbrowser.open(url, new=2)
|
|
385
|
+
except Exception:
|
|
386
|
+
pass
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import ipaddress
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from datetime import datetime, timedelta, timezone
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from cryptography import x509
|
|
12
|
+
from cryptography.hazmat.primitives import hashes, serialization
|
|
13
|
+
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
14
|
+
from cryptography.x509.oid import NameOID
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class HttpsMaterial:
|
|
19
|
+
cert_file: Path
|
|
20
|
+
key_file: Path
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class HttpsError(Exception):
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def prepare_https_material(
|
|
28
|
+
mode: str,
|
|
29
|
+
host: str,
|
|
30
|
+
cert_file: Path | None = None,
|
|
31
|
+
key_file: Path | None = None,
|
|
32
|
+
valid_days: int = 30,
|
|
33
|
+
cache_dir: Path | None = None,
|
|
34
|
+
) -> HttpsMaterial | None:
|
|
35
|
+
if mode == "off":
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
if mode == "cert-files":
|
|
39
|
+
return validate_cert_files(cert_file, key_file)
|
|
40
|
+
|
|
41
|
+
if mode == "self-signed":
|
|
42
|
+
return prepare_self_signed_material(host, valid_days, cache_dir)
|
|
43
|
+
|
|
44
|
+
raise HttpsError(f"Unsupported HTTPS mode: {mode}")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def validate_cert_files(
|
|
48
|
+
cert_file: Path | None,
|
|
49
|
+
key_file: Path | None,
|
|
50
|
+
) -> HttpsMaterial:
|
|
51
|
+
if cert_file is None or key_file is None:
|
|
52
|
+
raise HttpsError(
|
|
53
|
+
"`--https cert-files` requires both `--ssl-cert-file` and `--ssl-key-file`."
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
if not cert_file.is_file():
|
|
57
|
+
raise HttpsError(f"SSL certificate file not found: {cert_file}")
|
|
58
|
+
|
|
59
|
+
if not key_file.is_file():
|
|
60
|
+
raise HttpsError(f"SSL key file not found: {key_file}")
|
|
61
|
+
|
|
62
|
+
return HttpsMaterial(cert_file=cert_file, key_file=key_file)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def prepare_self_signed_material(
|
|
66
|
+
host: str,
|
|
67
|
+
valid_days: int = 30,
|
|
68
|
+
cache_dir: Path | None = None,
|
|
69
|
+
) -> HttpsMaterial:
|
|
70
|
+
if valid_days < 1:
|
|
71
|
+
raise HttpsError("`--cert-valid-days` must be at least 1.")
|
|
72
|
+
|
|
73
|
+
sans = subject_alt_names_for_host(host)
|
|
74
|
+
cert_file, key_file, metadata_file = self_signed_paths(host, cache_dir)
|
|
75
|
+
cert_dir = cert_file.parent
|
|
76
|
+
cert_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
77
|
+
|
|
78
|
+
if is_reusable_certificate(cert_file, key_file, sans):
|
|
79
|
+
return HttpsMaterial(cert_file=cert_file, key_file=key_file)
|
|
80
|
+
|
|
81
|
+
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
82
|
+
now = datetime.now(timezone.utc)
|
|
83
|
+
cert = (
|
|
84
|
+
x509.CertificateBuilder()
|
|
85
|
+
.subject_name(certificate_name())
|
|
86
|
+
.issuer_name(certificate_name())
|
|
87
|
+
.public_key(key.public_key())
|
|
88
|
+
.serial_number(x509.random_serial_number())
|
|
89
|
+
.not_valid_before(now - timedelta(minutes=1))
|
|
90
|
+
.not_valid_after(now + timedelta(days=valid_days))
|
|
91
|
+
.add_extension(x509.SubjectAlternativeName(sans), critical=False)
|
|
92
|
+
.sign(key, hashes.SHA256())
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
key_file.write_bytes(
|
|
96
|
+
key.private_bytes(
|
|
97
|
+
encoding=serialization.Encoding.PEM,
|
|
98
|
+
format=serialization.PrivateFormat.TraditionalOpenSSL,
|
|
99
|
+
encryption_algorithm=serialization.NoEncryption(),
|
|
100
|
+
)
|
|
101
|
+
)
|
|
102
|
+
os.chmod(key_file, 0o600)
|
|
103
|
+
|
|
104
|
+
cert_file.write_bytes(cert.public_bytes(serialization.Encoding.PEM))
|
|
105
|
+
metadata_file.write_text(
|
|
106
|
+
json.dumps(
|
|
107
|
+
{
|
|
108
|
+
"created_at": now.isoformat(),
|
|
109
|
+
"expires_at": (now + timedelta(days=valid_days)).isoformat(),
|
|
110
|
+
"sans": [str(san) for san in sans],
|
|
111
|
+
},
|
|
112
|
+
indent=2,
|
|
113
|
+
)
|
|
114
|
+
+ "\n",
|
|
115
|
+
encoding="utf-8",
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
return HttpsMaterial(cert_file=cert_file, key_file=key_file)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def planned_self_signed_material(
|
|
122
|
+
host: str,
|
|
123
|
+
cache_dir: Path | None = None,
|
|
124
|
+
) -> HttpsMaterial:
|
|
125
|
+
cert_file, key_file, _ = self_signed_paths(host, cache_dir)
|
|
126
|
+
return HttpsMaterial(cert_file=cert_file, key_file=key_file)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def self_signed_paths(
|
|
130
|
+
host: str,
|
|
131
|
+
cache_dir: Path | None = None,
|
|
132
|
+
) -> tuple[Path, Path, Path]:
|
|
133
|
+
sans = subject_alt_names_for_host(host)
|
|
134
|
+
cert_dir = cache_dir if cache_dir is not None else default_cert_cache_dir()
|
|
135
|
+
fingerprint = hashlib.sha256(
|
|
136
|
+
json.dumps(
|
|
137
|
+
{
|
|
138
|
+
"schema": 1,
|
|
139
|
+
"sans": [str(san) for san in sans],
|
|
140
|
+
},
|
|
141
|
+
sort_keys=True,
|
|
142
|
+
).encode("utf-8")
|
|
143
|
+
).hexdigest()[:16]
|
|
144
|
+
|
|
145
|
+
return (
|
|
146
|
+
cert_dir / f"self-signed-{fingerprint}.crt",
|
|
147
|
+
cert_dir / f"self-signed-{fingerprint}.key",
|
|
148
|
+
cert_dir / f"self-signed-{fingerprint}.json",
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def default_cert_cache_dir() -> Path:
|
|
153
|
+
return Path.home() / ".streamlit-remote" / "certs"
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def subject_alt_names_for_host(host: str) -> list[x509.GeneralName]:
|
|
157
|
+
names: list[x509.GeneralName] = [
|
|
158
|
+
x509.DNSName("localhost"),
|
|
159
|
+
x509.IPAddress(ipaddress.ip_address("127.0.0.1")),
|
|
160
|
+
x509.IPAddress(ipaddress.ip_address("::1")),
|
|
161
|
+
]
|
|
162
|
+
|
|
163
|
+
try:
|
|
164
|
+
host_ip = ipaddress.ip_address(host)
|
|
165
|
+
except ValueError:
|
|
166
|
+
if host and host != "localhost":
|
|
167
|
+
names.append(x509.DNSName(host))
|
|
168
|
+
else:
|
|
169
|
+
if host_ip not in {ipaddress.ip_address("127.0.0.1"), ipaddress.ip_address("::1")}:
|
|
170
|
+
names.append(x509.IPAddress(host_ip))
|
|
171
|
+
|
|
172
|
+
return names
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def is_reusable_certificate(
|
|
176
|
+
cert_file: Path,
|
|
177
|
+
key_file: Path,
|
|
178
|
+
expected_sans: list[x509.GeneralName],
|
|
179
|
+
) -> bool:
|
|
180
|
+
if not cert_file.is_file() or not key_file.is_file():
|
|
181
|
+
return False
|
|
182
|
+
|
|
183
|
+
try:
|
|
184
|
+
cert = x509.load_pem_x509_certificate(cert_file.read_bytes())
|
|
185
|
+
sans = cert.extensions.get_extension_for_class(
|
|
186
|
+
x509.SubjectAlternativeName
|
|
187
|
+
).value
|
|
188
|
+
except (OSError, ValueError, x509.ExtensionNotFound):
|
|
189
|
+
return False
|
|
190
|
+
|
|
191
|
+
if sorted(str(san) for san in sans) != sorted(str(san) for san in expected_sans):
|
|
192
|
+
return False
|
|
193
|
+
|
|
194
|
+
expires_at = certificate_not_valid_after(cert)
|
|
195
|
+
return expires_at > datetime.now(timezone.utc) + timedelta(days=1)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def certificate_not_valid_after(cert: x509.Certificate) -> datetime:
|
|
199
|
+
expires_at = getattr(cert, "not_valid_after_utc", None)
|
|
200
|
+
if expires_at is not None:
|
|
201
|
+
return expires_at
|
|
202
|
+
return cert.not_valid_after.replace(tzinfo=timezone.utc)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def certificate_name() -> x509.Name:
|
|
206
|
+
return x509.Name(
|
|
207
|
+
[
|
|
208
|
+
x509.NameAttribute(NameOID.COMMON_NAME, "streamlit-remote local development"),
|
|
209
|
+
]
|
|
210
|
+
)
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Callable, Sequence
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
LineHandler = Callable[[str], None]
|
|
11
|
+
LinePredicate = Callable[[str], bool]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class ManagedProcess:
|
|
16
|
+
process: subprocess.Popen[str]
|
|
17
|
+
prefix: str
|
|
18
|
+
output_thread: threading.Thread
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def start_logged_process(
|
|
22
|
+
command: Sequence[str],
|
|
23
|
+
prefix: str,
|
|
24
|
+
on_line: LineHandler | None = None,
|
|
25
|
+
should_print_line: LinePredicate | None = None,
|
|
26
|
+
) -> ManagedProcess:
|
|
27
|
+
process = subprocess.Popen(
|
|
28
|
+
list(command),
|
|
29
|
+
stdout=subprocess.PIPE,
|
|
30
|
+
stderr=subprocess.STDOUT,
|
|
31
|
+
text=True,
|
|
32
|
+
encoding="utf-8",
|
|
33
|
+
errors="replace",
|
|
34
|
+
bufsize=1,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
def pump_output() -> None:
|
|
38
|
+
if process.stdout is None:
|
|
39
|
+
return
|
|
40
|
+
|
|
41
|
+
for raw_line in process.stdout:
|
|
42
|
+
line = raw_line.rstrip("\r\n")
|
|
43
|
+
if should_print_line is None or should_print_line(line):
|
|
44
|
+
print(f"[{prefix}] {line}", flush=True)
|
|
45
|
+
if on_line is not None:
|
|
46
|
+
on_line(line)
|
|
47
|
+
|
|
48
|
+
output_thread = threading.Thread(
|
|
49
|
+
target=pump_output,
|
|
50
|
+
name=f"streamlit-remote-{prefix}",
|
|
51
|
+
daemon=True,
|
|
52
|
+
)
|
|
53
|
+
output_thread.start()
|
|
54
|
+
return ManagedProcess(process=process, prefix=prefix, output_thread=output_thread)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def wait_for_process_exit(
|
|
58
|
+
handles: Sequence[ManagedProcess],
|
|
59
|
+
poll_interval: float = 0.2,
|
|
60
|
+
) -> ManagedProcess:
|
|
61
|
+
while True:
|
|
62
|
+
for handle in handles:
|
|
63
|
+
if handle.process.poll() is not None:
|
|
64
|
+
return handle
|
|
65
|
+
time.sleep(poll_interval)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def terminate_processes(
|
|
69
|
+
handles: Sequence[ManagedProcess],
|
|
70
|
+
terminate_timeout: float = 5.0,
|
|
71
|
+
kill_timeout: float = 2.0,
|
|
72
|
+
) -> None:
|
|
73
|
+
for handle in handles:
|
|
74
|
+
if handle.process.poll() is None:
|
|
75
|
+
handle.process.terminate()
|
|
76
|
+
|
|
77
|
+
deadline = time.monotonic() + terminate_timeout
|
|
78
|
+
for handle in handles:
|
|
79
|
+
remaining = max(0.0, deadline - time.monotonic())
|
|
80
|
+
try:
|
|
81
|
+
handle.process.wait(timeout=remaining)
|
|
82
|
+
except subprocess.TimeoutExpired:
|
|
83
|
+
if handle.process.poll() is None:
|
|
84
|
+
handle.process.kill()
|
|
85
|
+
|
|
86
|
+
for handle in handles:
|
|
87
|
+
try:
|
|
88
|
+
handle.process.wait(timeout=kill_timeout)
|
|
89
|
+
except subprocess.TimeoutExpired:
|
|
90
|
+
pass
|
|
91
|
+
|
|
92
|
+
for handle in handles:
|
|
93
|
+
handle.output_thread.join(timeout=kill_timeout)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Literal, Protocol
|
|
4
|
+
|
|
5
|
+
from streamlit_remote.providers.cloudflare import CloudflareQuickTunnelProvider
|
|
6
|
+
from streamlit_remote.providers.ngrok import NgrokProvider
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
TunnelLogLevel = Literal["info", "warn", "error", "off"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TunnelProvider(Protocol):
|
|
13
|
+
name: str
|
|
14
|
+
log_prefix: str
|
|
15
|
+
install_hint: str
|
|
16
|
+
|
|
17
|
+
def build_command(
|
|
18
|
+
self,
|
|
19
|
+
local_url: str,
|
|
20
|
+
*,
|
|
21
|
+
origin_tls_verify: bool = True,
|
|
22
|
+
tunnel_log_level: TunnelLogLevel = "info",
|
|
23
|
+
) -> list[str]: ...
|
|
24
|
+
|
|
25
|
+
def parse_public_url(self, line: str) -> str | None: ...
|
|
26
|
+
|
|
27
|
+
def get_public_url(self) -> str | None: ...
|
|
28
|
+
|
|
29
|
+
def is_available(self) -> bool: ...
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def get_provider(name: str) -> TunnelProvider:
|
|
33
|
+
if name == "cloudflare":
|
|
34
|
+
return CloudflareQuickTunnelProvider()
|
|
35
|
+
|
|
36
|
+
if name == "ngrok":
|
|
37
|
+
return NgrokProvider()
|
|
38
|
+
|
|
39
|
+
raise ValueError(f"Unsupported provider: {name}")
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import shutil
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
TRYCLOUDFLARE_URL_RE = re.compile(r"https://[A-Za-z0-9-]+\.trycloudflare\.com\b")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class CloudflareQuickTunnelProvider:
|
|
13
|
+
name: str = "cloudflare"
|
|
14
|
+
log_prefix: str = "cloudflared"
|
|
15
|
+
install_hint: str = (
|
|
16
|
+
"cloudflared was not found on PATH. Install Cloudflare Tunnel from "
|
|
17
|
+
"https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/ "
|
|
18
|
+
"and try again."
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
def build_command(
|
|
22
|
+
self,
|
|
23
|
+
local_url: str,
|
|
24
|
+
*,
|
|
25
|
+
origin_tls_verify: bool = True,
|
|
26
|
+
tunnel_log_level: str = "info",
|
|
27
|
+
) -> list[str]:
|
|
28
|
+
command = ["cloudflared", "tunnel", "--url", local_url]
|
|
29
|
+
if not origin_tls_verify:
|
|
30
|
+
command.append("--no-tls-verify")
|
|
31
|
+
return command
|
|
32
|
+
|
|
33
|
+
def parse_public_url(self, line: str) -> str | None:
|
|
34
|
+
match = TRYCLOUDFLARE_URL_RE.search(line)
|
|
35
|
+
if match is None:
|
|
36
|
+
return None
|
|
37
|
+
return match.group(0)
|
|
38
|
+
|
|
39
|
+
def get_public_url(self) -> str | None:
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
def is_available(self) -> bool:
|
|
43
|
+
return shutil.which("cloudflared") is not None
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
import shutil
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any
|
|
8
|
+
from urllib.parse import urlparse
|
|
9
|
+
from urllib.request import urlopen
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
HTTPS_URL_RE = re.compile(r"https://[^\s]+")
|
|
13
|
+
AGENT_API_TUNNELS_URL = "http://127.0.0.1:4040/api/tunnels"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class NgrokProvider:
|
|
18
|
+
name: str = "ngrok"
|
|
19
|
+
log_prefix: str = "ngrok"
|
|
20
|
+
install_hint: str = (
|
|
21
|
+
"ngrok was not found on PATH. Install ngrok from "
|
|
22
|
+
"https://ngrok.com/download and configure your authtoken with "
|
|
23
|
+
"`ngrok config add-authtoken TOKEN`."
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
def build_command(
|
|
27
|
+
self,
|
|
28
|
+
local_url: str,
|
|
29
|
+
*,
|
|
30
|
+
origin_tls_verify: bool = True,
|
|
31
|
+
tunnel_log_level: str = "info",
|
|
32
|
+
) -> list[str]:
|
|
33
|
+
if tunnel_log_level == "off":
|
|
34
|
+
return ["ngrok", "http", local_url, "--log", "false"]
|
|
35
|
+
|
|
36
|
+
return [
|
|
37
|
+
"ngrok",
|
|
38
|
+
"http",
|
|
39
|
+
local_url,
|
|
40
|
+
"--log",
|
|
41
|
+
"stdout",
|
|
42
|
+
"--log-format",
|
|
43
|
+
"logfmt",
|
|
44
|
+
"--log-level",
|
|
45
|
+
tunnel_log_level,
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
def parse_public_url(self, line: str) -> str | None:
|
|
49
|
+
if "Forwarding" not in line:
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
for candidate in HTTPS_URL_RE.findall(line):
|
|
53
|
+
parsed = urlparse(candidate)
|
|
54
|
+
if parsed.scheme == "https" and parsed.netloc:
|
|
55
|
+
return candidate.rstrip(",")
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
def get_public_url(self) -> str | None:
|
|
59
|
+
try:
|
|
60
|
+
with urlopen(AGENT_API_TUNNELS_URL, timeout=0.5) as response:
|
|
61
|
+
payload = json.loads(response.read().decode("utf-8"))
|
|
62
|
+
except (OSError, json.JSONDecodeError):
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
return parse_agent_api_public_url(payload)
|
|
66
|
+
|
|
67
|
+
def is_available(self) -> bool:
|
|
68
|
+
return shutil.which("ngrok") is not None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def parse_agent_api_public_url(payload: dict[str, Any]) -> str | None:
|
|
72
|
+
tunnels = payload.get("tunnels")
|
|
73
|
+
if not isinstance(tunnels, list):
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
for tunnel in tunnels:
|
|
77
|
+
if not isinstance(tunnel, dict):
|
|
78
|
+
continue
|
|
79
|
+
|
|
80
|
+
public_url = tunnel.get("public_url")
|
|
81
|
+
if not isinstance(public_url, str):
|
|
82
|
+
continue
|
|
83
|
+
|
|
84
|
+
parsed = urlparse(public_url)
|
|
85
|
+
if parsed.scheme == "https" and parsed.netloc:
|
|
86
|
+
return public_url
|
|
87
|
+
|
|
88
|
+
return None
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import socket
|
|
4
|
+
import time
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class LocalServerConfig:
|
|
10
|
+
host: str
|
|
11
|
+
port: int
|
|
12
|
+
scheme: str = "http"
|
|
13
|
+
|
|
14
|
+
@property
|
|
15
|
+
def url(self) -> str:
|
|
16
|
+
return f"{self.scheme}://{self.host}:{self.port}"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def is_port_available(host: str, port: int) -> bool:
|
|
20
|
+
try:
|
|
21
|
+
with socket.create_server((host, port), reuse_port=False):
|
|
22
|
+
return True
|
|
23
|
+
except OSError:
|
|
24
|
+
return False
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def wait_until_listening(
|
|
28
|
+
host: str,
|
|
29
|
+
port: int,
|
|
30
|
+
timeout: float = 20.0,
|
|
31
|
+
interval: float = 0.2,
|
|
32
|
+
) -> bool:
|
|
33
|
+
deadline = time.monotonic() + timeout
|
|
34
|
+
while time.monotonic() < deadline:
|
|
35
|
+
try:
|
|
36
|
+
with socket.create_connection((host, port), timeout=interval):
|
|
37
|
+
return True
|
|
38
|
+
except OSError:
|
|
39
|
+
time.sleep(interval)
|
|
40
|
+
return False
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: streamlit-remote
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Run Streamlit apps with optional remote HTTPS access.
|
|
5
|
+
Author: Yuichiro Tachibana (Tsuchiya)
|
|
6
|
+
Author-email: Yuichiro Tachibana (Tsuchiya) <t.yic.yt@gmail.com>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
18
|
+
Requires-Dist: cryptography>=42
|
|
19
|
+
Requires-Dist: streamlit>=1.28
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Project-URL: Homepage, https://github.com/whitphx/streamlit-remote
|
|
22
|
+
Project-URL: Repository, https://github.com/whitphx/streamlit-remote
|
|
23
|
+
Project-URL: Issues, https://github.com/whitphx/streamlit-remote/issues
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# streamlit-remote
|
|
27
|
+
|
|
28
|
+
`streamlit-remote` runs a Streamlit app locally, can serve it over local HTTPS, and can expose it through a temporary remote HTTPS URL.
|
|
29
|
+
|
|
30
|
+
It supports Cloudflare Quick Tunnel, ngrok, and managed self-signed certificates for local HTTPS. It is meant for development, demos, and temporary sharing, similar in spirit to Slidev's remote access workflow.
|
|
31
|
+
|
|
32
|
+
## Installation
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install streamlit-remote
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
This package requires Python 3.10 or newer.
|
|
39
|
+
|
|
40
|
+
## Basic Usage
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
st-remote app.py
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
This starts Streamlit on `http://localhost:8501`, starts a Cloudflare Quick Tunnel to that local URL, prefixes logs from both child processes, prints the public `trycloudflare.com` URL once Cloudflare reports it, and opens that remote URL in your browser.
|
|
47
|
+
|
|
48
|
+
You can also use the alias:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
streamlit-remote app.py
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Options
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
st-remote APP [--port 8501] [--host localhost] [--https off] [--provider cloudflare]
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Useful options:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
st-remote app.py --port 9000
|
|
64
|
+
st-remote app.py --host 0.0.0.0
|
|
65
|
+
st-remote app.py --no-remote
|
|
66
|
+
st-remote app.py --no-browser
|
|
67
|
+
st-remote app.py --dry-run
|
|
68
|
+
st-remote app.py --https self-signed --no-remote
|
|
69
|
+
st-remote app.py --provider ngrok
|
|
70
|
+
st-remote app.py --provider ngrok --tunnel-log-level warn
|
|
71
|
+
st-remote app.py -- --server.headless true
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Extra arguments after `--` are passed to `python -m streamlit run`.
|
|
75
|
+
|
|
76
|
+
`st-remote` starts Streamlit in headless mode so Streamlit does not open the local URL automatically. When remote access is enabled, `st-remote` opens the detected remote HTTPS URL instead. Use `--no-browser` to suppress browser opening.
|
|
77
|
+
|
|
78
|
+
## Local HTTPS
|
|
79
|
+
|
|
80
|
+
By default, Streamlit runs locally over HTTP:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
st-remote app.py --https off
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
For local HTTPS, use managed self-signed mode:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
st-remote app.py --https self-signed --no-remote
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
`streamlit-remote` creates and reuses a local development certificate in its own cache directory, then passes Streamlit's `server.sslCertFile` and `server.sslKeyFile` options automatically. You do not need to choose filenames or manage generated certificate files.
|
|
93
|
+
|
|
94
|
+
Browsers generally do not trust self-signed certificates by default. You may see a certificate warning unless you manually trust the generated certificate. This mode is intended for local development and testing, not production.
|
|
95
|
+
|
|
96
|
+
Advanced users can pass existing certificate files:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
st-remote app.py --https cert-files \
|
|
100
|
+
--ssl-cert-file cert.pem \
|
|
101
|
+
--ssl-key-file key.pem
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Remote Providers
|
|
105
|
+
|
|
106
|
+
### Cloudflare Tunnel
|
|
107
|
+
|
|
108
|
+
Cloudflare Quick Tunnel requires the `cloudflared` command to be installed and available on `PATH`.
|
|
109
|
+
|
|
110
|
+
Install instructions are available from Cloudflare:
|
|
111
|
+
|
|
112
|
+
https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/
|
|
113
|
+
|
|
114
|
+
`streamlit-remote` checks for `cloudflared` before starting the tunnel and prints an actionable error if it is missing. It does not install `cloudflared` automatically.
|
|
115
|
+
|
|
116
|
+
### ngrok
|
|
117
|
+
|
|
118
|
+
ngrok requires the `ngrok` command to be installed and available on `PATH`.
|
|
119
|
+
|
|
120
|
+
Install instructions are available from ngrok:
|
|
121
|
+
|
|
122
|
+
https://ngrok.com/download
|
|
123
|
+
|
|
124
|
+
Configure your ngrok account token before use:
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
ngrok config add-authtoken TOKEN
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Then run:
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
st-remote app.py --provider ngrok
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
ngrok provides HTTPS for the public URL while forwarding to your local Streamlit app. If you combine ngrok with local self-signed HTTPS:
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
st-remote app.py --provider ngrok --https self-signed
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
ngrok still provides HTTPS for the public URL. The self-signed certificate is used only between the local ngrok agent and Streamlit.
|
|
143
|
+
|
|
144
|
+
## Tunnel Logs
|
|
145
|
+
|
|
146
|
+
Tunnel provider logs are shown by default:
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
st-remote app.py --tunnel-log-level info
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Use a quieter level to reduce provider noise while keeping Streamlit logs visible:
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
st-remote app.py --provider ngrok --tunnel-log-level warn
|
|
156
|
+
st-remote app.py --provider ngrok --tunnel-log-level error
|
|
157
|
+
st-remote app.py --provider ngrok --tunnel-log-level off
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
`off` suppresses printed tunnel logs but still captures provider output internally when needed to detect the remote URL. ngrok also uses its local agent API as a fallback for URL detection.
|
|
161
|
+
|
|
162
|
+
## HTTPS Serving vs Remote Access
|
|
163
|
+
|
|
164
|
+
The design treats HTTPS serving and remote access as separate concepts.
|
|
165
|
+
|
|
166
|
+
HTTPS serving means the user-facing URL uses HTTPS. Remote access means the app is reachable from outside your local machine.
|
|
167
|
+
|
|
168
|
+
Cloudflare Quick Tunnel and ngrok usually provide both at once: Streamlit can run locally over plain HTTP, while the provider gives you a public HTTPS URL that forwards to the local app.
|
|
169
|
+
|
|
170
|
+
Local self-signed HTTPS is different: Streamlit itself runs with HTTPS locally. You can use this without remote access, or combine it with a remote provider when you specifically want HTTPS between the tunnel agent and Streamlit.
|
|
171
|
+
|
|
172
|
+
Common combinations:
|
|
173
|
+
|
|
174
|
+
```text
|
|
175
|
+
--https off + --provider cloudflare
|
|
176
|
+
Public HTTPS via Cloudflare, local HTTP Streamlit.
|
|
177
|
+
|
|
178
|
+
--https off + --provider ngrok
|
|
179
|
+
Public HTTPS via ngrok, local HTTP Streamlit.
|
|
180
|
+
|
|
181
|
+
--https self-signed + --no-remote
|
|
182
|
+
Local HTTPS Streamlit only.
|
|
183
|
+
|
|
184
|
+
--https self-signed + --provider ngrok
|
|
185
|
+
Public HTTPS via ngrok, local HTTPS between ngrok and Streamlit.
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
For managed self-signed HTTPS with Cloudflare Tunnel, `streamlit-remote` also passes Cloudflare's origin TLS verification flag automatically so `cloudflared` can connect to the local self-signed Streamlit server.
|
|
189
|
+
|
|
190
|
+
## Security
|
|
191
|
+
|
|
192
|
+
This exposes a local Streamlit app to the internet.
|
|
193
|
+
|
|
194
|
+
Do not use it for sensitive data unless you have proper authentication and access control in place. Cloudflare Quick Tunnel and ngrok are best suited for development, demos, and temporary sharing.
|
|
195
|
+
|
|
196
|
+
Streamlit's built-in SSL configuration is useful for local testing, but it is not a replacement for a production HTTPS reverse proxy.
|
|
197
|
+
|
|
198
|
+
## Limitations
|
|
199
|
+
|
|
200
|
+
The current package does not include mkcert integration, production Cloudflare named tunnels, authentication, password protection, reverse proxy management, or PyPI publishing automation.
|
|
201
|
+
|
|
202
|
+
## Roadmap
|
|
203
|
+
|
|
204
|
+
- local HTTPS with mkcert
|
|
205
|
+
- optional auth and access-control integration
|
|
206
|
+
|
|
207
|
+
## Development
|
|
208
|
+
|
|
209
|
+
```bash
|
|
210
|
+
uv run pytest
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
## Release Management
|
|
214
|
+
|
|
215
|
+
This project uses `scriv-release` for changelog-fragment based releases.
|
|
216
|
+
|
|
217
|
+
For user-visible changes, add a fragment:
|
|
218
|
+
|
|
219
|
+
```bash
|
|
220
|
+
uv run scriv create --edit
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
When fragments are merged to `main`, the release workflow opens or updates a changelog preview PR. Merging that preview PR tags the release. Tag pushes matching `v*` run the PyPI publish workflow through Trusted Publishing.
|
|
224
|
+
|
|
225
|
+
The release workflow expects a GitHub App configured through `RELEASE_APP_CLIENT_ID` and `RELEASE_APP_KEY` so release tags can trigger the downstream publish workflow.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
streamlit_remote/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
2
|
+
streamlit_remote/cli.py,sha256=uf06kpaEezoc5oWd0u0UR-uJaIwrrvyP8x-_E7lOkeQ,11873
|
|
3
|
+
streamlit_remote/https.py,sha256=rHzSqN8EBHnoFb3sKHr_89sbhgLvv3wWU4l-yyE7Rsc,6159
|
|
4
|
+
streamlit_remote/process.py,sha256=OzlrTNWpoDYFO3X-1SI4drz5_jF0CKad17pY4W16Fww,2467
|
|
5
|
+
streamlit_remote/providers/__init__.py,sha256=u7PY69vMMHFB4M_joExxqnSmDjECQ7YhQJoWequ03dk,938
|
|
6
|
+
streamlit_remote/providers/cloudflare.py,sha256=zZlfSSHSOx1ZmZOQrg0vSoGWdF79GtXoz3ahdi_jjag,1225
|
|
7
|
+
streamlit_remote/providers/ngrok.py,sha256=zqCsAmv406W9u6GpFH77F_rRHJELVnhfHmd9LcIe5DU,2395
|
|
8
|
+
streamlit_remote/server.py,sha256=NotodxzLQQkscDJrPD_JsmrzyFDvNXaShDUjPHFqtGk,889
|
|
9
|
+
streamlit_remote-0.1.0.dist-info/licenses/LICENSE,sha256=xsfsjT1anhkYpYTUBK5AzuBgCSVwGxIqG0_5sVSy1Xk,1086
|
|
10
|
+
streamlit_remote-0.1.0.dist-info/WHEEL,sha256=wXwAVsgVaOZ_pwDFqQm5Rd6PID-Fc74nkLc8X8gHiDo,81
|
|
11
|
+
streamlit_remote-0.1.0.dist-info/entry_points.txt,sha256=_tb74_eGe-Mloy8jT3rZrswZHR1VkHMgAX4HWpz7vqk,102
|
|
12
|
+
streamlit_remote-0.1.0.dist-info/METADATA,sha256=6h4LcCs6G3YPerx2_yRbPC9tHicBRXX10zgymowyffU,7754
|
|
13
|
+
streamlit_remote-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yuichiro Tachibana (Tsuchiya)
|
|
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.
|