mirror of
https://github.com/nailuj05/framemate.git
synced 2026-10-18 01:01:58 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd029b0050 | ||
|
|
724acd4ac6 |
No files matched your search
Generated
+1
-1
@@ -327,7 +327,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "framemate-agent"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "framemate-app",
|
||||
"version": "0.1.4",
|
||||
"version": "0.1.5",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
Generated
+1
-1
@@ -823,7 +823,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "framemate-app"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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(());
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
Reference in new issue
Block a user