- Progressive retry in rns_client.py: fast timeout (15s) then slow (60s+) for LoRa/multi-hop links, with automatic fallback - Background sync threads so subscriptions page returns immediately with syncing/error status indicators per subscription - LoRa RNode configuration in settings page with serial port and expandable advanced radio settings (frequency, bandwidth, etc.) - Internet transport now toggleable alongside LoRa — users can enable one, the other, or both - Reticulum config auto-generated from settings on startup Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
252 lines
8.1 KiB
Python
252 lines
8.1 KiB
Python
import os
|
|
import sys
|
|
import time
|
|
import threading
|
|
import argparse
|
|
import RNS
|
|
from http.server import HTTPServer
|
|
|
|
from db import init_db, get_setting, set_setting
|
|
from handlers import dispatch_request
|
|
from gateway import GatewayState, GatewayHandler, GATEWAY_PORT
|
|
|
|
APP_NAME = "tinyweb"
|
|
ASPECTS = ["server"]
|
|
IDENTITY_FILE = "tinyweb_identity"
|
|
DEFAULT_TRANSPORT_HOST = "reticulum.derickphan.com"
|
|
DEFAULT_TRANSPORT_PORT = 4242
|
|
DATA_DIR = os.path.expanduser("~/.tinyweb")
|
|
|
|
|
|
def get_transport_config():
|
|
host = get_setting("transport_host", DEFAULT_TRANSPORT_HOST)
|
|
port = get_setting("transport_port", str(DEFAULT_TRANSPORT_PORT))
|
|
return host, int(port)
|
|
|
|
|
|
def find_available_port(start=8080, max_attempts=20):
|
|
"""Find an available port starting from start."""
|
|
import socket
|
|
for port in range(start, start + max_attempts):
|
|
try:
|
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
s.bind(("0.0.0.0", port))
|
|
return port
|
|
except OSError:
|
|
continue
|
|
return start
|
|
|
|
|
|
def get_version():
|
|
"""Get version from git tag or VERSION file."""
|
|
try:
|
|
import subprocess
|
|
tag = subprocess.check_output(
|
|
["git", "describe", "--tags", "--abbrev=0"],
|
|
stderr=subprocess.DEVNULL,
|
|
text=True
|
|
).strip()
|
|
if tag.startswith("v"):
|
|
return tag[1:]
|
|
return tag
|
|
except Exception:
|
|
version_file = os.path.join(os.path.dirname(__file__), "VERSION")
|
|
if os.path.exists(version_file):
|
|
with open(version_file) as f:
|
|
return f.read().strip()
|
|
return "0.0.0"
|
|
|
|
|
|
def load_or_create_identity():
|
|
os.makedirs(DATA_DIR, exist_ok=True)
|
|
identity_path = os.path.join(DATA_DIR, IDENTITY_FILE)
|
|
if os.path.isfile(identity_path):
|
|
current = os.stat(identity_path).st_mode & 0o777
|
|
if current != 0o600:
|
|
os.chmod(identity_path, 0o600)
|
|
return RNS.Identity.from_file(identity_path)
|
|
identity = RNS.Identity()
|
|
identity.to_file(identity_path)
|
|
os.chmod(identity_path, 0o600)
|
|
return identity
|
|
|
|
|
|
def rns_request_handler(path, data, request_id, link_id, remote_identity, requested_at):
|
|
if data is None:
|
|
data = {"method": "GET", "path": "/", "query": {}, "body": {}, "gateway_host": ""}
|
|
return dispatch_request(data)
|
|
|
|
|
|
def start_gateway(reticulum):
|
|
GatewayState.reticulum = reticulum
|
|
GatewayState.local_dispatch = dispatch_request
|
|
server = HTTPServer(("0.0.0.0", GATEWAY_PORT), GatewayHandler)
|
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
thread.start()
|
|
|
|
|
|
def _config_settings_match(config_file, desired_host, desired_port):
|
|
"""Check if existing config transport and LoRa settings match desired values."""
|
|
import configparser
|
|
try:
|
|
config = configparser.ConfigParser()
|
|
config.read(config_file)
|
|
# Check TCP transport
|
|
tcp_enabled = get_setting("tcp_enabled", "1") == "1"
|
|
has_tcp = config.has_section("TCP Transport")
|
|
if tcp_enabled != has_tcp:
|
|
return False
|
|
if tcp_enabled and has_tcp:
|
|
if (config.get("TCP Transport", "target_host") != desired_host or
|
|
config.get("TCP Transport", "target_port") != str(desired_port)):
|
|
return False
|
|
# Check LoRa
|
|
lora_enabled = get_setting("lora_enabled", "0") == "1"
|
|
has_lora = config.has_section("RNode LoRa")
|
|
if lora_enabled != has_lora:
|
|
return False
|
|
if lora_enabled and has_lora:
|
|
if config.get("RNode LoRa", "port", fallback="") != get_setting("lora_port", ""):
|
|
return False
|
|
if config.get("RNode LoRa", "frequency", fallback="") != get_setting("lora_frequency", "867200000"):
|
|
return False
|
|
return True
|
|
except Exception:
|
|
pass
|
|
return False
|
|
|
|
|
|
def ensure_rns_config(config_dir, transport_host=None, transport_port=None):
|
|
"""Generate a default Reticulum config with internet transport if none exists."""
|
|
if config_dir is None:
|
|
config_dir = os.path.expanduser("~/.reticulum")
|
|
config_file = os.path.join(config_dir, "config")
|
|
if transport_host is None:
|
|
transport_host = get_setting("transport_host", DEFAULT_TRANSPORT_HOST)
|
|
if transport_port is None:
|
|
transport_port = int(get_setting("transport_port", str(DEFAULT_TRANSPORT_PORT)))
|
|
|
|
if os.path.exists(config_file):
|
|
if _config_settings_match(config_file, transport_host, transport_port):
|
|
return
|
|
|
|
# Build optional interface blocks
|
|
tcp_block = ""
|
|
if get_setting("tcp_enabled", "1") == "1":
|
|
tcp_block = f"""
|
|
[[TCP Transport]]
|
|
type = TCPClientInterface
|
|
enabled = yes
|
|
target_host = {transport_host}
|
|
target_port = {transport_port}
|
|
"""
|
|
|
|
lora_block = ""
|
|
if get_setting("lora_enabled", "0") == "1":
|
|
lora_port = get_setting("lora_port", "")
|
|
if lora_port:
|
|
lora_frequency = get_setting("lora_frequency", "867200000")
|
|
lora_bandwidth = get_setting("lora_bandwidth", "125000")
|
|
lora_txpower = get_setting("lora_txpower", "7")
|
|
lora_sf = get_setting("lora_sf", "8")
|
|
lora_cr = get_setting("lora_cr", "5")
|
|
lora_block = f"""
|
|
[[RNode LoRa]]
|
|
type = RNodeInterface
|
|
enabled = yes
|
|
port = {lora_port}
|
|
frequency = {lora_frequency}
|
|
bandwidth = {lora_bandwidth}
|
|
txpower = {lora_txpower}
|
|
spreadingfactor = {lora_sf}
|
|
codingrate = {lora_cr}
|
|
"""
|
|
|
|
os.makedirs(config_dir, exist_ok=True)
|
|
with open(config_file, "w") as f:
|
|
f.write(f"""[reticulum]
|
|
enable_transport = False
|
|
share_instance = No
|
|
|
|
[logging]
|
|
loglevel = 4
|
|
|
|
[interfaces]
|
|
[[Default Interface]]
|
|
type = AutoInterface
|
|
enabled = Yes
|
|
{tcp_block}{lora_block}""")
|
|
print(f"Created Reticulum config at {config_file}")
|
|
|
|
|
|
def _preload_embeddings():
|
|
"""Pre-load the embedding model and build the HNSW index in background."""
|
|
if get_setting("semantic_search", "0") != "1":
|
|
print("Semantic search disabled.")
|
|
return
|
|
try:
|
|
from embeddings import _get_session, _get_reranker, build_index
|
|
_get_session()
|
|
build_index()
|
|
if get_setting("use_reranker", "0") == "1":
|
|
_get_reranker()
|
|
print("Semantic search ready (with reranker).")
|
|
else:
|
|
print("Semantic search ready.")
|
|
except Exception as e:
|
|
print(f"Semantic search unavailable: {e}")
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(prog="tinyweb", description="Personal decentralized search engine")
|
|
parser.add_argument("--version", "-v", action="store_true", help="Show version")
|
|
parser.add_argument("--port", "-p", type=int, default=None, help="HTTP gateway port (default: 8080)")
|
|
args = parser.parse_args()
|
|
|
|
if args.version:
|
|
print(f"TinyWeb {get_version()}")
|
|
return
|
|
|
|
port = args.port or 8080
|
|
import gateway
|
|
gateway.GATEWAY_PORT = find_available_port(port)
|
|
|
|
init_db()
|
|
transport_host = get_setting("transport_host", DEFAULT_TRANSPORT_HOST)
|
|
transport_port = int(get_setting("transport_port", str(DEFAULT_TRANSPORT_PORT)))
|
|
threading.Thread(target=_preload_embeddings, daemon=True).start()
|
|
config_dir = os.environ.get("RNS_CONFIG_DIR")
|
|
ensure_rns_config(config_dir, transport_host, transport_port)
|
|
reticulum = RNS.Reticulum(configdir=config_dir)
|
|
identity = load_or_create_identity()
|
|
|
|
destination = RNS.Destination(
|
|
identity,
|
|
RNS.Destination.IN,
|
|
RNS.Destination.SINGLE,
|
|
APP_NAME,
|
|
*ASPECTS,
|
|
)
|
|
|
|
destination.register_request_handler(
|
|
"/tinyweb",
|
|
response_generator=rns_request_handler,
|
|
allow=RNS.Destination.ALLOW_ALL,
|
|
)
|
|
|
|
# Brief delay to ensure all interfaces (especially TCP) are fully ready
|
|
time.sleep(2)
|
|
destination.announce()
|
|
set_setting("dest_hash", destination.hash.hex())
|
|
start_gateway(reticulum)
|
|
|
|
print(f"TinyWeb running!")
|
|
print(f"Open http://localhost:{GATEWAY_PORT} in your browser")
|
|
print(f"Destination hash: {RNS.prettyhexrep(destination.hash)} (share this so friends can subscribe)")
|
|
|
|
while True:
|
|
time.sleep(1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|