3 Commits
Author SHA1 Message Date
Julian 816aff8902 better refreshing on client app to avoid showing stale data 2026-10-05 01:20:11 +02:00
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
16 changed files with 315 additions and 32 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",
+58 -13
View File
@@ -5,6 +5,10 @@ import type { AgentState } from "./types";
const STORAGE_KEY = "framemate.connection";
const DEFAULT_PORT = 7380;
const RETRY_MS = 3000;
/** A connect that hasn't opened by then is given up (a sleeping Frame never answers the SYN). */
const CONNECT_TIMEOUT_MS = 8000;
/** The agent pushes at least every ~10 s (power poll); silence beyond this means a dead socket. */
const SILENCE_MS = 25000;
export interface ConnectionSettings {
/** Hostname or IP, optionally with `:port`. */
@@ -28,8 +32,17 @@ class Agent {
state = $state<AgentState | null>(null);
status = $state<ConnectionStatus>("unconfigured");
/** When the last message arrived; tells live data from a snapshot left over from before. */
receivedAt = $state(0);
#socket: WebSocket | null = null;
#retry: ReturnType<typeof setTimeout> | undefined;
#watchdog: ReturnType<typeof setTimeout> | undefined;
/** True while `state` is being kept current by an open connection. */
get live() {
return this.status === "connected";
}
get configured() {
return this.settings.host.trim() !== "" && this.settings.token.trim() !== "";
@@ -67,24 +80,55 @@ class Agent {
if (this.status !== "offline" && this.status !== "unauthorized") this.status = "connecting";
const socket = new WebSocket(this.socketUrl("/api/ws"));
this.#socket = socket;
let opened = false;
const lost = () => this.#lost(socket, opened);
socket.onmessage = event => {
if (this.#socket !== socket) return;
this.state = JSON.parse(event.data);
this.status = "connected";
this.receivedAt = Date.now();
this.#arm(socket, SILENCE_MS, lost);
};
let opened = false;
socket.onopen = () => (opened = true);
socket.onclose = async () => {
if (this.#socket !== socket) return; // replaced by a newer connection
this.#socket = null;
// A rejected upgrade looks like any other failure to the WebSocket API; ask over HTTP.
const status = opened ? "offline" : await this.#probe();
if (this.#socket || this.#retry !== undefined) return; // reconnected meanwhile
this.status = status;
this.#retry = setTimeout(() => {
this.#retry = undefined;
this.connect();
}, RETRY_MS);
};
socket.onclose = lost;
this.#arm(socket, CONNECT_TIMEOUT_MS, lost);
}
/**
* Fresh snapshot after the app was in the background: Android freezes the WebView, so the old
* socket may be dead without ever having fired `onclose` and would keep showing old data.
* The current state stays visible (marked stale via `live`) until the new one arrives.
*/
refresh() {
if (!this.configured) return;
if (this.status === "connected") this.status = "connecting";
this.connect();
}
/** Give up on `socket` if nothing arrives within `ms`. */
#arm(socket: WebSocket, ms: number, lost: () => void) {
clearTimeout(this.#watchdog);
this.#watchdog = setTimeout(() => {
if (this.#socket !== socket) return;
// On a dead TCP connection onclose can take minutes; don't wait for it.
socket.onclose = null;
socket.close();
lost();
}, ms);
}
async #lost(socket: WebSocket, opened: boolean) {
if (this.#socket !== socket) return; // replaced by a newer connection
this.#socket = null;
clearTimeout(this.#watchdog);
// A rejected upgrade looks like any other failure to the WebSocket API; ask over HTTP.
const status = opened ? "offline" : await this.#probe();
if (this.#socket || this.#retry !== undefined) return; // reconnected meanwhile
this.status = status;
this.#retry = setTimeout(() => {
this.#retry = undefined;
this.connect();
}, RETRY_MS);
}
/** Wrong token (401) vs. unreachable; needs CORS on /api/state. */
@@ -99,6 +143,7 @@ class Agent {
}
#close() {
clearTimeout(this.#watchdog);
clearTimeout(this.#retry);
this.#retry = undefined;
const socket = this.#socket;
+34 -5
View File
@@ -20,17 +20,27 @@
];
const battery = $derived(agent.state?.steam.topics.battery);
// Old data stays visible while reconnecting, dimmed and with its age in the top bar.
const stale = $derived(!!agent.state && !agent.live);
const lastUpdate = $derived(
new Date(agent.receivedAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
);
onMount(() => {
agent.connect();
if (updates.autoCheck) updates.check();
if (!agent.configured) goto("/settings");
// Mobile WebViews drop sockets in the background; reconnect when we come back.
// Coming back from the background: the socket may be dead without knowing it, so always
// fetch a fresh snapshot instead of trusting the old one.
const onVisible = () => {
if (document.visibilityState === "visible" && agent.status !== "connected") agent.connect();
if (document.visibilityState === "visible") agent.refresh();
};
document.addEventListener("visibilitychange", onVisible);
return () => document.removeEventListener("visibilitychange", onVisible);
window.addEventListener("online", onVisible);
return () => {
document.removeEventListener("visibilitychange", onVisible);
window.removeEventListener("online", onVisible);
};
});
</script>
@@ -40,18 +50,21 @@
<a href="/" class="title">FrameMate</a>
<span class="spacer"></span>
{#if battery}
<span class="battery">
<span class="battery" class:stale>
{Math.round(battery.level * 100)}%
<Battery level={battery.level} charging={battery.ac_state === 2} size={18} />
</span>
{/if}
{#if stale}
<span class="stale-note">{agent.status === "connecting" ? "Updating…" : lastUpdate}</span>
{/if}
<span class="status {agent.status}" title={agent.status}></span>
<a href="/help" class="help" class:active={true} aria-label="Help" >
<Icon name="help" size={20} />
</a>
</header>
<main>
<main class:stale>
{@render children()}
</main>
@@ -121,6 +134,22 @@
overflow-y: auto;
padding-bottom: 24px;
}
/* Delayed, so the usual quick refresh on resume doesn't flash. */
main.stale,
.battery.stale {
opacity: 0.45;
transition: opacity 0.2s 0.8s;
}
.stale-note {
color: var(--muted);
font-size: 13px;
animation: appear 0.2s 0.8s both;
}
@keyframes appear {
from {
opacity: 0;
}
}
.tabs {
display: flex;
background: var(--topbar);
+15 -1
View File
@@ -103,6 +103,20 @@
if (video.currentTime - start > 10) buffer.remove(start, video.currentTime - 5);
}
// In the background the socket dies or, worse, keeps the Frame capturing for nobody:
// stop it, and start fresh (new keyframe, no stale buffer) when the app comes back.
function onVisibilityChange() {
if (closed) return;
clearTimeout(retry);
if (socket) socket.onclose = null;
socket?.close();
socket = null;
if (document.visibilityState === "visible") {
status = "Connecting…";
connect();
}
}
onMount(connect);
onDestroy(() => {
if (fullscreen) applyFullscreen(false);
@@ -113,7 +127,7 @@
});
</script>
<svelte:document onfullscreenchange={onFullscreenChange} />
<svelte:document onfullscreenchange={onFullscreenChange} onvisibilitychange={onVisibilityChange} />
<div class="player" class:fullscreen>
<video bind:this={video} autoplay muted playsinline></video>
+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()?;