Compare commits

..

No commits in common. "master" and "0.1.0" have entirely different histories.

15 changed files with 31 additions and 456 deletions

View File

@ -1,3 +0,0 @@
Projects in this repository:
* [`connectbox-rs`](connectbox) - API client crate for the Compal CH7465LG, which is a cable modem provided by various European ISPs under the name Connect Box.
* [`connectbox-shell`](connectbox-shell) - Interactive shell for managing a Connect Box modem, powered by the connectbox-rs crate.

View File

@ -1,19 +1,6 @@
[package]
name = "connectbox-shell"
description = "A shell for managing your Connect Box router, based on the connectbox-rs library"
authors = ["lemonsh"]
version = "0.1.0"
edition = "2021"
[dependencies]
rustyline = {version = "11", features = ["derive"]}
color-eyre = "0.6"
tokio = { version = "1.0", default-features = false, features = ["macros"] }
tracing = "0.1"
tracing-subscriber = "0.3"
clap = { version = "4.2", default-features = false, features = ["suggestions", "color", "std", "help", "usage", "derive"] }
dirs = "5.0"
connectbox = { path = "../connectbox" }
color-print = "0.3"
ascii_table = "4.0"
once_cell = "1.17"

View File

@ -1,8 +0,0 @@
# connectbox-shell
Interactive shell for managing a Connect Box modem.
## Installation
You can install this project easily using this command:
```sh
cargo install --git https://git.lemonsh.moe/lemon/connectbox-rs.git
```

View File

@ -1,63 +0,0 @@
use std::net::Ipv4Addr;
use clap::{Command, Parser, Subcommand};
use tracing::Level;
#[derive(Parser, Debug)]
#[command(author, version, about)]
pub(crate) struct Args {
/// Address of the modem
pub address: String,
/// Password used to log in to the modem. If not supplied, it will be asked interactively
pub password: Option<String>,
/// Log level, one of 'trace', 'debug', 'info', 'warn', 'error'
#[arg(short, default_value = "warn")]
pub log_level: Level,
}
#[derive(Parser, Debug)]
pub(crate) enum ShellCommand {
/// Log out and close the shell
Exit,
/// Manage port forwards
#[command(name = "pfw")]
PortForwards {
#[command(subcommand)]
cmd: PortForwardsCommand,
},
}
#[derive(Parser, Debug)]
pub(crate) enum PortForwardsCommand {
/// List all port forwards
Show,
/// Add a port forward
Add {
/// LAN address of the host to forward the port to
local_ip: Ipv4Addr,
/// External port range
range: String,
/// Internal port range. If unspecified, the same as external
int_range: Option<String>,
/// TCP, UDP or both
#[arg(short, default_value = "both")]
protocol: String,
/// Add this port forward in disabled state
#[arg(short)]
disable: bool,
},
/// Enable, disable or delete a port forward
Edit {
/// ID of the port. You can use `pfw show` to find it
id: u32,
/// Action to perform with the port. Can be either enable, disable or delete.
action: String,
},
}
pub(crate) fn shell_cmd() -> Command {
ShellCommand::augment_subcommands(Command::new("").multicall(true))
}

View File

@ -1 +0,0 @@
pub mod pfw;

View File

@ -1,140 +0,0 @@
use std::vec;
use ascii_table::{Align::Right, AsciiTable};
use color_eyre::Result;
use color_print::{cprintln, cprint};
use connectbox::{
models::{PortForwardEntry, PortForwardProtocol},
PortForwardAction,
};
use once_cell::sync::OnceCell;
use crate::{cli::PortForwardsCommand, AppState};
static PORT_FORWARDING_TABLE: OnceCell<AsciiTable> = OnceCell::new();
fn init_port_forwarding_table() -> AsciiTable {
let mut t = AsciiTable::default();
t.column(0).set_header("ID").set_align(Right);
t.column(1).set_header("Local IP");
t.column(2).set_header("Port range");
t.column(3).set_header("Int. port range");
t.column(4).set_header("Protocol");
t.column(5).set_header("Enabled");
t
}
pub(crate) async fn run(cmd: PortForwardsCommand, state: &AppState) -> Result<()> {
match cmd {
PortForwardsCommand::Show => {
cprintln!("<blue!>Retrieving the port forwarding table...");
let port_forwards = state.connect_box.port_forwards().await?;
let table_entries = port_forwards.entries.into_iter().map(|e| {
let port_range = format!("{}-{}", e.start_port, e.end_port);
let in_port_range = format!("{}-{}", e.start_port_in, e.end_port_in);
vec![
e.id.to_string(),
e.local_ip.to_string(),
port_range,
in_port_range,
e.protocol.to_string(),
e.enable.to_string(),
]
});
let rendered_table = PORT_FORWARDING_TABLE
.get_or_init(init_port_forwarding_table)
.format(table_entries);
cprint!(
"<black!>LAN IP: {}\nSubnet mask: {}\n</black!>{rendered_table}",
port_forwards.lan_ip,
port_forwards.subnet_mask
);
}
PortForwardsCommand::Add {
local_ip,
range,
int_range,
protocol,
disable,
} => {
let Some(protocol) = PortForwardProtocol::new(&protocol) else {
cprintln!("<red!>Invalid protocol {protocol:?}");
return Ok(())
};
let Some(range) = parse_port_range(&range) else {
cprintln!("<red!>Invalid external range {range:?}");
return Ok(())
};
let int_range = if let Some(r) = int_range {
let Some(r) = parse_port_range(&r) else {
cprintln!("<red!>Invalid internal range {r:?}");
return Ok(())
};
r
} else {
range
};
let port = PortForwardEntry {
id: 0,
local_ip,
start_port: range.0,
end_port: range.1,
start_port_in: int_range.0,
end_port_in: int_range.1,
protocol,
enable: !disable,
};
state.connect_box.add_port_forward(&port).await?;
cprintln!("<green!>Done!");
}
PortForwardsCommand::Edit { id, mut action } => {
action.make_ascii_lowercase();
let action = match action.as_str() {
"enable" => {
cprintln!("<blue!>Enabling port {id}...");
PortForwardAction::Enable
}
"disable" => {
cprintln!("<blue!>Disabling port {id}...");
PortForwardAction::Disable
}
"delete" => {
cprintln!("<blue!>Deleting port {id}...");
PortForwardAction::Delete
}
_ => {
cprintln!("<red!>Invalid action {action:?}");
return Ok(());
}
};
let mut modified = false;
state
.connect_box
.edit_port_forwards(|p| {
if p.id == id {
modified = true;
action
} else {
PortForwardAction::Keep
}
})
.await?;
if modified {
cprintln!("<green!>Done!");
} else {
cprintln!("<red!>No port with id {id} exists");
}
}
}
Ok(())
}
fn parse_port_range(s: &str) -> Option<(u16, u16)> {
if let Some((start, end)) = s.split_once('-') {
Some((start.parse().ok()?, end.parse().ok()?))
} else if let Ok(port) = s.parse() {
Some((port, port))
} else {
None
}
}

View File

@ -1,118 +1,3 @@
use crate::{cli::ShellCommand, utils::QuotableArgs};
use clap::{FromArgMatches, Parser};
use cli::Args;
use color_eyre::Result;
use color_print::{cformat, cprintln};
use connectbox::ConnectBox;
use rustyline::{
error::ReadlineError,
highlight::Highlighter,
Completer, Editor, Helper, Hinter, Validator,
};
use std::borrow::Cow;
mod cli;
mod commands;
mod utils;
pub(crate) struct AppState {
connect_box: ConnectBox,
}
#[derive(Completer, Helper, Hinter, Validator)]
struct GreenPrompt;
impl Highlighter for GreenPrompt {
fn highlight_prompt<'b, 's: 'b, 'p: 'b>(
&'s self,
prompt: &'p str,
_default: bool,
) -> std::borrow::Cow<'b, str> {
cformat!("<green!>{prompt}").into()
}
}
#[derive(Completer, Helper, Hinter, Validator)]
struct PasswordPrompt;
impl Highlighter for PasswordPrompt {
fn highlight_prompt<'b, 's: 'b, 'p: 'b>(
&'s self,
prompt: &'p str,
_default: bool,
) -> std::borrow::Cow<'b, str> {
cformat!("<red!>{prompt}").into()
}
fn highlight<'l>(&self, line: &'l str, _pos: usize) -> Cow<'l, str> {
"*".repeat(line.len()).into()
}
fn highlight_char(&self, _line: &str, _pos: usize) -> bool {
true
}
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<()> {
let args = Args::parse();
let mut shell_cmd = cli::shell_cmd();
color_eyre::install()?;
tracing_subscriber::fmt::fmt()
.with_max_level(args.log_level)
.init();
let mut rl = Editor::new()?;
rl.set_helper(Some(GreenPrompt));
let password = if let Some(password) = args.password {
password
} else {
let mut rl = Editor::new()?;
rl.set_helper(Some(PasswordPrompt));
rl.readline("Password: ")?
};
let history_path = dirs::data_dir()
.unwrap_or_default()
.join(".connectbox-shell-history");
let _err = rl.load_history(&history_path);
cprintln!("<blue!>Logging in...");
let connect_box = ConnectBox::new(args.address, password, true)?;
connect_box.login().await?;
let state = AppState { connect_box };
loop {
match rl.readline("\n >> ") {
Ok(line) => {
if line.chars().all(char::is_whitespace) {
continue;
}
let matches = shell_cmd.try_get_matches_from_mut(QuotableArgs::new(&line));
rl.add_history_entry(line)?;
let cmd = match matches {
Ok(mut matches) => ShellCommand::from_arg_matches_mut(&mut matches)?,
Err(e) => {
e.print()?;
continue;
}
};
match cmd {
ShellCommand::Exit => break,
ShellCommand::PortForwards { cmd } => commands::pfw::run(cmd, &state).await?,
}
}
Err(ReadlineError::Interrupted | ReadlineError::Eof) => break,
Err(err) => {
println!("{err:?}");
break;
}
}
}
cprintln!("<blue!>Logging out...");
state.connect_box.logout().await?;
rl.save_history(&history_path)?;
Ok(())
fn main() {
println!("Hello, world!");
}

View File

@ -1,40 +0,0 @@
pub(crate) struct QuotableArgs<'a> {
s: &'a str,
}
impl<'a> QuotableArgs<'a> {
pub fn new(s: &'a str) -> Self {
Self { s }
}
}
impl<'a> Iterator for QuotableArgs<'a> {
type Item = &'a str;
fn next(&mut self) -> Option<Self::Item> {
self.s = self.s.trim_start();
if self.s.is_empty() {
return None;
}
if self.s.as_bytes()[0] == b'"' {
self.s = &self.s[1..];
if let Some(pos) = self.s.find('"') {
let result = &self.s[..pos];
self.s = &self.s[pos + 1..];
return Some(result);
}
let result = self.s;
self.s = &self.s[..0];
return Some(result);
}
if let Some(pos) = self.s.find(char::is_whitespace) {
let result = &self.s[..pos];
self.s = &self.s[pos..];
Some(result)
} else {
let result = self.s;
self.s = &self.s[..0];
Some(result)
}
}
}

View File

@ -1,7 +1,7 @@
[package]
name = "connectbox"
description = "API client library for the Compal CH7465LG, which is a cable modem provided by various European ISPs under the name Connect Box."
version = "0.1.1"
version = "0.1.0"
edition = "2021"
license = "EUPL-1.2"
repository = "https://git.lemonsh.moe/lemon/connectbox-rs"

View File

@ -3,18 +3,14 @@ API client library for the Compal CH7465LG, which is a cable modem provided by v
For more information, see the crate documentation.
## Supported endpoints
- [x] Devices list
- [x] Port forwarding
- [ ] Wireless settings
This list will grow as the project progresses.
### IPv6 Notice
I am running my modem in the IPv4 mode, so the options available to me are different than what IPv6 mode users see. Thus, this crate will likely not work correctly with IPv6 mode Connect Boxes.
Contributions adding IPv6 support are always welcome, though.
### Similar projects
* [home-assistant-ecosystem/python-connect-box](https://github.com/home-assistant-ecosystem/python-connect-box) (Python)
* [ties/compal_CH7465LG_py](https://github.com/ties/compal_CH7465LG_py) (Python)
### Credits
Special thanks to the authors of the following projects:
* [home-assistant-ecosystem/python-connect-box](https://github.com/home-assistant-ecosystem/python-connect-box)
* [ties/compal_CH7465LG_py](https://github.com/ties/compal_CH7465LG_py)
They have saved me from hours of reverse engineering work.

View File

@ -1,6 +1,3 @@
// This example shows you how to add, edit and remove port forwading entries.
// Usage: cargo run --example port_forwards -- <Connect Box IP> <login password> <local IP>
use std::{env, net::Ipv4Addr};
use color_eyre::Result;
@ -13,24 +10,16 @@ use connectbox::{
async fn main() -> Result<()> {
tracing_subscriber::fmt::init();
color_eyre::install()?;
let mut args = env::args().skip(1);
let ip = args.next().expect("no ip specified");
let password = args.next().expect("no password specified");
let local_ip: Ipv4Addr = args
.next()
.expect("no local ip specified")
.parse()
.expect("local ip is not a valid ipv4 address");
let code = args.next().expect("no code specified");
// first, we create a new API client and log in to the router.
let connect_box = ConnectBox::new(ip, password, true)?;
let connect_box = ConnectBox::new(ip, code, true)?;
connect_box.login().await?;
// then, we remove all port forwarding entries for the local ip
connect_box
.edit_port_forwards(|v| {
if v.local_ip == local_ip {
if v.local_ip == Ipv4Addr::new(192, 168, 0, 180) {
PortForwardAction::Delete
} else {
PortForwardAction::Keep
@ -38,10 +27,9 @@ async fn main() -> Result<()> {
})
.await?;
// then, we add a new port forward for the port 25565 (Minecraft server)
let pf = PortForwardEntry {
id: 0,
local_ip,
local_ip: "192.168.0.180".parse().unwrap(),
start_port: 25565,
end_port: 25565,
start_port_in: 25565,
@ -51,11 +39,9 @@ async fn main() -> Result<()> {
};
connect_box.add_port_forward(&pf).await?;
// lastly, we get the new port forwarding table and print it out
let portforwards = connect_box.port_forwards().await?;
println!("{portforwards:#?}");
// and then we log out so that other users can log in to the web interface
connect_box.logout().await?;
Ok(())

View File

@ -6,7 +6,7 @@ pub enum Error {
#[error("session token not found, are you logged in?")]
NoSessionToken,
#[error("incorrect password")]
IncorrectPassword,
IncorrectCode,
#[error("unexpected response from the server: {0:?}")]
UnexpectedResponse(String),
#[error("you are not logged in, or perhaps the session has expired")]

View File

@ -1,4 +1,4 @@
//! API client library for the Compal CH7465LG, which is a cable modem provided by various European ISPs under the name Connect Box.
//! API client library for the Compal CH7465CE, which is a cable modem provided by various European ISPs under the name Connect Box.
#![allow(clippy::missing_errors_doc)]
use std::{borrow::Cow, fmt::Display, sync::Arc};
@ -26,7 +26,7 @@ type Field<'a, 'b> = (Cow<'a, str>, Cow<'b, str>);
/// The entry point of the library - the API client
pub struct ConnectBox {
http: Client,
password: String,
code: String,
cookie_store: Arc<Jar>,
base_url: Url,
getter_url: Url,
@ -36,9 +36,9 @@ pub struct ConnectBox {
impl ConnectBox {
/// Create a new client associated with the specified address. You must call [`login`](Self::login()) before use.
/// * `password` - the router password
/// * `code` - the router password
/// * `auto_reauth` - whether to automatically re-authenticate when the session expires
pub fn new(address: impl Display, password: String, auto_reauth: bool) -> Result<Self> {
pub fn new(address: impl Display, code: String, auto_reauth: bool) -> Result<Self> {
let cookie_store = Arc::new(Jar::default());
let http = Client::builder()
.user_agent("Mozilla/5.0")
@ -50,7 +50,7 @@ impl ConnectBox {
let setter_url = base_url.join("xml/setter.xml")?;
Ok(ConnectBox {
http,
password,
code,
cookie_store,
base_url,
getter_url,
@ -139,7 +139,7 @@ impl ConnectBox {
("token".into(), session_token.into()),
("fun".into(), functions::LOGIN.to_string().into()),
("Username".into(), "NULL".into()),
("Password".into(), (&self.password).into()),
("Password".into(), (&self.code).into()),
];
let req = self.http.post(self.setter_url.clone()).form(form);
let resp = req.send().await?;
@ -155,7 +155,7 @@ impl ConnectBox {
}
let resp_text = resp.text().await?;
if resp_text == "idloginincorrect" {
return Err(Error::IncorrectPassword);
return Err(Error::IncorrectCode);
}
let sid = resp_text
.strip_prefix("successful;SID=")
@ -201,7 +201,7 @@ impl ConnectBox {
}
/// Toggle or remove port forwards.
///
///
/// This function accepts a predicate that will be called for every existing port forward. It should decide what to do with each port forward and return a [`PortForwardAction`].
pub async fn edit_port_forwards<F>(&self, mut f: F) -> Result<()>
where
@ -262,7 +262,7 @@ impl ConnectBox {
("end_port".into(), port.end_port.to_string().into()),
("start_portIn".into(), port.start_port_in.to_string().into()),
("end_portIn".into(), port.end_port_in.to_string().into()),
("protocol".into(), port.protocol.id_str().into()),
("protocol".into(), port.protocol.id().to_string().into()),
("enable".into(), u8::from(port.enable).to_string().into()),
("delete".into(), "0".into()),
("idd".into(), "".into()),
@ -279,7 +279,6 @@ impl ConnectBox {
}
/// Specifies the action to perform with a given port forward. Used in conjunction with [`ConnectBox::edit_port_forwards`]
#[derive(Debug, Clone, Copy)]
pub enum PortForwardAction {
/// Don't do anything with the port forward
Keep,

View File

@ -1,9 +1,8 @@
use std::{fmt::Display, net::Ipv4Addr, time::Duration};
use std::net::Ipv4Addr;
use std::time::Duration;
use serde::{
de::{self, Error, Unexpected},
Deserialize, Deserializer,
};
use serde::de::{self, Error, Unexpected};
use serde::{Deserialize, Deserializer};
#[derive(Deserialize, Debug)]
pub struct LanUserTable {
@ -71,21 +70,11 @@ pub enum PortForwardProtocol {
}
impl PortForwardProtocol {
pub(crate) fn id_str(&self) -> &str {
pub(crate) fn id(&self) -> u8 {
match self {
PortForwardProtocol::Tcp => "1",
PortForwardProtocol::Udp => "2",
PortForwardProtocol::Both => "3",
}
}
#[must_use]
pub fn new(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"tcp" => Some(Self::Tcp),
"udp" => Some(Self::Udp),
"both" => Some(Self::Both),
_ => None,
PortForwardProtocol::Tcp => 1,
PortForwardProtocol::Udp => 2,
PortForwardProtocol::Both => 3,
}
}
}
@ -104,16 +93,6 @@ impl<'de> Deserialize<'de> for PortForwardProtocol {
}
}
impl Display for PortForwardProtocol {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
PortForwardProtocol::Tcp => "TCP",
PortForwardProtocol::Udp => "UDP",
PortForwardProtocol::Both => "Both",
})
}
}
fn bool_from_int<'de, D>(deserializer: D) -> Result<bool, D::Error>
where
D: Deserializer<'de>,

View File

@ -1,2 +0,0 @@
unstable_features = true
imports_granularity = "Crate"