Implement native IRC gateway
All checks were successful
Native Rust workspace compile / compile (push) Successful in 21m47s
All checks were successful
Native Rust workspace compile / compile (push) Successful in 21m47s
This commit is contained in:
@@ -9,7 +9,7 @@ publish = false
|
||||
[dependencies]
|
||||
clap = { version = "4.5", features = ["derive"] }
|
||||
libremetaverse = { path = "../crates/libremetaverse", default-features = false }
|
||||
tokio = { version = "1.47", features = ["macros", "rt-multi-thread", "signal", "sync", "time"] }
|
||||
tokio = { version = "1.47", features = ["io-util", "macros", "net", "rt-multi-thread", "signal", "sync", "time"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -12,7 +12,7 @@ here so a source entry is never mistaken for a completed port.
|
||||
| `packet-dump` | PacketDump | Implemented with live and deterministic fake-grid capture |
|
||||
| `prim-inspector` | PrimInspector | Implemented with live and deterministic fake-grid discovery |
|
||||
| `inventory-explorer` | InventoryExplorer | Implemented with live inventory and deterministic AIS fixtures |
|
||||
| `irc-gateway` | IRCGateway | Pending milestone 11 issue #90 |
|
||||
| `irc-gateway` | IRCGateway | Implemented with live IRC/grid transports and deterministic offline scripts |
|
||||
| `test-client` | TestClient | Pending milestone 11 issues #91–#94 |
|
||||
| `vivox-test` | VivoxTest | Pending milestone 11 issue #95 |
|
||||
| `webrtc-test` | WebRtcTest | Pending milestone 11 issue #96 |
|
||||
@@ -101,6 +101,56 @@ cargo test -p libremetaverse-programs --test simple_bot_cli --locked
|
||||
cargo test --manifest-path tests/compat/Cargo.toml --test core_runtime_shims --locked
|
||||
```
|
||||
|
||||
## IRCGateway
|
||||
|
||||
`irc-gateway` preserves the upstream seven-position live interface and bridges
|
||||
fully audible normal local chat to one IRC channel in both directions:
|
||||
|
||||
```text
|
||||
irc-gateway FIRSTNAME LASTNAME PASSWORD MASTER_UUID IRC_HOST IRC_PORT '#channel'
|
||||
```
|
||||
|
||||
The positional values can instead come from `GRID_FIRST_NAME`,
|
||||
`GRID_LAST_NAME`, `GRID_PASSWORD`, `GRID_MASTER_UUID`, `IRC_HOST`, `IRC_PORT`,
|
||||
and `IRC_CHANNEL`. `GRID_LOGIN_URL` or `--login-uri` selects a grid endpoint.
|
||||
`--nickname` defaults to the upstream `SLGateway` name. Login is bounded by
|
||||
`--login-timeout-seconds`; `--messages-per-second` and `--burst` control the
|
||||
per-direction token buckets.
|
||||
|
||||
The native IRC transport registers with `NICK`/`USER`, responds to `PING`,
|
||||
joins after numeric `001`, handles fragmented UTF-8 input, caps inbound and
|
||||
outbound lines, and reconnects with exponential backoff. The bridge uses
|
||||
bounded queues, sanitizes names and line breaks, suppresses self messages,
|
||||
short-window duplicates, and reflected cross-transport messages, and accepts
|
||||
teleport lures only from `MASTER_UUID`. Login messages and console output
|
||||
redact credentials, tokens, capability URLs, and other URLs. Ctrl-C cancels
|
||||
login and reconnect waits, closes the IRC socket, joins workers, unsubscribes
|
||||
grid callbacks, logs out, and disposes the native client.
|
||||
|
||||
Offline tests use the same routing and rate-limit engine without credentials or
|
||||
network access:
|
||||
|
||||
```text
|
||||
irc-connect
|
||||
irc-disconnect
|
||||
irc<TAB>target<TAB>nickname<TAB>message
|
||||
grid-chat<TAB>source-uuid<TAB>name<TAB>normal|other<TAB>full|other<TAB>message
|
||||
grid-login<TAB>message
|
||||
teleport<TAB>source-uuid<TAB>session-uuid
|
||||
advance<TAB>milliseconds
|
||||
shutdown
|
||||
```
|
||||
|
||||
Scripts are limited to 1 MiB and 4,096 events; names, messages, transport
|
||||
queues, and loop histories are also bounded. Run the issue-focused suite and
|
||||
the related native social-message compatibility cases with:
|
||||
|
||||
```sh
|
||||
cargo test -p libremetaverse-programs --test irc_gateway_cli --locked
|
||||
cargo test -p libremetaverse-programs irc_gateway::tests --locked
|
||||
cargo test --manifest-path tests/compat/Cargo.toml --test social_message_semantics --locked
|
||||
```
|
||||
|
||||
## PacketDump
|
||||
|
||||
`packet-dump` preserves the upstream live arguments and its 20-second login
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
fn main() -> std::process::ExitCode {
|
||||
libremetaverse_programs::pending_program("IRCGateway")
|
||||
libremetaverse_programs::irc_gateway::main_entry()
|
||||
}
|
||||
|
||||
1573
programs/src/irc_gateway.rs
Normal file
1573
programs/src/irc_gateway.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@
|
||||
|
||||
pub mod commands;
|
||||
pub mod inventory_explorer;
|
||||
pub mod irc_gateway;
|
||||
pub mod osd_inspector;
|
||||
pub mod packet_dump;
|
||||
pub mod prim_inspector;
|
||||
|
||||
222
programs/tests/irc_gateway_cli.rs
Normal file
222
programs/tests/irc_gateway_cli.rs
Normal file
@@ -0,0 +1,222 @@
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Output, Stdio};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
const MASTER: &str = "11111111-2222-3333-4444-555555555555";
|
||||
const SESSION: &str = "22222222-3333-4444-5555-666666666666";
|
||||
const OTHER: &str = "bbbbbbbb-cccc-dddd-eeee-ffffffffffff";
|
||||
const SELF_ID: &str = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
|
||||
const EXIT_USAGE: i32 = 2;
|
||||
const EXIT_INPUT: i32 = 3;
|
||||
|
||||
static TEMP_ID: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
struct TestDir(PathBuf);
|
||||
|
||||
impl TestDir {
|
||||
fn new(name: &str) -> Self {
|
||||
let id = TEMP_ID.fetch_add(1, Ordering::Relaxed);
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"metacrate-irc-gateway-{name}-{}-{id}",
|
||||
std::process::id()
|
||||
));
|
||||
fs::create_dir(&path).expect("create test directory");
|
||||
Self(path)
|
||||
}
|
||||
|
||||
fn write(&self, contents: &str) -> PathBuf {
|
||||
let path = self.0.join("bridge.tsv");
|
||||
fs::write(&path, contents).expect("write bridge script");
|
||||
path
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn path_text(path: &Path) -> &str {
|
||||
path.to_str().expect("UTF-8 path")
|
||||
}
|
||||
|
||||
fn run(args: &[&str]) -> Output {
|
||||
Command::new(env!("CARGO_BIN_EXE_irc-gateway"))
|
||||
.args(args)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.expect("run irc-gateway")
|
||||
}
|
||||
|
||||
fn stdout(output: &Output) -> &str {
|
||||
std::str::from_utf8(&output.stdout).expect("UTF-8 stdout")
|
||||
}
|
||||
|
||||
fn stderr(output: &Output) -> &str {
|
||||
std::str::from_utf8(&output.stderr).expect("UTF-8 stderr")
|
||||
}
|
||||
|
||||
fn assert_exit(output: &Output, code: i32) {
|
||||
assert_eq!(
|
||||
output.status.code(),
|
||||
Some(code),
|
||||
"stdout:\n{}\nstderr:\n{}",
|
||||
stdout(output),
|
||||
stderr(output)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_preserves_upstream_arguments_and_documents_controls() {
|
||||
let output = run(&["--help"]);
|
||||
assert!(output.status.success());
|
||||
let help = stdout(&output);
|
||||
for marker in [
|
||||
"[FIRSTNAME]",
|
||||
"[LASTNAME]",
|
||||
"[PASSWORD]",
|
||||
"[MASTER_UUID]",
|
||||
"[IRC_HOST]",
|
||||
"[IRC_PORT]",
|
||||
"[#CHANNEL]",
|
||||
"--fake-script",
|
||||
"--messages-per-second",
|
||||
"--burst",
|
||||
"irc-connect",
|
||||
"grid-chat",
|
||||
"teleport",
|
||||
] {
|
||||
assert!(help.contains(marker), "help omitted {marker}:\n{help}");
|
||||
}
|
||||
|
||||
let output = run(&[]);
|
||||
assert_exit(&output, EXIT_USAGE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scripted_bidirectional_bridge_filters_maps_and_accepts_only_master() {
|
||||
let directory = TestDir::new("bidirectional");
|
||||
let script = directory.write(&format!(
|
||||
"irc-connect\n\
|
||||
grid-login\tconnected to https://grid.invalid/cap/private\n\
|
||||
grid-chat\t{OTHER}\tAlice <Resident>\tnormal\tfull\thello IRC ☃\n\
|
||||
grid-chat\t{OTHER}\tAlice <Resident>\twhisper\tfull\tignored type\n\
|
||||
grid-chat\t{OTHER}\tAlice <Resident>\tnormal\tpartial\tignored range\n\
|
||||
grid-chat\t{SELF_ID}\tGateway Resident\tnormal\tfull\tignored self\n\
|
||||
irc\t#metacrate\tBob\thello grid ☃\n\
|
||||
irc\t#other\tMallory\tignored channel\n\
|
||||
irc\t#metacrate\tSLGateway\tignored self\n\
|
||||
teleport\t{OTHER}\t{SESSION}\n\
|
||||
teleport\t{MASTER}\t{SESSION}\n\
|
||||
shutdown\n"
|
||||
));
|
||||
let output = run(&["--fake-script", path_text(&script)]);
|
||||
assert!(output.status.success(), "{}", stderr(&output));
|
||||
assert!(output.stderr.is_empty());
|
||||
let text = stdout(&output);
|
||||
for expected in [
|
||||
"CALL irc-join #metacrate",
|
||||
"CALL irc-privmsg #metacrate <Alice _Resident_> hello IRC ☃",
|
||||
"CALL grid-chat channel=0 type=Normal <Bob> hello grid ☃",
|
||||
&format!("CALL teleport-lure-respond {MASTER} {SESSION} accept=true"),
|
||||
"active-transports=0 active-tasks=0 shutdown=true",
|
||||
] {
|
||||
assert!(text.contains(expected), "missing {expected}:\n{text}");
|
||||
}
|
||||
for rejected in [
|
||||
"ignored type",
|
||||
"ignored range",
|
||||
"ignored self",
|
||||
"ignored channel",
|
||||
&format!("teleport-lure-respond {OTHER}"),
|
||||
"grid.invalid",
|
||||
"/cap/private",
|
||||
] {
|
||||
assert!(
|
||||
!text.contains(rejected),
|
||||
"leaked/forwarded {rejected}:\n{text}"
|
||||
);
|
||||
}
|
||||
assert!(text.contains("<redacted-url>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconnect_rate_limit_duplicate_and_loop_scenarios_are_deterministic() {
|
||||
let directory = TestDir::new("resilience");
|
||||
let script = directory.write(&format!(
|
||||
"irc-connect\n\
|
||||
grid-chat\t{OTHER}\tAlice\tnormal\tfull\tone\n\
|
||||
grid-chat\t{OTHER}\tAlice\tnormal\tfull\ttwo\n\
|
||||
grid-chat\t{OTHER}\tAlice\tnormal\tfull\ttwo\n\
|
||||
irc\t#metacrate\tRelay\t<Alice> one\n\
|
||||
irc-disconnect\n\
|
||||
grid-chat\t{OTHER}\tAlice\tnormal\tfull\twhile offline\n\
|
||||
advance\t1000\n\
|
||||
irc-connect\n\
|
||||
advance\t1000\n\
|
||||
shutdown\n"
|
||||
));
|
||||
let output = run(&[
|
||||
"--fake-script",
|
||||
path_text(&script),
|
||||
"--messages-per-second",
|
||||
"1",
|
||||
"--burst",
|
||||
"1",
|
||||
]);
|
||||
assert!(output.status.success(), "{}", stderr(&output));
|
||||
let text = stdout(&output);
|
||||
assert_eq!(
|
||||
text.matches("CALL irc-join #metacrate").count(),
|
||||
2,
|
||||
"{text}"
|
||||
);
|
||||
assert_eq!(
|
||||
text.matches("<Alice> two").count(),
|
||||
2,
|
||||
"one log and one transport call expected:\n{text}"
|
||||
);
|
||||
assert_eq!(
|
||||
text.matches("<Alice> one").count(),
|
||||
2,
|
||||
"one log and one call expected:\n{text}"
|
||||
);
|
||||
assert!(
|
||||
!text.contains("CALL grid-chat"),
|
||||
"loop was reflected:\n{text}"
|
||||
);
|
||||
assert!(
|
||||
text.contains("CALL irc-privmsg #metacrate <Alice> while offline"),
|
||||
"{text}"
|
||||
);
|
||||
assert!(text.contains("queued=0"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_and_oversized_inputs_fail_without_network_access() {
|
||||
let directory = TestDir::new("invalid");
|
||||
let invalid = directory.write("irc\tmissing fields\n");
|
||||
let output = run(&["--fake-script", path_text(&invalid)]);
|
||||
assert_exit(&output, EXIT_INPUT);
|
||||
assert!(stderr(&output).contains("line 1"));
|
||||
|
||||
let oversized = directory.write(&format!("grid-login\t{}\n", "x".repeat(4097)));
|
||||
let output = run(&["--fake-script", path_text(&oversized)]);
|
||||
assert_exit(&output, EXIT_INPUT);
|
||||
|
||||
let output = run(&[
|
||||
"First",
|
||||
"Last",
|
||||
"secret",
|
||||
"not-a-uuid",
|
||||
"irc.invalid",
|
||||
"6667",
|
||||
"#room",
|
||||
]);
|
||||
assert_exit(&output, EXIT_USAGE);
|
||||
assert!(!stderr(&output).contains("secret"));
|
||||
}
|
||||
Reference in New Issue
Block a user