Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6bdf1058fa | |||
| 6807fe2bc0 | |||
| fb00ac1101 |
Generated
+1
-1
@@ -3197,7 +3197,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hello-agent"
|
name = "hello-agent"
|
||||||
version = "0.1.2"
|
version = "0.1.5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"env_logger 0.10.2",
|
"env_logger 0.10.2",
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "hello-agent"
|
name = "hello-agent"
|
||||||
version = "0.1.2"
|
version = "0.1.5"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.75"
|
rust-version = "1.75"
|
||||||
description = "Headless RustDesk-protocol-compatible support agent for Windows"
|
description = "Headless RustDesk-protocol-compatible support agent for Windows"
|
||||||
@@ -24,7 +24,7 @@ path = "src/main.rs"
|
|||||||
librustdesk = { package = "rustdesk", path = "vendor/rustdesk", default-features = false, features = ["use_dasp", "hwcodec", "vram"] }
|
librustdesk = { package = "rustdesk", path = "vendor/rustdesk", default-features = false, features = ["use_dasp", "hwcodec", "vram"] }
|
||||||
hbb_common = { path = "vendor/rustdesk/libs/hbb_common" }
|
hbb_common = { path = "vendor/rustdesk/libs/hbb_common" }
|
||||||
|
|
||||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "io-util"] }
|
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "io-util", "process"] }
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
env_logger = "0.10"
|
env_logger = "0.10"
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
|
|||||||
@@ -60,6 +60,18 @@ hello-agent.exe --server # user session, SYSTEM token
|
|||||||
│ └─ stamps `agent_name` / `agent_version` / `inventory`
|
│ └─ stamps `agent_name` / `agent_version` / `inventory`
|
||||||
│ into each /api/sysinfo payload (re-uploads when the
|
│ into each /api/sysinfo payload (re-uploads when the
|
||||||
│ inventory collector below transitions empty → ready)
|
│ inventory collector below transitions empty → ready)
|
||||||
|
│ └─ signs every request with the device's Ed25519 sk
|
||||||
|
│ (same key rendezvous registers via RegisterPk).
|
||||||
|
│ The server's first valid sig flips that peer to
|
||||||
|
│ `managed=1` and unsigned posts get 401 from then on.
|
||||||
|
│ Spec: rustdesk-server/docs/AGENT-API-AUTH.md
|
||||||
|
├── exec::run_loop (background thread)
|
||||||
|
│ └─ subscribes to sync.rs's EXEC_SENDER broadcast; for
|
||||||
|
│ each queued PowerShell command runs `powershell.exe
|
||||||
|
│ -NoProfile -NonInteractive -Command -`, captures
|
||||||
|
│ stdout+stderr with 1 MiB cap & 5-min timeout, POSTs
|
||||||
|
│ signed result to /api/agent/exec-result. Idle unless
|
||||||
|
│ an admin dispatches via the dashboard.
|
||||||
├── inventory::collect_inventory (background thread)
|
├── inventory::collect_inventory (background thread)
|
||||||
│ └─ PowerShell + WMI + wlanapi + ipify → `INVENTORY` global
|
│ └─ PowerShell + WMI + wlanapi + ipify → `INVENTORY` global
|
||||||
│ consumed by hbbs_http::sync above; one-shot, no retry
|
│ consumed by hbbs_http::sync above; one-shot, no retry
|
||||||
@@ -115,6 +127,15 @@ inventory — keep it in sync when adding new patches.
|
|||||||
`--cm` process can plug a `MessageBoxW`-based `InvokeUiCM` into
|
`--cm` process can plug a `MessageBoxW`-based `InvokeUiCM` into
|
||||||
upstream's connection-manager IPC loop and inherit file-transfer,
|
upstream's connection-manager IPC loop and inherit file-transfer,
|
||||||
chat, and clipboard handling rather than re-implementing them.
|
chat, and clipboard handling rather than re-implementing them.
|
||||||
|
* `mod hbbs_http` → `pub mod hbbs_http` so hello-agent's
|
||||||
|
`unattended_password::try_report` and `exec::run_loop` can reach
|
||||||
|
`librustdesk::hbbs_http::sign::build_signed_headers` and
|
||||||
|
`librustdesk::hbbs_http::sync::exec_signal_receiver`. Without
|
||||||
|
this the in-crate code can't sign / can't subscribe to the
|
||||||
|
server's queued PowerShell commands, and the build fails with
|
||||||
|
`E0603: module 'hbbs_http' is private`. Tightly coupled to the
|
||||||
|
**Signed agent API** and **Remote PowerShell exec** divergences
|
||||||
|
below.
|
||||||
2. **Build shape** — [`vendor/rustdesk/Cargo.toml`](vendor/rustdesk/Cargo.toml):
|
2. **Build shape** — [`vendor/rustdesk/Cargo.toml`](vendor/rustdesk/Cargo.toml):
|
||||||
`[lib] crate-type` reduced from `["cdylib", "staticlib", "rlib"]` to
|
`[lib] crate-type` reduced from `["cdylib", "staticlib", "rlib"]` to
|
||||||
`["rlib"]`. We statically link the rlib into hello-agent.exe; the
|
`["rlib"]`. We statically link the rlib into hello-agent.exe; the
|
||||||
@@ -148,6 +169,71 @@ inventory — keep it in sync when adding new patches.
|
|||||||
[`src/main.rs`](vendor/rustdesk/src/main.rs) (`.author(...)`).
|
[`src/main.rs`](vendor/rustdesk/src/main.rs) (`.author(...)`).
|
||||||
Cosmetic, but they show through in the Windows EXE metadata and
|
Cosmetic, but they show through in the Windows EXE metadata and
|
||||||
in-app error dialogs.
|
in-app error dialogs.
|
||||||
|
6. **Signed agent API** — every `POST /api/heartbeat`,
|
||||||
|
`POST /api/sysinfo`, and `POST /api/unattended-password` carries
|
||||||
|
two extra headers (`X-RD-Device-Id`,
|
||||||
|
`X-RD-Signature: v1.<ts>.<base64-ed25519-sig>`) so the server can
|
||||||
|
bind the request to the device's existing rendezvous keypair
|
||||||
|
instead of trusting the `id` + `uuid` body fields. Without this
|
||||||
|
patch, anyone who knows a peer's id and uuid can inject inventory,
|
||||||
|
heartbeats, and unattended-access passwords for it. Three patch
|
||||||
|
sites in the vendor tree (plus one in the hello-agent crate):
|
||||||
|
* New file
|
||||||
|
[`src/hbbs_http/sign.rs`](vendor/rustdesk/src/hbbs_http/sign.rs) —
|
||||||
|
the signer (`build_signed_headers`, `path_from_url`). Reads
|
||||||
|
`Config::get_key_pair()` and `Config::get_id()`; uses the
|
||||||
|
re-exported `hbb_common::sodiumoxide`.
|
||||||
|
* [`src/hbbs_http.rs`](vendor/rustdesk/src/hbbs_http.rs) — adds
|
||||||
|
`pub mod sign;` next to the existing module declarations.
|
||||||
|
* [`src/common.rs`](vendor/rustdesk/src/common.rs) — the
|
||||||
|
`post_request_` and `parse_simple_header` header-string parsers
|
||||||
|
now accept a `\n`-separated list of `Name: Value` lines so we
|
||||||
|
can pass both signing headers in one call. Old single-pair
|
||||||
|
callers parse identically — there's no newline to split on.
|
||||||
|
* [`src/hbbs_http/sync.rs`](vendor/rustdesk/src/hbbs_http/sync.rs)
|
||||||
|
call sites (the sysinfo POST around the sysinfo-version
|
||||||
|
comparison block, and the heartbeat POST a few dozen lines
|
||||||
|
later) — both build a signed-headers string via
|
||||||
|
`crate::hbbs_http::sign::build_signed_headers("POST",
|
||||||
|
&path_from_url(&url), body.as_bytes()).unwrap_or_default()`
|
||||||
|
and pass it to `post_request` instead of `""`.
|
||||||
|
|
||||||
|
And in the hello-agent crate proper (not the vendor tree, no
|
||||||
|
re-sync concern):
|
||||||
|
* [`src/unattended_password.rs`](src/unattended_password.rs) —
|
||||||
|
`try_report` also signs its `POST /api/unattended-password`
|
||||||
|
via `librustdesk::hbbs_http::sign::build_signed_headers`.
|
||||||
|
|
||||||
|
Matching server side: see rustdesk-server's
|
||||||
|
[`docs/AGENT-API-AUTH.md`](https://github.com/cstudio-ch/rustdesk-server/blob/pro-features/docs/AGENT-API-AUTH.md)
|
||||||
|
for the wire format and verification flow.
|
||||||
|
7. **Remote PowerShell exec** — the dashboard can queue a PowerShell
|
||||||
|
script for a managed peer; the agent runs it as its service account
|
||||||
|
and POSTs the result back. Gated server-side on admin role +
|
||||||
|
`peer.managed=1` + strategy `enable-remote-exec=Y`. Vendor-tree
|
||||||
|
patches:
|
||||||
|
* [`src/hbbs_http/sync.rs`](vendor/rustdesk/src/hbbs_http/sync.rs) —
|
||||||
|
new `EXEC_SENDER` broadcast channel, new `ExecRequest` type, new
|
||||||
|
`pub fn exec_signal_receiver()` helper, and the heartbeat-reply
|
||||||
|
parser drains the `exec: [...]` field into the channel. Vanilla
|
||||||
|
rustdesk simply has no subscriber — the channel send errors out
|
||||||
|
with NoReceivers and the requests are dropped silently.
|
||||||
|
|
||||||
|
In the hello-agent crate:
|
||||||
|
* [`src/exec.rs`](src/exec.rs) — the PowerShell runner. Subscribes
|
||||||
|
to the broadcast channel above, spawns
|
||||||
|
`powershell.exe -NoProfile -NonInteractive -ExecutionPolicy
|
||||||
|
Bypass -Command -`, writes the script to stdin, captures
|
||||||
|
stdout+stderr with 1 MiB cap and a 5-minute wall-clock timeout,
|
||||||
|
signs and POSTs the result to `/api/agent/exec-result`. Started
|
||||||
|
from `run_server()` in [`src/main.rs`](src/main.rs) (must live in
|
||||||
|
the `--server` process to share the broadcast channel with
|
||||||
|
sync.rs).
|
||||||
|
* [`Cargo.toml`](Cargo.toml) — adds `process` to tokio's feature
|
||||||
|
list for `tokio::process::Command`.
|
||||||
|
|
||||||
|
Server-side spec: see [`docs/AGENT-API-AUTH.md`](https://github.com/cstudio-ch/rustdesk-server/blob/pro-features/docs/AGENT-API-AUTH.md)
|
||||||
|
§*Remote PowerShell exec*.
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
|
|||||||
+259
@@ -0,0 +1,259 @@
|
|||||||
|
//! PowerShell remote-exec worker.
|
||||||
|
//!
|
||||||
|
//! Subscribes to `librustdesk::hbbs_http::sync::exec_signal_receiver()` — a
|
||||||
|
//! broadcast channel that the vendored sync loop populates whenever the
|
||||||
|
//! server returns an `exec` field in a heartbeat reply (see
|
||||||
|
//! rustdesk-server/docs/AGENT-API-AUTH.md). For each `ExecRequest` we:
|
||||||
|
//!
|
||||||
|
//! 1. Spawn `powershell.exe -NoProfile -NonInteractive -ExecutionPolicy
|
||||||
|
//! Bypass -Command -` and write the script to stdin.
|
||||||
|
//! 2. Concurrently drain stdout and stderr into 1 MiB-capped buffers.
|
||||||
|
//! 3. Apply a wall-clock timeout (default 5 min); kill on expiry.
|
||||||
|
//! 4. POST the result to `/api/agent/exec-result` with the same Ed25519
|
||||||
|
//! signature the heartbeat / sysinfo posts use.
|
||||||
|
//!
|
||||||
|
//! The whole thing only makes sense on Windows (the agent's target OS),
|
||||||
|
//! so the module body is `#[cfg(windows)]` and other platforms get a
|
||||||
|
//! no-op `start()` to keep the call site in `service.rs` portable.
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
mod windows_impl {
|
||||||
|
use anyhow::{anyhow, Result};
|
||||||
|
use hbb_common::config::Config;
|
||||||
|
use librustdesk::hbbs_http::sync::ExecRequest;
|
||||||
|
use std::process::Stdio;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
|
||||||
|
pub fn start() {
|
||||||
|
std::thread::spawn(|| {
|
||||||
|
let rt = match tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
{
|
||||||
|
Ok(rt) => rt,
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("exec worker: build runtime: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
rt.block_on(run_loop());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_loop() {
|
||||||
|
// The vendored sync layer creates the broadcast channel lazily on
|
||||||
|
// first `subscribe()`. Calling here also primes it for the parser.
|
||||||
|
let mut rx = librustdesk::hbbs_http::sync::exec_signal_receiver();
|
||||||
|
log::info!("exec worker: subscribed to heartbeat exec channel");
|
||||||
|
loop {
|
||||||
|
match rx.recv().await {
|
||||||
|
Ok(req) => {
|
||||||
|
log::info!(
|
||||||
|
"exec worker: received cmd_id={} script_len={} max_secs={} max_bytes={}",
|
||||||
|
req.cmd_id,
|
||||||
|
req.script.len(),
|
||||||
|
req.max_secs,
|
||||||
|
req.max_bytes
|
||||||
|
);
|
||||||
|
let outcome = run_one(&req).await;
|
||||||
|
if let Err(e) = report(&req, &outcome).await {
|
||||||
|
log::warn!(
|
||||||
|
"exec worker: report failed for cmd_id={}: {e:#}",
|
||||||
|
req.cmd_id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
||||||
|
log::warn!("exec worker: lagged, dropped {n} exec requests");
|
||||||
|
}
|
||||||
|
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
|
||||||
|
log::warn!("exec worker: channel closed, exiting");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Outcome {
|
||||||
|
exit_code: i64,
|
||||||
|
stdout: String,
|
||||||
|
stderr: String,
|
||||||
|
timed_out: bool,
|
||||||
|
truncated: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_one(req: &ExecRequest) -> Outcome {
|
||||||
|
// Defensive lower bound — a misconfigured server shouldn't be able to
|
||||||
|
// send max_secs=0 and have us skip the wait.
|
||||||
|
let timeout = Duration::from_secs(req.max_secs.max(1));
|
||||||
|
let max_bytes = req.max_bytes.max(1024) as usize;
|
||||||
|
|
||||||
|
// `-Command -` makes PowerShell read the script body from stdin,
|
||||||
|
// which avoids quoting / length issues that plague `-Command "…"`
|
||||||
|
// for multi-line scripts. `-NoProfile` skips both the
|
||||||
|
// machine-wide and user-wide profile loads — those would change
|
||||||
|
// behaviour depending on which AD-managed PowerShell profile the
|
||||||
|
// service account inherited. `-NonInteractive` makes prompts fail
|
||||||
|
// instead of hanging the run.
|
||||||
|
let mut child = match tokio::process::Command::new("powershell.exe")
|
||||||
|
.args([
|
||||||
|
"-NoProfile",
|
||||||
|
"-NonInteractive",
|
||||||
|
"-ExecutionPolicy",
|
||||||
|
"Bypass",
|
||||||
|
"-Command",
|
||||||
|
"-",
|
||||||
|
])
|
||||||
|
.stdin(Stdio::piped())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
{
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
return Outcome {
|
||||||
|
exit_code: -1,
|
||||||
|
stdout: String::new(),
|
||||||
|
stderr: format!("spawn failed: {e}"),
|
||||||
|
timed_out: false,
|
||||||
|
truncated: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(mut stdin) = child.stdin.take() {
|
||||||
|
let _ = stdin.write_all(req.script.as_bytes()).await;
|
||||||
|
let _ = stdin.shutdown().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let stdout = child.stdout.take().expect("piped stdout was requested");
|
||||||
|
let stderr = child.stderr.take().expect("piped stderr was requested");
|
||||||
|
|
||||||
|
// Concurrent capped readers. Each task accumulates up to
|
||||||
|
// `max_bytes` bytes, then drains and discards the rest so the
|
||||||
|
// pipe doesn't block the child writer.
|
||||||
|
let stdout_buf: Arc<Mutex<(Vec<u8>, bool)>> = Arc::new(Mutex::new((Vec::new(), false)));
|
||||||
|
let stderr_buf: Arc<Mutex<(Vec<u8>, bool)>> = Arc::new(Mutex::new((Vec::new(), false)));
|
||||||
|
let so = tokio::spawn(read_capped(stdout, stdout_buf.clone(), max_bytes));
|
||||||
|
let se = tokio::spawn(read_capped(stderr, stderr_buf.clone(), max_bytes));
|
||||||
|
|
||||||
|
let wait_result = tokio::time::timeout(timeout, child.wait()).await;
|
||||||
|
let (exit_code, timed_out) = match wait_result {
|
||||||
|
Ok(Ok(s)) => (s.code().unwrap_or(-1) as i64, false),
|
||||||
|
Ok(Err(_)) => (-1, false),
|
||||||
|
Err(_) => {
|
||||||
|
// Timed out: kill, then wait the killed child so it
|
||||||
|
// reaps cleanly (and so the read tasks finish via EOF).
|
||||||
|
let _ = child.kill().await;
|
||||||
|
let _ = child.wait().await;
|
||||||
|
(-1, true)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let _ = so.await;
|
||||||
|
let _ = se.await;
|
||||||
|
|
||||||
|
let (out_bytes, out_trunc) = {
|
||||||
|
let g = stdout_buf.lock().unwrap();
|
||||||
|
(g.0.clone(), g.1)
|
||||||
|
};
|
||||||
|
let (err_bytes, err_trunc) = {
|
||||||
|
let g = stderr_buf.lock().unwrap();
|
||||||
|
(g.0.clone(), g.1)
|
||||||
|
};
|
||||||
|
Outcome {
|
||||||
|
exit_code,
|
||||||
|
// PowerShell on a current Windows defaults to UTF-8 when
|
||||||
|
// OutputEncoding is set, but the agent service inherits the
|
||||||
|
// legacy code page on older boxes. `from_utf8_lossy`
|
||||||
|
// guarantees we always have a UTF-8 string to ship; the
|
||||||
|
// operator sees a U+FFFD when raw bytes weren't UTF-8.
|
||||||
|
stdout: String::from_utf8_lossy(&out_bytes).into_owned(),
|
||||||
|
stderr: String::from_utf8_lossy(&err_bytes).into_owned(),
|
||||||
|
timed_out,
|
||||||
|
truncated: out_trunc || err_trunc,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_capped<R: AsyncReadExt + Unpin>(
|
||||||
|
mut reader: R,
|
||||||
|
buf: Arc<Mutex<(Vec<u8>, bool)>>,
|
||||||
|
cap: usize,
|
||||||
|
) {
|
||||||
|
let mut chunk = [0u8; 8192];
|
||||||
|
loop {
|
||||||
|
match reader.read(&mut chunk).await {
|
||||||
|
Ok(0) => return,
|
||||||
|
Ok(n) => {
|
||||||
|
let mut g = buf.lock().unwrap();
|
||||||
|
if g.0.len() < cap {
|
||||||
|
let room = cap - g.0.len();
|
||||||
|
if n <= room {
|
||||||
|
g.0.extend_from_slice(&chunk[..n]);
|
||||||
|
} else {
|
||||||
|
g.0.extend_from_slice(&chunk[..room]);
|
||||||
|
g.1 = true; // truncated; keep draining
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// else: already truncated, drop this chunk on the floor.
|
||||||
|
}
|
||||||
|
Err(_) => return,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn report(req: &ExecRequest, out: &Outcome) -> Result<()> {
|
||||||
|
let api = librustdesk::common::get_api_server(
|
||||||
|
Config::get_option("api-server"),
|
||||||
|
Config::get_option("custom-rendezvous-server"),
|
||||||
|
);
|
||||||
|
if api.is_empty() {
|
||||||
|
return Err(anyhow!("no api-server configured"));
|
||||||
|
}
|
||||||
|
let url = format!("{api}/api/agent/exec-result");
|
||||||
|
let id = Config::get_id();
|
||||||
|
let uuid = librustdesk::common::encode64(hbb_common::get_uuid());
|
||||||
|
let body = hbb_common::serde_json::json!({
|
||||||
|
"id": id,
|
||||||
|
"uuid": uuid,
|
||||||
|
"cmd_id": req.cmd_id,
|
||||||
|
"exit_code": out.exit_code,
|
||||||
|
"stdout": out.stdout,
|
||||||
|
"stderr": out.stderr,
|
||||||
|
"timed_out": out.timed_out,
|
||||||
|
"truncated": out.truncated,
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let headers = librustdesk::hbbs_http::sign::build_signed_headers(
|
||||||
|
"POST",
|
||||||
|
"/api/agent/exec-result",
|
||||||
|
body.as_bytes(),
|
||||||
|
)
|
||||||
|
.unwrap_or_default();
|
||||||
|
if headers.is_empty() {
|
||||||
|
// Server rejects unsigned exec-result posts unconditionally
|
||||||
|
// (see api/agent_exec.rs); bail loudly so the operator can
|
||||||
|
// see the agent isn't ready to sign yet.
|
||||||
|
return Err(anyhow!("no signing keypair available"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let resp = librustdesk::common::post_request(url, body, &headers)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow!("post: {e}"))?;
|
||||||
|
if resp.trim() == "OK" {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(anyhow!("unexpected response: {}", resp.trim()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
pub use windows_impl::start;
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
pub fn start() {
|
||||||
|
log::info!("exec worker: skipped (non-Windows build)");
|
||||||
|
}
|
||||||
@@ -105,6 +105,53 @@ try {
|
|||||||
$public_ip = (Invoke-RestMethod -Uri 'https://api.ipify.org' -TimeoutSec 5 -ErrorAction Stop).ToString().Trim()
|
$public_ip = (Invoke-RestMethod -Uri 'https://api.ipify.org' -TimeoutSec 5 -ErrorAction Stop).ToString().Trim()
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
|
# Installed software (the "Add/Remove Programs" / "Apps & features" list).
|
||||||
|
# We enumerate the Uninstall registry keys directly — the same source
|
||||||
|
# Settings reads — rather than `Get-CimInstance Win32_Product`, which is
|
||||||
|
# notoriously slow (triggers MSI self-repair on every entry) and only
|
||||||
|
# covers MSI-installed software, missing everything from per-user
|
||||||
|
# installers, Chocolatey/Scoop/Inno-Setup, etc.
|
||||||
|
#
|
||||||
|
# We read both HKLM hives (64-bit + WOW6432Node 32-bit) so apps installed
|
||||||
|
# under either bitness show up. HKCU is skipped on purpose: the agent
|
||||||
|
# runs as LocalSystem (or LocalService), whose HKCU hive has nothing the
|
||||||
|
# logged-in user installed under their own profile — that data would
|
||||||
|
# require running per-user, which is out of scope for v1.
|
||||||
|
#
|
||||||
|
# Filter rules:
|
||||||
|
# * `DisplayName` must be set — empty-DisplayName entries are uninstall
|
||||||
|
# stubs for individual update KBs and not user-facing apps.
|
||||||
|
# * Skip `SystemComponent = 1` — internal Windows components hidden
|
||||||
|
# from Settings (DirectX shims, VC++ private redists, …).
|
||||||
|
# * Skip entries with a `ParentKeyName` — those are subcomponents of a
|
||||||
|
# parent application (e.g. Office's per-language packs); the parent
|
||||||
|
# row already covers the user-facing app.
|
||||||
|
#
|
||||||
|
# Bitness-tagged so the admin UI can distinguish a 64-bit vs 32-bit
|
||||||
|
# install of the same product (common for runtimes like VC++).
|
||||||
|
$installed_software = @()
|
||||||
|
foreach ($scope in @(
|
||||||
|
@{ path = 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*'; bitness = '64' },
|
||||||
|
@{ path = 'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*'; bitness = '32' }
|
||||||
|
)) {
|
||||||
|
try {
|
||||||
|
$entries = Get-ItemProperty -Path $scope.path -ErrorAction SilentlyContinue
|
||||||
|
} catch { continue }
|
||||||
|
foreach ($e in $entries) {
|
||||||
|
if (-not $e.DisplayName) { continue }
|
||||||
|
if ($e.SystemComponent -eq 1) { continue }
|
||||||
|
if ($e.ParentKeyName) { continue }
|
||||||
|
$installed_software += [pscustomobject]@{
|
||||||
|
name = "$($e.DisplayName)"
|
||||||
|
version = if ($e.DisplayVersion) { "$($e.DisplayVersion)" } else { '' }
|
||||||
|
publisher = if ($e.Publisher) { "$($e.Publisher)" } else { '' }
|
||||||
|
install_date = if ($e.InstallDate) { "$($e.InstallDate)" } else { '' }
|
||||||
|
bitness = $scope.bitness
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$installed_software = @($installed_software | Sort-Object name, version)
|
||||||
|
|
||||||
$os_release = "$($os.Version)"
|
$os_release = "$($os.Version)"
|
||||||
if ($displayVersion) { $os_release = "$($os.Version) $displayVersion" }
|
if ($displayVersion) { $os_release = "$($os.Version) $displayVersion" }
|
||||||
$result = [pscustomobject]@{
|
$result = [pscustomobject]@{
|
||||||
@@ -123,6 +170,7 @@ $result = [pscustomobject]@{
|
|||||||
bitlocker_recovery_key = $bl_key
|
bitlocker_recovery_key = $bl_key
|
||||||
network_interfaces = $nics
|
network_interfaces = $nics
|
||||||
public_ip = $public_ip
|
public_ip = $public_ip
|
||||||
|
installed_software = $installed_software
|
||||||
}
|
}
|
||||||
$result | ConvertTo-Json -Compress -Depth 6
|
$result | ConvertTo-Json -Compress -Depth 6
|
||||||
"#;
|
"#;
|
||||||
|
|||||||
+11
@@ -28,6 +28,8 @@ mod inventory;
|
|||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
mod cm_popup;
|
mod cm_popup;
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
|
mod exec;
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
mod service;
|
mod service;
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
mod unattended_password;
|
mod unattended_password;
|
||||||
@@ -278,6 +280,15 @@ fn run_server() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Start the PowerShell remote-exec worker. Subscribes to the
|
||||||
|
// broadcast channel in the vendored sync layer; the channel is
|
||||||
|
// shared in-process so the worker MUST run in this --server process
|
||||||
|
// (where sync.rs lives), not the --service supervisor. The worker
|
||||||
|
// is idle until an admin dispatches an exec from the dashboard.
|
||||||
|
// Gated server-side on peer.managed=1 + strategy.enable-remote-exec.
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
exec::start();
|
||||||
|
|
||||||
// `start_server` is `#[tokio::main]` and runs forever. (is_server=true,
|
// `start_server` is `#[tokio::main]` and runs forever. (is_server=true,
|
||||||
// no_server=false). It boots the default IPC server, input service,
|
// no_server=false). It boots the default IPC server, input service,
|
||||||
// rendezvous mediator, and heartbeat sync.
|
// rendezvous mediator, and heartbeat sync.
|
||||||
|
|||||||
@@ -111,7 +111,18 @@ async fn try_report(password: &str) -> Result<()> {
|
|||||||
})
|
})
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
let resp = librustdesk::common::post_request(url, body, "")
|
// Same per-peer signature gate as heartbeat / sysinfo. Once this peer's
|
||||||
|
// `managed` flag has flipped to 1 server-side, unsigned posts here
|
||||||
|
// would be rejected — and we want unattended-password to keep landing
|
||||||
|
// through the same TOFU lifecycle as the other endpoints.
|
||||||
|
let headers = librustdesk::hbbs_http::sign::build_signed_headers(
|
||||||
|
"POST",
|
||||||
|
"/api/unattended-password",
|
||||||
|
body.as_bytes(),
|
||||||
|
)
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let resp = librustdesk::common::post_request(url, body, &headers)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| anyhow!("post: {e}"))?;
|
.map_err(|e| anyhow!("post: {e}"))?;
|
||||||
let trimmed = resp.trim();
|
let trimmed = resp.trim();
|
||||||
|
|||||||
Vendored
+29
-12
@@ -1331,17 +1331,26 @@ async fn tcp_proxy_request(
|
|||||||
fn parse_simple_header(header: &str) -> Vec<HeaderEntry> {
|
fn parse_simple_header(header: &str) -> Vec<HeaderEntry> {
|
||||||
let mut entries = Vec::new();
|
let mut entries = Vec::new();
|
||||||
let mut has_content_type = false;
|
let mut has_content_type = false;
|
||||||
|
// Accept a `\n`-separated list of `Name: Value` pairs. Single-pair input
|
||||||
|
// (the historical shape) still parses correctly because there's no
|
||||||
|
// newline to split on.
|
||||||
if !header.is_empty() {
|
if !header.is_empty() {
|
||||||
let tmp: Vec<&str> = header.splitn(2, ": ").collect();
|
for line in header.split('\n') {
|
||||||
if tmp.len() == 2 {
|
let line = line.trim();
|
||||||
if tmp[0].eq_ignore_ascii_case("Content-Type") {
|
if line.is_empty() {
|
||||||
has_content_type = true;
|
continue;
|
||||||
|
}
|
||||||
|
let tmp: Vec<&str> = line.splitn(2, ": ").collect();
|
||||||
|
if tmp.len() == 2 {
|
||||||
|
if tmp[0].eq_ignore_ascii_case("Content-Type") {
|
||||||
|
has_content_type = true;
|
||||||
|
}
|
||||||
|
entries.push(HeaderEntry {
|
||||||
|
name: tmp[0].into(),
|
||||||
|
value: tmp[1].into(),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
}
|
}
|
||||||
entries.push(HeaderEntry {
|
|
||||||
name: tmp[0].into(),
|
|
||||||
value: tmp[1].into(),
|
|
||||||
..Default::default()
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !has_content_type {
|
if !has_content_type {
|
||||||
@@ -1499,10 +1508,18 @@ async fn post_request_(
|
|||||||
danger_accept_invalid_cert.unwrap_or(false),
|
danger_accept_invalid_cert.unwrap_or(false),
|
||||||
)
|
)
|
||||||
.post(url);
|
.post(url);
|
||||||
|
// `header` is a `\n`-separated list of `Name: Value` pairs. Single-pair
|
||||||
|
// callers (the original shape) work unchanged. The signed-API path uses
|
||||||
|
// this to pass both `X-RD-Device-Id` and `X-RD-Signature` at once.
|
||||||
if !header.is_empty() {
|
if !header.is_empty() {
|
||||||
let tmp: Vec<&str> = header.split(": ").collect();
|
for line in header.split('\n') {
|
||||||
if tmp.len() == 2 {
|
let line = line.trim();
|
||||||
req = req.header(tmp[0], tmp[1]);
|
if line.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some((name, value)) = line.split_once(": ") {
|
||||||
|
req = req.header(name, value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
req = req.header("Content-Type", "application/json");
|
req = req.header("Content-Type", "application/json");
|
||||||
|
|||||||
Vendored
+1
@@ -7,6 +7,7 @@ pub mod account;
|
|||||||
pub mod downloader;
|
pub mod downloader;
|
||||||
mod http_client;
|
mod http_client;
|
||||||
pub mod record_upload;
|
pub mod record_upload;
|
||||||
|
pub mod sign;
|
||||||
pub mod sync;
|
pub mod sync;
|
||||||
pub use http_client::{
|
pub use http_client::{
|
||||||
create_http_client_async, create_http_client_async_with_url, create_http_client_with_url,
|
create_http_client_async, create_http_client_async_with_url, create_http_client_with_url,
|
||||||
|
|||||||
+72
@@ -0,0 +1,72 @@
|
|||||||
|
//! Sign agent → server HTTP requests with the device's existing Ed25519
|
||||||
|
//! keypair (the same one rendezvous uses for `RegisterPk`). Producing the
|
||||||
|
//! header pair below for any signed call:
|
||||||
|
//!
|
||||||
|
//! X-RD-Device-Id: <id>
|
||||||
|
//! X-RD-Signature: v1.<unix_ts>.<base64(ed25519_sig)>
|
||||||
|
//!
|
||||||
|
//! Server verifier: `/Users/sn0/Desktop/rustdesk-server/src/api/device_auth.rs`.
|
||||||
|
//!
|
||||||
|
//! Signed message format (must match the server byte-for-byte):
|
||||||
|
//! "rd-api-v1\n" || METHOD || "\n" || PATH || "\n" || TS || "\n" || sha256(body)
|
||||||
|
|
||||||
|
use hbb_common::config::Config;
|
||||||
|
use hbb_common::sodiumoxide::crypto::{hash::sha256, sign};
|
||||||
|
|
||||||
|
/// Returns the two HTTP header lines joined by `\n`, ready to hand to
|
||||||
|
/// `post_request`'s extended `header` parser. Returns `None` if the local
|
||||||
|
/// keypair hasn't been generated yet (very early boot, before rendezvous) —
|
||||||
|
/// the caller should fall back to an unsigned request in that case; the
|
||||||
|
/// server's TOFU promote will still flip `managed=1` on the next signed
|
||||||
|
/// request and any unsigned attempts after that flip will be rejected.
|
||||||
|
pub fn build_signed_headers(method: &str, path: &str, body: &[u8]) -> Option<String> {
|
||||||
|
let (sk_bytes, _pk_bytes) = Config::get_key_pair();
|
||||||
|
if sk_bytes.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let sk = sign::SecretKey::from_slice(&sk_bytes)?;
|
||||||
|
let id = Config::get_id();
|
||||||
|
if id.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let ts = chrono::Utc::now().timestamp();
|
||||||
|
|
||||||
|
let body_sha = sha256::hash(body);
|
||||||
|
let ts_s = ts.to_string();
|
||||||
|
let mut msg = Vec::with_capacity(64 + method.len() + path.len());
|
||||||
|
msg.extend_from_slice(b"rd-api-v1\n");
|
||||||
|
msg.extend_from_slice(method.as_bytes());
|
||||||
|
msg.push(b'\n');
|
||||||
|
msg.extend_from_slice(path.as_bytes());
|
||||||
|
msg.push(b'\n');
|
||||||
|
msg.extend_from_slice(ts_s.as_bytes());
|
||||||
|
msg.push(b'\n');
|
||||||
|
msg.extend_from_slice(body_sha.as_ref());
|
||||||
|
|
||||||
|
let sig = sign::sign_detached(&msg, &sk);
|
||||||
|
let sig_b64 = crate::encode64(sig.as_ref());
|
||||||
|
|
||||||
|
Some(format!(
|
||||||
|
"X-RD-Device-Id: {}\nX-RD-Signature: v1.{}.{}",
|
||||||
|
id, ts, sig_b64
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract the `/path` portion of a full URL. Used to derive the signed
|
||||||
|
/// path from sync.rs's `url` variable, which is always something like
|
||||||
|
/// `https://server.example.com:21114/api/heartbeat`. Falls back to "/" if
|
||||||
|
/// the URL doesn't parse — server-side verification will then fail, which
|
||||||
|
/// is the right outcome (a malformed agent URL is a misconfiguration the
|
||||||
|
/// operator should see).
|
||||||
|
pub fn path_from_url(url: &str) -> String {
|
||||||
|
// Manual parse to avoid pulling in the `url` crate for one call. The
|
||||||
|
// structure is always scheme://host[:port]/path[?query]. Strip scheme,
|
||||||
|
// then take from the first '/' onward, then drop any '?query'.
|
||||||
|
let no_scheme = url.split_once("://").map(|(_, rest)| rest).unwrap_or(url);
|
||||||
|
let path_and_q = no_scheme.find('/').map(|i| &no_scheme[i..]).unwrap_or("/");
|
||||||
|
let path = path_and_q
|
||||||
|
.split_once('?')
|
||||||
|
.map(|(p, _)| p)
|
||||||
|
.unwrap_or(path_and_q);
|
||||||
|
path.to_string()
|
||||||
|
}
|
||||||
+71
-2
@@ -24,6 +24,30 @@ const TIME_CONN: Duration = Duration::from_secs(3);
|
|||||||
lazy_static::lazy_static! {
|
lazy_static::lazy_static! {
|
||||||
static ref SENDER : Mutex<broadcast::Sender<Vec<i32>>> = Mutex::new(start_hbbs_sync());
|
static ref SENDER : Mutex<broadcast::Sender<Vec<i32>>> = Mutex::new(start_hbbs_sync());
|
||||||
static ref PRO: Arc<Mutex<bool>> = Default::default();
|
static ref PRO: Arc<Mutex<bool>> = Default::default();
|
||||||
|
/// hello-agent local patch: broadcast channel for PowerShell exec
|
||||||
|
/// commands the server queues for this peer. sync.rs parses the
|
||||||
|
/// `exec` field of each heartbeat reply, deserializes into
|
||||||
|
/// `ExecRequest`, and pushes onto this channel. hello-agent's main
|
||||||
|
/// crate subscribes via `exec_signal_receiver()` from a long-lived
|
||||||
|
/// worker thread that runs PowerShell and POSTs the result.
|
||||||
|
static ref EXEC_SENDER: Mutex<broadcast::Sender<ExecRequest>> = {
|
||||||
|
let (tx, _rx) = broadcast::channel::<ExecRequest>(64);
|
||||||
|
Mutex::new(tx)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// hello-agent local patch: mirrors the upstream `disconnect` reply
|
||||||
|
/// field. Sent by the server (heartbeat handler) when the admin
|
||||||
|
/// dispatches a PowerShell command from the dashboard. See
|
||||||
|
/// rustdesk-server/docs/AGENT-API-AUTH.md.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct ExecRequest {
|
||||||
|
pub cmd_id: String,
|
||||||
|
pub script: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub max_secs: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub max_bytes: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(any(target_os = "ios")))]
|
#[cfg(not(any(target_os = "ios")))]
|
||||||
@@ -36,6 +60,14 @@ pub fn signal_receiver() -> broadcast::Receiver<Vec<i32>> {
|
|||||||
SENDER.lock().unwrap().subscribe()
|
SENDER.lock().unwrap().subscribe()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// hello-agent local patch: subscribe to PowerShell exec commands
|
||||||
|
/// pushed by the heartbeat-reply parser. Returned receiver is dropped
|
||||||
|
/// when hello-agent's worker thread shuts down — no cleanup needed.
|
||||||
|
#[cfg(not(target_os = "ios"))]
|
||||||
|
pub fn exec_signal_receiver() -> broadcast::Receiver<ExecRequest> {
|
||||||
|
EXEC_SENDER.lock().unwrap().subscribe()
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(not(any(target_os = "ios")))]
|
#[cfg(not(any(target_os = "ios")))]
|
||||||
fn start_hbbs_sync() -> broadcast::Sender<Vec<i32>> {
|
fn start_hbbs_sync() -> broadcast::Sender<Vec<i32>> {
|
||||||
let (tx, _rx) = broadcast::channel::<Vec<i32>>(16);
|
let (tx, _rx) = broadcast::channel::<Vec<i32>>(16);
|
||||||
@@ -258,7 +290,15 @@ async fn start_hbbs_sync_async() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
match crate::post_request(url.replace("heartbeat", "sysinfo"), v, "").await {
|
let sysinfo_url = url.replace("heartbeat", "sysinfo");
|
||||||
|
let sysinfo_path = crate::hbbs_http::sign::path_from_url(&sysinfo_url);
|
||||||
|
let sysinfo_headers = crate::hbbs_http::sign::build_signed_headers(
|
||||||
|
"POST",
|
||||||
|
&sysinfo_path,
|
||||||
|
v.as_bytes(),
|
||||||
|
)
|
||||||
|
.unwrap_or_default();
|
||||||
|
match crate::post_request(sysinfo_url, v, &sysinfo_headers).await {
|
||||||
Ok(x) => {
|
Ok(x) => {
|
||||||
if x == "SYSINFO_UPDATED" {
|
if x == "SYSINFO_UPDATED" {
|
||||||
info_uploaded = InfoUploaded::uploaded(url.clone(), id.clone(), sys_username, had_inventory);
|
info_uploaded = InfoUploaded::uploaded(url.clone(), id.clone(), sys_username, had_inventory);
|
||||||
@@ -292,7 +332,15 @@ async fn start_hbbs_sync_async() {
|
|||||||
}
|
}
|
||||||
let modified_at = LocalConfig::get_option("strategy_timestamp").parse::<i64>().unwrap_or(0);
|
let modified_at = LocalConfig::get_option("strategy_timestamp").parse::<i64>().unwrap_or(0);
|
||||||
v["modified_at"] = json!(modified_at);
|
v["modified_at"] = json!(modified_at);
|
||||||
if let Ok(s) = crate::post_request(url.clone(), v.to_string(), "").await {
|
let hb_body = v.to_string();
|
||||||
|
let hb_path = crate::hbbs_http::sign::path_from_url(&url);
|
||||||
|
let hb_headers = crate::hbbs_http::sign::build_signed_headers(
|
||||||
|
"POST",
|
||||||
|
&hb_path,
|
||||||
|
hb_body.as_bytes(),
|
||||||
|
)
|
||||||
|
.unwrap_or_default();
|
||||||
|
if let Ok(s) = crate::post_request(url.clone(), hb_body, &hb_headers).await {
|
||||||
if let Ok(mut rsp) = serde_json::from_str::<HashMap::<&str, Value>>(&s) {
|
if let Ok(mut rsp) = serde_json::from_str::<HashMap::<&str, Value>>(&s) {
|
||||||
if rsp.remove("sysinfo").is_some() {
|
if rsp.remove("sysinfo").is_some() {
|
||||||
info_uploaded.uploaded = false;
|
info_uploaded.uploaded = false;
|
||||||
@@ -317,6 +365,27 @@ async fn start_hbbs_sync_async() {
|
|||||||
handle_config_options(strategy.config_options);
|
handle_config_options(strategy.config_options);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// hello-agent local patch: forward queued PowerShell
|
||||||
|
// commands to the EXEC_SENDER broadcast channel so
|
||||||
|
// the main crate's worker thread can run them. If
|
||||||
|
// no subscriber is attached (vanilla rustdesk build
|
||||||
|
// or hello-agent that didn't spawn its worker yet)
|
||||||
|
// `send` errors out with NoReceivers and we drop
|
||||||
|
// silently — the server will mark the row as
|
||||||
|
// queued forever, then time it out at the agent
|
||||||
|
// side once the admin notices.
|
||||||
|
if let Some(exec) = rsp.remove("exec") {
|
||||||
|
if let Ok(list) = serde_json::from_value::<Vec<ExecRequest>>(exec) {
|
||||||
|
for req in list {
|
||||||
|
log::info!(
|
||||||
|
"exec dispatch: cmd_id={} script_len={}",
|
||||||
|
req.cmd_id,
|
||||||
|
req.script.len()
|
||||||
|
);
|
||||||
|
let _ = EXEC_SENDER.lock().unwrap().send(req);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+1
-1
@@ -72,7 +72,7 @@ pub mod ui_cm_interface;
|
|||||||
mod ui_interface;
|
mod ui_interface;
|
||||||
mod ui_session_interface;
|
mod ui_session_interface;
|
||||||
|
|
||||||
mod hbbs_http;
|
pub mod hbbs_http;
|
||||||
|
|
||||||
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
|
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
|
||||||
pub mod clipboard_file;
|
pub mod clipboard_file;
|
||||||
|
|||||||
Vendored
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
pub const VERSION: &str = "1.4.6";
|
pub const VERSION: &str = "1.4.6";
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub const BUILD_DATE: &str = "2026-05-21 13:02";
|
pub const BUILD_DATE: &str = "2026-05-22 14:17";
|
||||||
|
|||||||
Reference in New Issue
Block a user