🎉 Initial commit: PiAgentUI — AI Agent Desktop & Web UI
Build & Push Frontend Docker Image / build-and-push (push) Canceled after 0s
Build Validation / validate (push) Canceled after 6s
Build & Push Backend Docker Image / build-and-push (push) Failing after 10m10s
Build & Push Gateway Docker Image / build-and-push (push) Failing after 10m9s

A professional workspace for AI coding agents with:
- Real-time streaming chat with SSE
- Session management (fork, clone, tree navigation)
- MCP tools browser & metadata panel
- Skills browser (global, project, package sources)
- Voice input & STT provider configuration
- Integrated terminal & file explorer
- Model selector with capability metadata
- Keyboard-first UI with command palette
- 8 languages (en, pt-BR, es, ja, zh-CN, ar, bn, hi)
- Tauri 2 desktop app
- React 19 + TypeScript + Vite 8 + Tailwind CSS 4
- Pi Agent extension runtime

Originally forked from OpenCodeUI, now an independent project.
License: GPL-3.0-only
This commit is contained in:
Evandro committed 2026-06-01 00:50:06 -03:00
commit b3489e1ede
983 files changed
+158475

No files matched your search

+71
View File
@@ -0,0 +1,71 @@
use std::{collections::BTreeMap, path::Path};
use tokio::fs;
use crate::{
config::{Config, PREVIEW_FILE, ROUTES_FILE},
scanner,
state::RouteInfo,
};
pub(crate) async fn write_map(
config: &Config,
routes: &BTreeMap<String, RouteInfo>,
) -> Result<(), String> {
ensure_parent_dir(ROUTES_FILE).await?;
let mut lines = vec!["# Dynamic routes (auto generated by router)".to_string()];
for (token, info) in routes {
lines.push(format!("@route_{token} path /p/{token} /p/{token}/*"));
lines.push(format!("handle @route_{token} {{"));
lines.push(format!("\turi strip_prefix /p/{token}"));
lines.push(format!(
"\treverse_proxy {}:{}",
config.target_container(),
info.port
));
lines.push("}".to_string());
lines.push(String::new());
}
fs::write(ROUTES_FILE, lines.join("\n") + "\n")
.await
.map_err(|err| err.to_string())
}
pub(crate) async fn write_preview_conf(config: &Config, port: Option<u16>) -> Result<(), String> {
ensure_parent_dir(PREVIEW_FILE).await?;
let body = match port {
Some(port) => format!(
"# Preview proxy (auto generated by router)\nreverse_proxy {}:{}\n",
config.target_container(),
port
),
None => concat!(
"# Preview proxy (no port selected)\n",
"respond \"No preview port configured. Set one from the Routes panel.\" 503\n"
)
.to_string(),
};
fs::write(PREVIEW_FILE, body)
.await
.map_err(|err| err.to_string())
}
pub(crate) async fn reload_gateway() -> Result<(), String> {
scanner::run_cmd(&["caddy", "reload", "--config", "/etc/caddy/Caddyfile"]).await?;
Ok(())
}
async fn ensure_parent_dir(path: &str) -> Result<(), String> {
let Some(parent) = Path::new(path).parent() else {
return Ok(());
};
fs::create_dir_all(parent)
.await
.map_err(|err| err.to_string())
}
+127
View File
@@ -0,0 +1,127 @@
use std::{collections::HashSet, env};
pub(crate) const ROUTES_FILE: &str = "/etc/caddy/routes.conf";
pub(crate) const PREVIEW_FILE: &str = "/etc/caddy/preview.conf";
#[derive(Clone, Debug)]
pub struct Config {
target_container: String,
router_state_file: String,
public_base_url: String,
preview_domain: String,
router_username: String,
router_password: String,
scan_interval: u64,
token_length: usize,
port_range: (u16, u16),
exclude_ports: HashSet<u16>,
}
impl Config {
pub fn from_env() -> Self {
Self {
target_container: env::var("TARGET_CONTAINER")
.unwrap_or_else(|_| "opencode-backend".to_string()),
router_state_file: env::var("ROUTER_STATE_FILE")
.unwrap_or_else(|_| "/data/routes.json".to_string()),
public_base_url: env::var("PUBLIC_BASE_URL")
.map(|value| value.trim_end_matches('/').to_string())
.unwrap_or_default(),
preview_domain: env::var("PREVIEW_DOMAIN")
.map(|value| value.trim().to_string())
.unwrap_or_default(),
router_username: env::var("ROUTER_USERNAME").unwrap_or_default(),
router_password: env::var("ROUTER_PASSWORD").unwrap_or_default(),
scan_interval: parse_u64_env("ROUTER_SCAN_INTERVAL", 5),
token_length: parse_usize_env("ROUTER_TOKEN_LENGTH", 12),
port_range: parse_port_range(
env::var("ROUTER_PORT_RANGE")
.ok()
.as_deref()
.unwrap_or("3000-9999"),
),
exclude_ports: parse_exclude_ports(
env::var("ROUTER_EXCLUDE_PORTS")
.ok()
.as_deref()
.unwrap_or("4096"),
),
}
}
pub fn target_container(&self) -> &str {
&self.target_container
}
pub fn router_state_file(&self) -> &str {
&self.router_state_file
}
pub fn public_base_url(&self) -> &str {
&self.public_base_url
}
pub fn preview_domain(&self) -> &str {
&self.preview_domain
}
pub fn router_username(&self) -> &str {
&self.router_username
}
pub fn router_password(&self) -> &str {
&self.router_password
}
pub fn scan_interval(&self) -> u64 {
self.scan_interval
}
pub fn token_length(&self) -> usize {
self.token_length
}
pub fn port_range(&self) -> (u16, u16) {
self.port_range
}
pub fn exclude_ports(&self) -> &HashSet<u16> {
&self.exclude_ports
}
}
fn parse_u64_env(name: &str, default: u64) -> u64 {
env::var(name)
.ok()
.and_then(|value| value.parse::<u64>().ok())
.unwrap_or(default)
}
fn parse_usize_env(name: &str, default: usize) -> usize {
env::var(name)
.ok()
.and_then(|value| value.parse::<usize>().ok())
.unwrap_or(default)
}
fn parse_port_range(value: &str) -> (u16, u16) {
let Some((start, end)) = value.split_once('-') else {
return (3000, 9999);
};
let mut start = start.trim().parse::<u16>().unwrap_or(3000);
let mut end = end.trim().parse::<u16>().unwrap_or(9999);
if start > end {
std::mem::swap(&mut start, &mut end);
}
(start, end)
}
fn parse_exclude_ports(value: &str) -> HashSet<u16> {
value
.split(',')
.filter_map(|item| item.trim().parse::<u16>().ok())
.collect()
}
+36
View File
@@ -0,0 +1,36 @@
mod caddy;
mod config;
mod router;
mod scanner;
mod state;
use std::{net::SocketAddr, sync::Arc};
use config::Config;
use router::AppState;
#[tokio::main]
async fn main() {
env_logger::init();
let config = Arc::new(Config::from_env());
let state = Arc::new(AppState::new(config));
state.initialize().await;
state.spawn_sync_loop();
let app = router::app(state);
let addr = SocketAddr::from(([0, 0, 0, 0], 7070));
log::info!("Router starting on {}", addr);
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await
.unwrap();
}
async fn shutdown_signal() {
let _ = tokio::signal::ctrl_c().await;
}
+730
View File
@@ -0,0 +1,730 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>Routes - OpenCode</title>
<style>
:root {
--bg-0: #fafbfc;
--bg-1: #f3f5f7;
--bg-2: #e8ecef;
--bg-card: #ffffff;
--bg-hover: #f7f9fa;
--text-1: #1a2332;
--text-2: #4a5568;
--text-3: #8694a7;
--accent: #3b82f6;
--accent-hover: #2563eb;
--accent-soft: #eff6ff;
--green: #10b981;
--green-soft: #ecfdf5;
--border: #e2e8f0;
--border-focus: #93c5fd;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
--shadow-lg: 0 4px 12px rgba(0, 0, 0, 0.08);
--radius: 10px;
--radius-lg: 14px;
--mono: 'SF Mono', 'Cascadia Code', 'Fira Code', 'JetBrains Mono', monospace;
--sans: -apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', sans-serif;
}
@media (prefers-color-scheme: dark) {
:root {
--bg-0: #0f1419;
--bg-1: #161b22;
--bg-2: #1c2333;
--bg-card: #1c2333;
--bg-hover: #232b3a;
--text-1: #e6edf3;
--text-2: #8b949e;
--text-3: #545d68;
--accent: #58a6ff;
--accent-hover: #79c0ff;
--accent-soft: #161b22;
--green: #3fb950;
--green-soft: #0d1117;
--border: #30363d;
--border-focus: #388bfd;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 4px 12px rgba(0, 0, 0, 0.4);
}
}
html[data-theme='light'] {
--bg-0: #fafbfc;
--bg-1: #f3f5f7;
--bg-2: #e8ecef;
--bg-card: #ffffff;
--bg-hover: #f7f9fa;
--text-1: #1a2332;
--text-2: #4a5568;
--text-3: #8694a7;
--accent: #3b82f6;
--accent-hover: #2563eb;
--accent-soft: #eff6ff;
--green: #10b981;
--green-soft: #ecfdf5;
--border: #e2e8f0;
--border-focus: #93c5fd;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
--shadow-lg: 0 4px 12px rgba(0, 0, 0, 0.08);
}
html[data-theme='dark'] {
--bg-0: #0f1419;
--bg-1: #161b22;
--bg-2: #1c2333;
--bg-card: #1c2333;
--bg-hover: #232b3a;
--text-1: #e6edf3;
--text-2: #8b949e;
--text-3: #545d68;
--accent: #58a6ff;
--accent-hover: #79c0ff;
--accent-soft: #161b22;
--green: #3fb950;
--green-soft: #0d1117;
--border: #30363d;
--border-focus: #388bfd;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 4px 12px rgba(0, 0, 0, 0.4);
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: var(--sans);
background: var(--bg-0);
color: var(--text-1);
min-height: 100vh;
min-height: 100dvh;
-webkit-font-smoothing: antialiased;
}
.page {
max-width: 640px;
margin: 0 auto;
padding: 40px 16px 60px;
}
/* ---- Header ---- */
.header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 24px;
}
.header-left {
flex: 1;
min-width: 0;
}
.title {
font-size: 22px;
font-weight: 700;
letter-spacing: -0.02em;
line-height: 1.2;
}
.status {
display: inline-flex;
align-items: center;
gap: 6px;
margin-top: 6px;
font-size: 12px;
color: var(--text-3);
line-height: 1;
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--green);
flex-shrink: 0;
}
.header-actions {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
/* ---- Icon buttons ---- */
.icon-btn {
width: 34px;
height: 34px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-card);
color: var(--text-2);
cursor: pointer;
transition: all 120ms ease;
flex-shrink: 0;
}
.icon-btn:hover {
background: var(--bg-hover);
color: var(--text-1);
border-color: var(--border-focus);
}
.icon-btn:active {
transform: scale(0.93);
}
.icon-btn svg {
width: 16px;
height: 16px;
}
/* ---- Search ---- */
.search-wrap {
position: relative;
margin-bottom: 16px;
}
.search-icon {
position: absolute;
left: 11px;
top: 50%;
transform: translateY(-50%);
width: 15px;
height: 15px;
color: var(--text-3);
pointer-events: none;
}
.search {
width: 100%;
height: 38px;
padding: 0 12px 0 34px;
border-radius: var(--radius);
border: 1px solid var(--border);
background: var(--bg-card);
color: var(--text-1);
font-size: 13px;
font-family: var(--sans);
outline: none;
transition:
border-color 150ms,
box-shadow 150ms;
}
.search::placeholder {
color: var(--text-3);
}
.search:focus {
border-color: var(--border-focus);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 12%, transparent);
}
/* ---- Card list ---- */
.list {
display: flex;
flex-direction: column;
gap: 8px;
}
.card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 14px 16px;
box-shadow: var(--shadow);
transition:
box-shadow 150ms,
border-color 150ms;
}
.card:hover {
box-shadow: var(--shadow-lg);
border-color: var(--border-focus);
}
.card-top {
display: flex;
align-items: center;
gap: 10px;
}
.port-badge {
display: inline-flex;
align-items: center;
height: 24px;
padding: 0 8px;
border-radius: 6px;
background: var(--accent-soft);
color: var(--accent);
font-family: var(--mono);
font-size: 12px;
font-weight: 600;
flex-shrink: 0;
letter-spacing: 0.01em;
}
.card-time {
font-size: 11px;
color: var(--text-3);
white-space: nowrap;
flex-shrink: 0;
}
.card-url {
display: block;
margin-top: 8px;
font-family: var(--mono);
font-size: 12px;
line-height: 1.5;
color: var(--text-2);
word-break: break-all;
user-select: all;
}
/* ---- Empty state ---- */
.empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 56px 24px;
text-align: center;
border: 1px dashed var(--border);
border-radius: var(--radius-lg);
background: var(--bg-card);
}
.empty-icon {
width: 40px;
height: 40px;
color: var(--text-3);
opacity: 0.5;
margin-bottom: 16px;
}
.empty-title {
font-size: 14px;
font-weight: 600;
color: var(--text-2);
margin-bottom: 4px;
}
.empty-hint {
font-size: 12px;
color: var(--text-3);
line-height: 1.5;
}
/* ---- Toast ---- */
.toast {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%) translateY(80px);
padding: 8px 16px;
border-radius: 8px;
background: var(--text-1);
color: var(--bg-0);
font-size: 12px;
font-weight: 500;
box-shadow: var(--shadow-lg);
opacity: 0;
pointer-events: none;
transition: all 250ms cubic-bezier(0.4, 0, 0.2, 1);
z-index: 100;
}
.toast.show {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
/* ---- Mobile ---- */
@media (max-width: 480px) {
.page {
padding: 24px 12px 48px;
}
.title {
font-size: 18px;
}
.card {
padding: 12px 14px;
}
.card-time {
font-size: 10px;
}
.card-url {
font-size: 11px;
}
}
/* ---- Preview badge ---- */
.preview-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
margin-bottom: 16px;
border-radius: var(--radius);
background: var(--accent-soft);
border: 1px solid color-mix(in srgb, var(--accent) 20%, transparent);
font-size: 12px;
color: var(--text-2);
}
.preview-bar-icon {
width: 14px;
height: 14px;
color: var(--accent);
flex-shrink: 0;
}
.preview-bar a {
color: var(--accent);
text-decoration: none;
font-family: var(--mono);
font-weight: 500;
word-break: break-all;
}
.preview-bar a:hover {
text-decoration: underline;
}
.preview-bar-stop {
margin-left: auto;
padding: 3px 8px;
border-radius: 5px;
border: 1px solid var(--border);
background: var(--bg-card);
color: var(--text-2);
font-size: 11px;
cursor: pointer;
flex-shrink: 0;
}
.preview-bar-stop:hover {
background: var(--bg-hover);
color: var(--text-1);
}
.card.previewing {
border-color: var(--accent);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 15%, transparent);
}
.card-actions {
display: flex;
align-items: center;
gap: 4px;
margin-left: auto;
flex-shrink: 0;
}
.card-btn {
width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-card);
color: var(--text-3);
cursor: pointer;
transition: all 120ms ease;
}
.card-btn:hover {
background: var(--bg-hover);
color: var(--accent);
border-color: var(--accent);
}
.card-btn:active {
transform: scale(0.9);
}
.card-btn.active {
background: var(--accent-soft);
color: var(--accent);
border-color: var(--accent);
}
.card-btn svg {
width: 14px;
height: 14px;
}
</style>
</head>
<body>
<div class="page">
<div class="header">
<div class="header-left">
<div class="title">Routes</div>
<div class="status">
<span class="status-dot"></span>
<span id="count">0 active</span>
</div>
</div>
<div class="header-actions">
<button class="icon-btn" id="themeToggle" title="Toggle theme">
<svg
id="themeIcon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="5" />
<line x1="12" y1="1" x2="12" y2="3" />
<line x1="12" y1="21" x2="12" y2="23" />
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
<line x1="1" y1="12" x2="3" y2="12" />
<line x1="21" y1="12" x2="23" y2="12" />
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
</svg>
</button>
<button class="icon-btn" id="copyAll" title="Copy all URLs">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
</button>
<button class="icon-btn" id="refresh" title="Refresh">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="23 4 23 10 17 10" />
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
</svg>
</button>
</div>
</div>
<div id="previewBar"></div>
<div class="search-wrap">
<svg
class="search-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<input class="search" id="filter" placeholder="Filter by port or URL..." />
</div>
<section id="content"></section>
<div class="toast" id="toast"></div>
</div>
<script>
const $ = id => document.getElementById(id)
const content = $('content'),
countEl = $('count')
const filterInput = $('filter'),
toastEl = $('toast')
// ---- Toast ----
let toastTimer
function toast(msg) {
clearTimeout(toastTimer)
toastEl.textContent = msg
toastEl.classList.add('show')
toastTimer = setTimeout(() => toastEl.classList.remove('show'), 1800)
}
// ---- Theme ----
const sunIcon =
'<circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>'
const moonIcon = '<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>'
const autoIcon = '<circle cx="12" cy="12" r="10"/><path d="M12 2a10 10 0 0 1 0 20z" fill="currentColor"/>'
function applyTheme(t) {
if (!t || t === 'auto') document.documentElement.removeAttribute('data-theme')
else document.documentElement.setAttribute('data-theme', t)
const icon = t === 'dark' ? moonIcon : t === 'light' ? sunIcon : autoIcon
$('themeIcon').innerHTML = icon
$('themeToggle').title = 'Theme: ' + (t || 'auto')
}
function cycleTheme() {
const m = { light: 'dark', dark: 'auto', auto: 'light' }
const cur = localStorage.getItem('routes-theme') || 'auto'
const next = m[cur] || 'light'
localStorage.setItem('routes-theme', next)
applyTheme(next)
}
applyTheme(localStorage.getItem('routes-theme') || 'auto')
$('themeToggle').addEventListener('click', cycleTheme)
// ---- Helpers ----
function ago(ts) {
if (!ts) return ''
const s = Math.floor(Date.now() / 1000 - ts)
if (s < 60) return 'just now'
if (s < 3600) return Math.floor(s / 60) + 'm ago'
if (s < 86400) return Math.floor(s / 3600) + 'h ago'
return Math.floor(s / 86400) + 'd ago'
}
function getUrl(r) {
if (r.publicUrl) return r.publicUrl
const domain = currentData.previewDomain
if (domain) return 'https://' + domain + '/p/' + r.token + '/'
return location.origin + '/p/' + r.token + '/'
}
// ---- Render ----
function render(routes) {
countEl.textContent = routes.length + ' active'
if (!routes.length) {
content.innerHTML = `
<div class="empty">
<svg class="empty-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/>
</svg>
<div class="empty-title">No active routes</div>
<div class="empty-hint">Start a dev server in the backend container<br/>and it will appear here automatically</div>
</div>`
return
}
const cards = routes.map(r => {
const url = getUrl(r)
const isPreviewing = currentData.previewPort === r.port
const previewCls = isPreviewing ? ' previewing' : ''
const btnCls = isPreviewing ? ' active' : ''
return `<div class="card${previewCls}" data-url="${url}" data-port="${r.port}">
<div class="card-top">
<span class="port-badge">${r.port}</span>
<span class="card-time">${ago(r.createdAt)}</span>
<div class="card-actions">
<button class="card-btn preview-btn${btnCls}" data-port="${r.port}" title="${isPreviewing ? 'Stop preview' : 'Preview this port'}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>
</svg>
</button>
<button class="card-btn copy-btn" data-url="${url}" title="Copy URL">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
</button>
</div>
</div>
<code class="card-url">${url}</code>
</div>`
})
content.innerHTML = '<div class="list">' + cards.join('') + '</div>'
content.querySelectorAll('.copy-btn').forEach(btn => {
btn.addEventListener('click', async e => {
e.stopPropagation()
try {
await navigator.clipboard.writeText(btn.dataset.url)
toast('Copied')
} catch (e) {}
})
})
content.querySelectorAll('.preview-btn').forEach(btn => {
btn.addEventListener('click', async e => {
e.stopPropagation()
const port = parseInt(btn.dataset.port)
const isActive = currentData.previewPort === port
await setPreview(isActive ? null : port)
})
})
}
// ---- Data ----
let currentData = __INITIAL_DATA__
// ---- Preview ----
const previewBarEl = $('previewBar')
const eyeOpenIcon = '<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>'
function renderPreviewBar() {
const port = currentData.previewPort
const domain = currentData.previewDomain
if (!port) {
previewBarEl.innerHTML = ''
return
}
const link = domain ? `<a href="https://${domain}" target="_blank">${domain}</a>` : `port ${port}`
previewBarEl.innerHTML = `
<div class="preview-bar">
<svg class="preview-bar-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${eyeOpenIcon}</svg>
<span>Preview active: ${link} &rarr; :${port}</span>
<button class="preview-bar-stop" id="stopPreview">Stop</button>
</div>`
$('stopPreview').addEventListener('click', () => setPreview(null))
}
async function setPreview(port) {
try {
const res = await fetch('/preview/set', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ port }),
})
if (!res.ok) {
toast('Failed: ' + res.status)
return
}
currentData.previewPort = port
toast(port ? 'Preview: port ' + port : 'Preview stopped')
renderPreviewBar()
applyFilter()
} catch (e) {
toast('Error setting preview')
}
}
function applyFilter() {
const q = filterInput.value.trim().toLowerCase()
const routes = (currentData.routes || []).filter(
r =>
!q ||
r.token.toLowerCase().includes(q) ||
String(r.port).includes(q) ||
getUrl(r).toLowerCase().includes(q),
)
render(routes)
}
async function load() {
try {
const res = await fetch('/routes?format=json', { cache: 'no-store', credentials: 'same-origin' })
if (!res.ok) {
countEl.textContent = 'error ' + res.status
return
}
currentData = await res.json()
applyFilter()
renderPreviewBar()
} catch (e) {
countEl.textContent = 'offline'
}
}
$('refresh').addEventListener('click', () => {
load()
toast('Refreshing...')
})
filterInput.addEventListener('input', applyFilter)
$('copyAll').addEventListener('click', async () => {
const urls = (currentData.routes || []).map(getUrl).join('\\n')
if (urls) {
await navigator.clipboard.writeText(urls)
toast('All URLs copied')
}
})
applyFilter()
renderPreviewBar()
setInterval(load, 8000)
</script>
</body>
</html>
+396
View File
@@ -0,0 +1,396 @@
use std::{
collections::HashSet,
sync::Arc,
time::{Duration, SystemTime, UNIX_EPOCH},
};
use axum::{
Json, Router,
extract::{Query, State},
http::{HeaderMap, HeaderValue, StatusCode, header},
response::{Html, IntoResponse, Response},
routing::{get, post},
};
use base64::{Engine as _, engine::general_purpose::STANDARD};
use rand::{Rng, distr::Alphanumeric, rng};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
use tokio::{
sync::RwLock,
time::{MissedTickBehavior, interval},
};
use crate::{
caddy,
config::Config,
scanner,
state::{self, RouteInfo},
};
const HTML_TEMPLATE: &str = include_str!("router.html");
#[derive(Clone)]
pub struct AppState {
config: Arc<Config>,
/// In-memory cache of the full state map (routes + preview port).
/// All reads/writes go through this lock; disk is only for persistence.
state_map: Arc<RwLock<Map<String, Value>>>,
preview_port: Arc<RwLock<Option<u16>>>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct RoutePayloadItem {
token: String,
port: u16,
public_url: String,
created_at: u64,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct RoutesPayload {
routes: Vec<RoutePayloadItem>,
preview_port: Option<u16>,
preview_domain: Option<String>,
}
#[derive(Debug, Deserialize)]
struct PreviewSetRequest {
port: Option<u16>,
}
#[derive(Debug, Deserialize)]
struct RoutesQuery {
format: Option<String>,
}
impl AppState {
pub fn new(config: Arc<Config>) -> Self {
Self {
config,
state_map: Arc::new(RwLock::new(Map::new())),
preview_port: Arc::new(RwLock::new(None)),
}
}
pub async fn initialize(&self) {
let state_map = state::load_state_map(self.config.router_state_file()).await;
let preview_port = state::load_preview_port(&state_map);
{
let mut cached = self.state_map.write().await;
*cached = state_map;
}
{
let mut current = self.preview_port.write().await;
*current = preview_port;
}
if let Err(err) = caddy::write_preview_conf(&self.config, preview_port).await {
log::error!("Failed to write preview config: {}", err);
}
if let Some(port) = preview_port {
log::info!("Restored preview port: {}", port);
}
}
pub fn spawn_sync_loop(self: &Arc<Self>) {
let state = Arc::clone(self);
tokio::spawn(async move {
log::info!(
"Route scanner started (interval={}s, target={}, range={}-{})",
state.config.scan_interval(),
state.config.target_container(),
state.config.port_range().0,
state.config.port_range().1,
);
let mut ticker = interval(Duration::from_secs(state.config.scan_interval()));
ticker.set_missed_tick_behavior(MissedTickBehavior::Skip);
loop {
ticker.tick().await;
if let Err(err) = state.sync_routes().await {
log::error!("Error in sync_routes: {}", err);
}
}
});
}
async fn sync_routes(&self) -> Result<(), String> {
let ports = match scanner::list_listening_ports(&self.config).await {
Ok(ports) => ports,
Err(err) => {
log::warn!(
"Failed to query ports from {}: {}",
self.config.target_container(),
err
);
return Ok(());
}
};
let mut state_map = self.state_map.write().await;
let mut routes = state::extract_routes(&state_map);
let existing_ports: HashSet<u16> = routes.values().map(|info| info.port).collect();
let mut changed = false;
for port in &ports {
if existing_ports.contains(port) {
continue;
}
let mut token = generate_token(self.config.token_length());
while state_map.contains_key(&token) {
token = generate_token(self.config.token_length());
}
let route = RouteInfo {
port: *port,
created_at: now_ts(),
};
routes.insert(token.clone(), route.clone());
state_map.insert(token.clone(), state::route_to_value(&route));
log::info!("New route: port {} -> /p/{}", port, token);
changed = true;
}
let active_ports: HashSet<u16> = ports.iter().copied().collect();
let stale_tokens: Vec<String> = routes
.iter()
.filter(|(_, info)| !active_ports.contains(&info.port))
.map(|(token, _)| token.clone())
.collect();
for token in stale_tokens {
if let Some(info) = routes.remove(&token) {
state_map.remove(&token);
log::info!("Removed stale route: port {} (token {})", info.port, token);
changed = true;
}
}
if changed {
caddy::write_map(&self.config, &routes).await?;
state::save_state_map(self.config.router_state_file(), &state_map).await?;
self.reload_gateway().await;
log::info!("Routes updated: {} active", routes.len());
}
Ok(())
}
async fn save_preview_port(&self, port: Option<u16>) -> Result<(), String> {
{
let mut current = self.preview_port.write().await;
*current = port;
}
let mut state_map = self.state_map.write().await;
state::set_preview_port_value(&mut state_map, port);
state::save_state_map(self.config.router_state_file(), &state_map).await
}
async fn set_preview_port(&self, port: Option<u16>) -> Result<(), String> {
caddy::write_preview_conf(&self.config, port).await?;
self.save_preview_port(port).await?;
self.reload_gateway().await;
log::info!("Preview port set to: {:?}", port);
Ok(())
}
async fn reload_gateway(&self) {
if let Err(err) = caddy::reload_gateway().await {
log::warn!("Failed to reload caddy: {}", err);
}
}
async fn build_routes_payload(&self) -> RoutesPayload {
let state_map = self.state_map.read().await;
let routes = state::extract_routes(&state_map)
.into_iter()
.map(|(token, info)| RoutePayloadItem {
public_url: self.public_url(&token),
token,
port: info.port,
created_at: info.created_at,
})
.collect();
RoutesPayload {
routes,
preview_port: *self.preview_port.read().await,
preview_domain: non_empty(self.config.preview_domain()),
}
}
fn public_url(&self, token: &str) -> String {
if !self.config.preview_domain().is_empty() {
return format!("https://{}/p/{}/", self.config.preview_domain(), token);
}
if !self.config.public_base_url().is_empty() {
return format!("{}/p/{}/", self.config.public_base_url(), token);
}
String::new()
}
}
pub fn app(state: Arc<AppState>) -> Router {
Router::new()
.route("/routes", get(get_routes))
.route("/preview/set", post(post_preview_set))
.route("/preview/status", get(get_preview_status))
.with_state(state)
}
async fn get_routes(
State(state): State<Arc<AppState>>,
Query(query): Query<RoutesQuery>,
headers: HeaderMap,
) -> Response {
if !check_basic_auth(&headers, &state.config) {
return unauthorized_response();
}
let payload = state.build_routes_payload().await;
log::info!(
"Serving /routes ({} routes, format={})",
payload.routes.len(),
query.format.as_deref().unwrap_or("html")
);
if query.format.as_deref() == Some("json") {
let mut response = Json(payload).into_response();
set_no_cache_headers(response.headers_mut());
return response;
}
let initial_data = serde_json::to_string(&payload).unwrap_or_else(|_| "{}".to_string());
let html = HTML_TEMPLATE.replace("__INITIAL_DATA__", &initial_data);
let mut response = Html(html).into_response();
set_no_cache_headers(response.headers_mut());
response
}
async fn get_preview_status(State(state): State<Arc<AppState>>, headers: HeaderMap) -> Response {
if !check_basic_auth(&headers, &state.config) {
return unauthorized_response();
}
let payload = json!({
"previewPort": *state.preview_port.read().await,
"previewDomain": non_empty(state.config.preview_domain()),
});
Json(payload).into_response()
}
async fn post_preview_set(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
payload: Result<Json<PreviewSetRequest>, axum::extract::rejection::JsonRejection>,
) -> Response {
if !check_basic_auth(&headers, &state.config) {
return unauthorized_response();
}
let Json(request) = match payload {
Ok(payload) => payload,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({ "error": "invalid port" })),
)
.into_response();
}
};
match state.set_preview_port(request.port).await {
Ok(()) => Json(json!({ "ok": true, "previewPort": request.port })).into_response(),
Err(err) => {
log::error!("Failed to set preview port: {}", err);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": "failed to set preview port" })),
)
.into_response()
}
}
}
fn check_basic_auth(headers: &HeaderMap, config: &Config) -> bool {
if config.router_password().is_empty() {
return true;
}
let Some(auth_header) = headers.get(header::AUTHORIZATION) else {
return false;
};
let Ok(auth_header) = auth_header.to_str() else {
return false;
};
let lower = auth_header.to_ascii_lowercase();
if !lower.starts_with("basic ") {
return false;
};
let raw = &auth_header["basic ".len()..];
let Ok(decoded) = STANDARD.decode(raw.trim()) else {
return false;
};
let Ok(decoded) = String::from_utf8(decoded) else {
return false;
};
let Some((user, password)) = decoded.split_once(':') else {
return false;
};
user == config.router_username() && password == config.router_password()
}
fn unauthorized_response() -> Response {
let mut response = (StatusCode::UNAUTHORIZED, "Unauthorized").into_response();
response
.headers_mut()
.insert(header::WWW_AUTHENTICATE, HeaderValue::from_static("Basic"));
response
}
fn set_no_cache_headers(headers: &mut HeaderMap) {
headers.insert(
header::CACHE_CONTROL,
HeaderValue::from_static("no-cache, no-store, must-revalidate"),
);
headers.insert(header::PRAGMA, HeaderValue::from_static("no-cache"));
}
fn generate_token(length: usize) -> String {
rng()
.sample_iter(Alphanumeric)
.take(length)
.map(char::from)
.collect()
}
fn now_ts() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
fn non_empty(value: &str) -> Option<String> {
if value.is_empty() {
None
} else {
Some(value.to_string())
}
}
+89
View File
@@ -0,0 +1,89 @@
use std::collections::HashSet;
use tokio::process::Command;
use crate::config::Config;
pub async fn list_listening_ports(config: &Config) -> Result<Vec<u16>, String> {
let output = run_cmd(&[
"docker",
"exec",
config.target_container(),
"sh",
"-c",
"cat /proc/net/tcp /proc/net/tcp6",
])
.await?;
let ports = parse_proc_net_tcp(&output);
let (start, end) = config.port_range();
let mut filtered: Vec<u16> = ports
.into_iter()
.filter(|port| !config.exclude_ports().contains(port))
.filter(|port| *port >= start && *port <= end)
.collect();
filtered.sort_unstable();
Ok(filtered)
}
pub(crate) async fn run_cmd(args: &[&str]) -> Result<String, String> {
let (program, rest) = args
.split_first()
.ok_or_else(|| "empty command".to_string())?;
let output = Command::new(program)
.args(rest)
.output()
.await
.map_err(|err| err.to_string())?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
log::warn!(
"Command failed (rc={}): {} | stderr: {}",
output.status.code().unwrap_or(-1),
args.join(" "),
stderr,
);
return Err(stderr);
}
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}
fn parse_proc_net_tcp(output: &str) -> HashSet<u16> {
let mut ports = HashSet::new();
for line in output.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with("sl") {
continue;
}
let mut parts = line.split_whitespace();
let _slot = parts.next();
let Some(local) = parts.next() else {
continue;
};
let _remote = parts.next();
let Some(state) = parts.next() else {
continue;
};
if state != "0A" {
continue;
}
let Some((_, port_hex)) = local.rsplit_once(':') else {
continue;
};
let Ok(port) = u16::from_str_radix(port_hex, 16) else {
continue;
};
ports.insert(port);
}
ports
}
+85
View File
@@ -0,0 +1,85 @@
use std::{collections::BTreeMap, path::Path};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
use tokio::fs;
const PREVIEW_STATE_KEY: &str = "__preview_port__";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct RouteInfo {
pub(crate) port: u16,
pub(crate) created_at: u64,
}
pub(crate) async fn load_state_map(path: &str) -> Map<String, Value> {
let Ok(content) = fs::read_to_string(path).await else {
return Map::new();
};
serde_json::from_str::<Map<String, Value>>(&content).unwrap_or_default()
}
pub(crate) async fn save_state_map(
path: &str,
state_map: &Map<String, Value>,
) -> Result<(), String> {
ensure_parent_dir(path).await?;
let mut sorted = BTreeMap::new();
for (key, value) in state_map {
sorted.insert(key.clone(), value.clone());
}
let body = serde_json::to_string_pretty(&sorted).map_err(|err| err.to_string())?;
fs::write(path, format!("{body}\n"))
.await
.map_err(|err| err.to_string())
}
pub(crate) fn extract_routes(state_map: &Map<String, Value>) -> BTreeMap<String, RouteInfo> {
state_map
.iter()
.filter(|(key, _)| !key.starts_with("__"))
.filter_map(|(token, value)| {
serde_json::from_value::<RouteInfo>(value.clone())
.ok()
.map(|info| (token.clone(), info))
})
.collect()
}
pub(crate) fn route_to_value(route: &RouteInfo) -> Value {
json!({
"port": route.port,
"created_at": route.created_at,
})
}
pub(crate) fn load_preview_port(state_map: &Map<String, Value>) -> Option<u16> {
state_map
.get(PREVIEW_STATE_KEY)
.and_then(Value::as_u64)
.and_then(|value| u16::try_from(value).ok())
}
pub(crate) fn set_preview_port_value(state_map: &mut Map<String, Value>, port: Option<u16>) {
match port {
Some(port) => {
state_map.insert(PREVIEW_STATE_KEY.to_string(), json!(port));
}
None => {
state_map.remove(PREVIEW_STATE_KEY);
}
}
}
async fn ensure_parent_dir(path: &str) -> Result<(), String> {
let Some(parent) = Path::new(path).parent() else {
return Ok(());
};
fs::create_dir_all(parent)
.await
.map_err(|err| err.to_string())
}