2 Commits
Author SHA1 Message Date
Julian cd029b0050 Release v0.1.5 2026-10-04 21:20:59 +02:00
Julian 724acd4ac6 token rotation, connection blacklist, dont log token 2026-10-04 21:20:02 +02:00
13 changed files with 208 additions and 13 deletions

No files matched your search

Generated
+1 -1
View File
@@ -327,7 +327,7 @@ dependencies = [
[[package]]
name = "framemate-agent"
version = "0.1.4"
version = "0.1.5"
dependencies = [
"anyhow",
"axum",
+1 -1
View File
@@ -5,7 +5,7 @@ resolver = "3"
members = ["crates/agent"]
[workspace.package]
version = "0.1.4"
version = "0.1.5"
edition = "2024"
license = "GPL-3.0-or-later"
+4 -2
View File
@@ -73,7 +73,8 @@ rm framemate-agent.flatpak
user's systemd); the agent then starts with the next restart, and the command prints how to
start it immediately.
- `flatpak run --user dev.framemate.Agent check` repeats the self check (useful when the app can't
connect); `flatpak run --user dev.framemate.Agent token` prints just the token.
connect); `flatpak run --user dev.framemate.Agent token` prints just the token, and
`flatpak run --user dev.framemate.Agent rotate-token` replaces it with a new one.
To **update**, download the new `framemate-agent.flatpak` and run the same commands
again. To **remove** it:
@@ -106,7 +107,8 @@ page in any browser, and `http://frame.local:7380/stream?token=<your token>` mir
- **Local network only.** The agent listens on port 7380 and talks plain HTTP/WebSocket, protected
by the token. Don't expose that port to the internet. The connection isn't encrypted, so others
on the same network could read the token and what's sent, including Mirroring. Use FrameMate on
networks you trust, like your home Wi-Fi, not on public or shared ones.
networks you trust, like your home Wi-Fi, not on public or shared ones. The agent also rejects
connections from public addresses outside your network (relevant for IPv6, where the Frame is globally addressable)
- **Developer Mode.** FrameMate doesn't depend on it. Note that while it is on, SteamOS's devkit
service exposes Steam's debugging interface to your whole network (port 8081); FrameMate never
uses that port.
+12
View File
@@ -24,10 +24,22 @@ Make sure of the following:
Note: Some guest or mesh wifi networks may isolate devices by default, make sure that isn't the issue before proceeding.
### "FrameMate only accepts connections from the local network"
The agent rejects devices it doesn't consider part of your local network. That can hit unusual
setups: a phone on a VPN, a guest or mesh Wi-Fi with its own subnet, or a router handing out
IPv6 addresses from several prefixes. The agent's log names the rejected address
(`journalctl --user -n 50 _COMM=framemate-agent`). To turn the check off, add
`Environment=FRAMEMATE_ALLOW_REMOTE=1` under `[Service]` in
`~/.config/systemd/user/framemate-agent.service`, then run
`systemctl --user daemon-reload && systemctl --user restart framemate-agent`. Please also open
an issue with your setup, so the check can be improved.
## Installation issues
If you encounter an error during installation please send me the logs and the commands you ran in a github issue.
Should the app say "Wrong token", get it with `flatpak run --user dev.framemate.Agent token`. Note that reinstalling keeps the token.
You may generate a new token with `flatpak run --user dev.framemate.Agent rotate-token` and enter the it in the app.
### Installing from Desktop Mode
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "framemate-app",
"version": "0.1.4",
"version": "0.1.5",
"description": "",
"type": "module",
"scripts": {
+1 -1
View File
@@ -823,7 +823,7 @@ dependencies = [
[[package]]
name = "framemate-app"
version = "0.1.4"
version = "0.1.5"
dependencies = [
"tauri",
"tauri-build",
+1 -1
View File
@@ -4,7 +4,7 @@
[package]
name = "framemate-app"
description = "FrameMate companion app"
version = "0.1.4"
version = "0.1.5"
license = "GPL-3.0-or-later"
edition = "2024"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "FrameMate",
"version": "0.1.4",
"version": "0.1.5",
"identifier": "dev.framemate.app",
"build": {
"beforeDevCommand": "bun run dev",
+137
View File
@@ -0,0 +1,137 @@
//! Only clients from the local network may connect. Mostly matters for IPv6, where the Frame has
//! a globally routable address and only the router's firewall stands between it and the internet.
//! Allowed: loopback, private/link-local/ULA ranges, CGNAT (Tailscale) and any address in the same
//! subnet as one of the Frame's interfaces (LAN devices with global IPv6 addresses).
//! `FRAMEMATE_ALLOW_REMOTE=1` turns the check off. The token stays the actual protection.
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
use std::sync::atomic::{AtomicU64, Ordering};
use axum::extract::{ConnectInfo, Request, State};
use axum::http::StatusCode;
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
/// At most one log line per this many seconds, so a scanner can't flood the journal.
const LOG_INTERVAL_S: u64 = 10;
pub async fn local_only(
State(allow_remote): State<bool>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
request: Request,
next: Next,
) -> Response {
let ip = peer.ip().to_canonical(); // IPv4 clients arrive as ::ffff:a.b.c.d on the dual-stack socket
if allow_remote || is_local(ip, &interface_networks) {
return next.run(request).await;
}
static LAST_LOG: AtomicU64 = AtomicU64::new(0);
let now = crate::hub::now_ms() / 1000;
if now.saturating_sub(LAST_LOG.swap(now, Ordering::Relaxed)) >= LOG_INTERVAL_S {
tracing::warn!("rejected {ip}: not in the local network (FRAMEMATE_ALLOW_REMOTE=1 allows it)");
}
(StatusCode::FORBIDDEN, "FrameMate only accepts connections from the local network\n").into_response()
}
/// `networks` is only consulted for public addresses (reads the interfaces).
fn is_local(ip: IpAddr, networks: &dyn Fn() -> Vec<(IpAddr, u8)>) -> bool {
let always = match ip {
IpAddr::V4(v4) => {
v4.is_loopback() || v4.is_private() || v4.is_link_local() || in_network(ip, Ipv4Addr::new(100, 64, 0, 0).into(), 10)
}
IpAddr::V6(v6) => {
v6.is_loopback()
|| in_network(ip, Ipv6Addr::new(0xfc00, 0, 0, 0, 0, 0, 0, 0).into(), 7) // ULA
|| in_network(ip, Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 0).into(), 10) // link-local
}
};
always || networks().into_iter().any(|(net, prefix)| in_network(ip, net, prefix))
}
fn in_network(ip: IpAddr, net: IpAddr, prefix: u8) -> bool {
match (ip, net) {
(IpAddr::V4(ip), IpAddr::V4(net)) => {
let mask = u32::MAX.checked_shl(32 - u32::from(prefix.min(32))).unwrap_or(0);
u32::from(ip) & mask == u32::from(net) & mask
}
(IpAddr::V6(ip), IpAddr::V6(net)) => {
let mask = u128::MAX.checked_shl(128 - u32::from(prefix.min(128))).unwrap_or(0);
u128::from(ip) & mask == u128::from(net) & mask
}
_ => false,
}
}
/// (address, prefix length) of every interface address, via getifaddrs(3).
fn interface_networks() -> Vec<(IpAddr, u8)> {
let mut out = Vec::new();
let mut list: *mut libc::ifaddrs = std::ptr::null_mut();
// SAFETY: getifaddrs allocates the list, freed below.
if unsafe { libc::getifaddrs(&mut list) } != 0 {
return out;
}
let mut node = list;
while !node.is_null() {
// SAFETY: nodes stay valid until freeifaddrs.
let ifa = unsafe { &*node };
if let (Some(addr), Some(mask)) = (sockaddr_ip(ifa.ifa_addr), sockaddr_ip(ifa.ifa_netmask)) {
let prefix = match mask {
IpAddr::V4(m) => u32::from(m).count_ones(),
IpAddr::V6(m) => u128::from(m).count_ones(),
};
out.push((addr, prefix as u8));
}
node = ifa.ifa_next;
}
// SAFETY: the list from getifaddrs above.
unsafe { libc::freeifaddrs(list) };
out
}
fn sockaddr_ip(sa: *const libc::sockaddr) -> Option<IpAddr> {
if sa.is_null() {
return None;
}
// SAFETY: sa_family says which sockaddr variant this is.
unsafe {
match i32::from((*sa).sa_family) {
libc::AF_INET => {
let sin = &*(sa as *const libc::sockaddr_in);
Some(Ipv4Addr::from(u32::from_be(sin.sin_addr.s_addr)).into())
}
libc::AF_INET6 => Some(Ipv6Addr::from((*(sa as *const libc::sockaddr_in6)).sin6_addr.s6_addr).into()),
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn local(ip: &str) -> bool {
// The Frame: 192.168.178.130/24 and a global IPv6 address in 2001:db8:1:2::/64.
let lan = || vec![("192.168.178.130".parse().unwrap(), 24), ("2001:db8:1:2::abcd".parse().unwrap(), 64)];
is_local(ip.parse::<IpAddr>().unwrap().to_canonical(), &lan)
}
#[test]
fn allows_the_local_network() {
for ip in ["127.0.0.1", "::1", "192.168.178.22", "10.1.2.3", "172.20.0.5", "169.254.1.1", "100.101.102.103",
"fd7a:115c:a1e3::1", "fe80::1", "::ffff:192.168.178.22", "2001:db8:1:2::77"] {
assert!(local(ip), "{ip} should be allowed");
}
}
#[test]
fn rejects_public_addresses() {
for ip in ["8.8.8.8", "::ffff:1.1.1.1", "2001:db8:9:9::1", "2a00:1450:4001::200e"] {
assert!(!local(ip), "{ip} should be rejected");
}
}
#[test]
fn reads_interfaces() {
assert!(interface_networks().iter().any(|(ip, _)| ip.is_loopback()));
}
}
+13 -1
View File
@@ -16,6 +16,8 @@ pub struct Config {
pub token: String,
pub power_supply_dir: PathBuf,
pub stream: StreamConfig,
/// Accept clients from outside the local network (see access.rs).
pub allow_remote: bool,
}
impl Config {
@@ -44,6 +46,7 @@ impl Config {
fps,
bitrate,
},
allow_remote: matches!(env_or("FRAMEMATE_ALLOW_REMOTE", "").as_str(), "1" | "true" | "yes"),
})
}
}
@@ -110,6 +113,15 @@ pub fn load_or_create_token() -> anyhow::Result<String> {
return Ok(token);
}
}
write_new_token(&path)
}
/// Replaces the token; the running agent only reads it at startup.
pub fn rotate_token() -> anyhow::Result<String> {
write_new_token(&config_dir()?.join("token"))
}
fn write_new_token(path: &std::path::Path) -> anyhow::Result<String> {
let mut bytes = [0u8; TOKEN_LEN];
std::fs::File::open("/dev/urandom")?.read_exact(&mut bytes)?;
// 256 is a multiple of 32, so `% 32` is unbiased.
@@ -123,7 +135,7 @@ pub fn load_or_create_token() -> anyhow::Result<String> {
.create(true)
.truncate(true)
.mode(0o600)
.open(&path)
.open(path)
.with_context(|| format!("writing {}", path.display()))?;
std::io::Write::write_all(&mut file, format_token(&token).as_bytes())?;
tracing::info!("generated a new API token");
+4 -1
View File
@@ -1,3 +1,4 @@
mod access;
mod cdp;
mod check;
mod config;
@@ -23,7 +24,8 @@ commands:
install-service start the agent with the user session (systemd user unit)
uninstall-service remove that unit again
token print the API token for the companion app
check check the running agent and print what the app needs";
check check the running agent and print what the app needs
rotate-token replace the API token (and restart the agent to use it)";
#[tokio::main]
async fn main() -> anyhow::Result<()> {
@@ -32,6 +34,7 @@ async fn main() -> anyhow::Result<()> {
Some("install-service") => return service::install().await,
Some("uninstall-service") => return service::uninstall().await,
Some("check") => return check::run().await,
Some("rotate-token") => return service::rotate_token().await,
Some("token") => {
println!("{}", config::format_token(&config::load_or_create_token()?));
return Ok(());
+9 -3
View File
@@ -49,6 +49,7 @@ pub async fn serve(hub: Arc<Hub>, stream: Arc<LiveStream>, config: &Config) -> a
.route("/api/stream/ws", get(stream_ws))
.route("/favicon.svg", get(|| async { asset("image/svg+xml", include_bytes!("../../../assets/framemate-black.svg")) }))
.route("/healthz", get(|| async { "ok" }))
.layer(axum::middleware::from_fn_with_state(config.allow_remote, crate::access::local_only))
.with_state(AppState {
hub,
token: config.token.as_str().into(),
@@ -63,9 +64,14 @@ pub async fn serve(hub: Arc<Hub>, stream: Arc<LiveStream>, config: &Config) -> a
}
result => result?,
};
let token = crate::config::format_token(&config.token);
tracing::info!("listening on http://{}/?token={token} (token: {token})", config.listen);
axum::serve(listener, app)
tracing::info!("listening on {}", config.listen);
// The token never goes to the log (people paste logs into issues); only to a terminal.
// SAFETY: isatty only inspects the descriptor.
if unsafe { libc::isatty(libc::STDOUT_FILENO) } == 1 {
let token = crate::config::format_token(&config.token);
println!("Dashboard: http://localhost:{}/?token={token}", config.listen.port());
}
axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>())
.with_graceful_shutdown(shutdown_signal())
.await?;
Ok(())
+23
View File
@@ -80,6 +80,29 @@ pub async fn uninstall() -> anyhow::Result<()> {
Ok(())
}
pub async fn rotate_token() -> anyhow::Result<()> {
anyhow::ensure!(
std::env::var_os("FRAMEMATE_TOKEN").is_none(),
"FRAMEMATE_TOKEN is set and overrides the token file"
);
let token = crate::config::format_token(&crate::config::rotate_token()?);
println!("New token: {token}");
match Systemd::reachable().await {
// TryRestartUnit only restarts it if it's running; NoSuchUnit without install-service.
Ok(systemd) => match systemd.call("TryRestartUnit", &(UNIT, "replace")).await {
Ok(()) => println!("Restarted {UNIT}; enter the new token in the app."),
Err(_) => println!("{UNIT} isn't installed; restart the agent to use the new token."),
},
Err(_) => {
println!("The running agent keeps the old token until it restarts. Restart the Frame, or run:\n");
println!(
" env XDG_RUNTIME_DIR=/run/user/$(id -u) DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/$(id -u)/bus \\\n systemctl --user restart {UNIT}"
);
}
}
Ok(())
}
/// `(installation flag incl. trailing space, app id)` when running as a Flatpak.
fn flatpak_run() -> Option<(String, String)> {
let app_id = std::env::var("FLATPAK_ID").ok()?;