mirror of
https://github.com/phantomptr/ps5upload.git
synced 2026-10-19 02:20:02 -05:00
Find rest mode over every network; offer Wake when not awake
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
This commit is contained in:
1 parent
c16e5ab264
commit
638a1cec15
6 files changed
+261
-46
No files matched your search
@@ -162,10 +162,15 @@ pub fn require_in(pool: &Pool, console: &str, cap: u64) -> Result<(), UploadFail
|
||||
/// A session attempt that got no TCP answer at all (as opposed to a refusal): a timeout, or
|
||||
/// no route to the host. The console is off, elsewhere, or asleep.
|
||||
fn no_tcp_answer(e: &Ava1Error) -> bool {
|
||||
use std::io::ErrorKind::{HostUnreachable, NetworkUnreachable, TimedOut};
|
||||
use std::io::ErrorKind::{HostUnreachable, NetworkDown, NetworkUnreachable, TimedOut};
|
||||
match e {
|
||||
Ava1Error::Timeout => true,
|
||||
Ava1Error::Io(io) => matches!(io.kind(), TimedOut | HostUnreachable | NetworkUnreachable),
|
||||
Ava1Error::Io(io) => {
|
||||
matches!(
|
||||
io.kind(),
|
||||
TimedOut | HostUnreachable | NetworkUnreachable | NetworkDown
|
||||
) || ps5upload_core::ddp::host_is_down(io)
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -223,6 +228,15 @@ pub fn require_ava1(console: &str) -> Result<(), UploadFailure> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn host_is_down_counts_as_no_tcp_answer() {
|
||||
let e = Ava1Error::Io(std::io::Error::from_raw_os_error(libc::EHOSTDOWN));
|
||||
assert!(no_tcp_answer(&e));
|
||||
let refused = Ava1Error::Io(std::io::ErrorKind::ConnectionRefused.into());
|
||||
assert!(!no_tcp_answer(&refused));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_listening_is_helper_not_ava1_with_the_exact_message() {
|
||||
let d = std::env::temp_dir().join(format!("p5a-console-{}", std::process::id()));
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
//! Note for anyone reaching for Wake-on-LAN: a PS5 does not wake from a magic
|
||||
//! packet, on any port or broadcast address. DDP is the only network path.
|
||||
|
||||
use std::net::{SocketAddr, ToSocketAddrs, UdpSocket};
|
||||
use std::net::{Ipv4Addr, SocketAddr, ToSocketAddrs, UdpSocket};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
@@ -100,23 +100,152 @@ fn resolve(host: &str, port: u16) -> Result<SocketAddr> {
|
||||
/// may depend on it, so it is preserved rather than tidied.
|
||||
pub fn probe(host: &str, timeout: Duration) -> Result<DdpStatus> {
|
||||
let addr = resolve(host, DDP_PORT)?;
|
||||
let socket = UdpSocket::bind("0.0.0.0:0").map_err(|e| anyhow!("binding a socket: {e}"))?;
|
||||
socket.set_read_timeout(Some(timeout))?;
|
||||
socket.set_broadcast(true)?;
|
||||
|
||||
let pkt = format!("SRCH * HTTP/1.1\ndevice-discovery-protocol-version:{DDP_VERSION}\n\0");
|
||||
socket
|
||||
.send_to(pkt.as_bytes(), addr)
|
||||
.map_err(|e| anyhow!("sending SRCH to {addr}: {e}"))?;
|
||||
|
||||
let mut buf = [0u8; 4096];
|
||||
let (n, _from) = socket.recv_from(&mut buf).map_err(|e| {
|
||||
let sockets = send_every_way(pkt.as_bytes(), addr, true);
|
||||
if sockets.is_empty() {
|
||||
return Err(anyhow!("could not send SRCH to {addr} on any network"));
|
||||
}
|
||||
first_reply(&sockets, addr, timeout).ok_or_else(|| {
|
||||
anyhow!(
|
||||
"no reply from {addr}: {e}. A console only answers when \
|
||||
"no reply from {addr}. A console only answers when \
|
||||
Remote Play is enabled in its settings."
|
||||
)
|
||||
})?;
|
||||
parse_reply(&String::from_utf8_lossy(&buf[..n]))
|
||||
})
|
||||
}
|
||||
|
||||
/// Sends `pkt` to `addr` every way this machine can reach it: from the default route, and
|
||||
/// from each local interface on the console's subnet (unicast, plus that subnet's broadcast
|
||||
/// when `broadcast` or the unicast send failed). Returns the sockets that sent something.
|
||||
///
|
||||
/// Why: a machine on Wi-Fi and Ethernet at once routes by its default interface, and a PS5 in
|
||||
/// rest mode was measured not answering over Wi-Fi at all ("Host is down", no ARP reply) while
|
||||
/// answering 620 on the same LAN over Ethernet. Sending only by the default route made both
|
||||
/// consoles look switched off.
|
||||
fn send_every_way(pkt: &[u8], addr: SocketAddr, broadcast: bool) -> Vec<UdpSocket> {
|
||||
let mut out = Vec::new();
|
||||
if let Ok(s) = UdpSocket::bind("0.0.0.0:0") {
|
||||
if s.send_to(pkt, addr).is_ok() {
|
||||
out.push(s);
|
||||
}
|
||||
}
|
||||
if let SocketAddr::V4(v4) = addr {
|
||||
for (local, bcast) in local_ipv4_on_subnet_of(*v4.ip()) {
|
||||
let Ok(s) = UdpSocket::bind((local, 0)) else {
|
||||
continue;
|
||||
};
|
||||
let unicast = s.send_to(pkt, addr).is_ok();
|
||||
let mut sent = unicast;
|
||||
if broadcast || !unicast {
|
||||
let _ = s.set_broadcast(true);
|
||||
sent |= s.send_to(pkt, (bcast, DDP_PORT)).is_ok();
|
||||
}
|
||||
if sent {
|
||||
out.push(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Waits up to `timeout` for the console at `addr` to answer on any of `sockets`, and parses
|
||||
/// the first reply. Only the console's own reply counts, not another console's answer to a
|
||||
/// broadcast or a stray datagram.
|
||||
fn first_reply(sockets: &[UdpSocket], addr: SocketAddr, timeout: Duration) -> Option<DdpStatus> {
|
||||
let until = std::time::Instant::now() + timeout;
|
||||
for s in sockets {
|
||||
s.set_nonblocking(true).ok()?;
|
||||
}
|
||||
let mut buf = [0u8; 4096];
|
||||
loop {
|
||||
for s in sockets {
|
||||
while let Ok((n, from)) = s.recv_from(&mut buf) {
|
||||
if from.ip() == addr.ip() {
|
||||
if let Ok(st) = parse_reply(&String::from_utf8_lossy(&buf[..n])) {
|
||||
return Some(st);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if std::time::Instant::now() >= until {
|
||||
return None;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
|
||||
/// This machine's IPv4 addresses on the same subnet as `dst`, with that subnet's broadcast
|
||||
/// address, one per interface that is up. Empty where interfaces cannot be listed (Windows):
|
||||
/// the default route alone is then used, as before.
|
||||
#[cfg(unix)]
|
||||
fn local_ipv4_on_subnet_of(dst: Ipv4Addr) -> Vec<(Ipv4Addr, Ipv4Addr)> {
|
||||
let mut out = Vec::new();
|
||||
let mut head: *mut libc::ifaddrs = std::ptr::null_mut();
|
||||
// SAFETY: getifaddrs fills `head` with a list we only read and then free once.
|
||||
if unsafe { libc::getifaddrs(&mut head) } != 0 {
|
||||
return out;
|
||||
}
|
||||
let mut cur = head;
|
||||
while !cur.is_null() {
|
||||
// SAFETY: `cur` is a node of the list getifaddrs returned, not yet freed.
|
||||
let ifa = unsafe { &*cur };
|
||||
cur = ifa.ifa_next;
|
||||
let up = ifa.ifa_flags & (libc::IFF_UP as libc::c_uint) != 0;
|
||||
let loopback = ifa.ifa_flags & (libc::IFF_LOOPBACK as libc::c_uint) != 0;
|
||||
if !up || loopback || ifa.ifa_addr.is_null() || ifa.ifa_netmask.is_null() {
|
||||
continue;
|
||||
}
|
||||
// SAFETY: both pointers are non-null and point at sockaddrs owned by the list.
|
||||
let (fam, addr, mask) = unsafe {
|
||||
(
|
||||
(*ifa.ifa_addr).sa_family as i32,
|
||||
std::ptr::read_unaligned(ifa.ifa_addr as *const libc::sockaddr_in),
|
||||
std::ptr::read_unaligned(ifa.ifa_netmask as *const libc::sockaddr_in),
|
||||
)
|
||||
};
|
||||
if fam != libc::AF_INET {
|
||||
continue;
|
||||
}
|
||||
let local = Ipv4Addr::from(u32::from_be(addr.sin_addr.s_addr));
|
||||
let mask = Ipv4Addr::from(u32::from_be(mask.sin_addr.s_addr));
|
||||
if let Some(b) = same_subnet_broadcast(local, mask, dst) {
|
||||
if !out.iter().any(|(l, _)| *l == local) {
|
||||
out.push((local, b));
|
||||
}
|
||||
}
|
||||
}
|
||||
// SAFETY: `head` came from a successful getifaddrs and is freed exactly once.
|
||||
unsafe { libc::freeifaddrs(head) };
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn local_ipv4_on_subnet_of(_dst: Ipv4Addr) -> Vec<(Ipv4Addr, Ipv4Addr)> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// The broadcast address of `local`/`mask` when `dst` is on that subnet; `None` otherwise, or
|
||||
/// for a mask too wide or too narrow to be a LAN (/0, /31, /32).
|
||||
fn same_subnet_broadcast(local: Ipv4Addr, mask: Ipv4Addr, dst: Ipv4Addr) -> Option<Ipv4Addr> {
|
||||
let (l, m, d) = (u32::from(local), u32::from(mask), u32::from(dst));
|
||||
if m == 0 || m.count_zeros() < 2 || (l & m) != (d & m) || l == d {
|
||||
return None;
|
||||
}
|
||||
Some(Ipv4Addr::from(l | !m))
|
||||
}
|
||||
|
||||
/// `EHOSTDOWN` ("Host is down"): the console gave no ARP reply. std has no `ErrorKind` for it.
|
||||
/// Seen on a Mac whose default route was Wi-Fi while both consoles slept, so callers treat it
|
||||
/// like a timeout (ask DDP why), not as a broken helper.
|
||||
pub fn host_is_down(io: &std::io::Error) -> bool {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
io.raw_os_error() == Some(libc::EHOSTDOWN)
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let _ = io;
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// The DDP code of a console in rest mode (`HTTP/1.1 620 Server Standby`).
|
||||
@@ -160,29 +289,16 @@ pub const STANDBY_PROBE_SENDS: u32 = 2;
|
||||
/// failure they already have. Blocking.
|
||||
pub fn standby_probe(host: &str, budget: Duration, sends: u32) -> Option<u16> {
|
||||
let addr = resolve(host, DDP_PORT).ok()?;
|
||||
let socket = UdpSocket::bind("0.0.0.0:0").ok()?;
|
||||
let pkt = format!("SRCH * HTTP/1.1\ndevice-discovery-protocol-version:{DDP_VERSION}\n\0");
|
||||
let sends = sends.max(1);
|
||||
let slice = (budget / sends).max(Duration::from_millis(1));
|
||||
let mut buf = [0u8; 4096];
|
||||
for _ in 0..sends {
|
||||
socket.send_to(pkt.as_bytes(), addr).ok()?;
|
||||
let until = std::time::Instant::now() + slice;
|
||||
loop {
|
||||
let left = until.saturating_duration_since(std::time::Instant::now());
|
||||
if left.is_zero() || socket.set_read_timeout(Some(left)).is_err() {
|
||||
break;
|
||||
}
|
||||
match socket.recv_from(&mut buf) {
|
||||
// Only the console's own reply counts, not a stray datagram on the port.
|
||||
Ok((n, from)) if from.ip() == addr.ip() => {
|
||||
if let Ok(s) = parse_reply(&String::from_utf8_lossy(&buf[..n])) {
|
||||
return Some(s.code);
|
||||
}
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(_) => break,
|
||||
}
|
||||
let sockets = send_every_way(pkt.as_bytes(), addr, true);
|
||||
if sockets.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if let Some(s) = first_reply(&sockets, addr, slice) {
|
||||
return Some(s.code);
|
||||
}
|
||||
}
|
||||
None
|
||||
@@ -203,8 +319,6 @@ pub fn wake(host: &str, credential: &str) -> Result<()> {
|
||||
));
|
||||
}
|
||||
let addr = resolve(host, DDP_PORT)?;
|
||||
let socket = UdpSocket::bind("0.0.0.0:0").map_err(|e| anyhow!("binding a socket: {e}"))?;
|
||||
socket.set_broadcast(true)?;
|
||||
|
||||
// Byte-for-byte what chiaki-ng sends, which is the client verified to
|
||||
// wake these consoles. Two details matter and both were wrong before:
|
||||
@@ -217,9 +331,14 @@ pub fn wake(host: &str, credential: &str) -> Result<()> {
|
||||
user-credential:{}\ndevice-discovery-protocol-version:{DDP_VERSION}\n\0",
|
||||
credential.trim()
|
||||
);
|
||||
socket
|
||||
.send_to(pkt.as_bytes(), addr)
|
||||
.map_err(|e| anyhow!("sending WAKEUP to {addr}: {e}"))?;
|
||||
// Every way the console can be reached, like a SRCH (see `send_every_way`). The
|
||||
// credential goes to the subnet's broadcast only from an interface whose unicast send
|
||||
// failed: it stays off the rest of the LAN whenever it can.
|
||||
if send_every_way(pkt.as_bytes(), addr, false).is_empty() {
|
||||
return Err(anyhow!(
|
||||
"could not send WAKEUP to {addr}: no network on this computer reaches it"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -285,6 +404,54 @@ device-discovery-protocol-version:00030010\nsystem-version:09600004\n";
|
||||
assert!(parse_reply("hello there\n").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_console_subnet_picks_the_interface_and_its_broadcast() {
|
||||
let ip = |s: &str| s.parse::<Ipv4Addr>().unwrap();
|
||||
let m24 = ip("255.255.255.0");
|
||||
assert_eq!(
|
||||
same_subnet_broadcast(ip("192.168.86.199"), m24, ip("192.168.86.99")),
|
||||
Some(ip("192.168.86.255"))
|
||||
);
|
||||
// Another subnet, the console itself, and point-to-point masks are not routes to it.
|
||||
assert_eq!(
|
||||
same_subnet_broadcast(ip("10.0.0.2"), m24, ip("192.168.86.99")),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
same_subnet_broadcast(ip("192.168.86.99"), m24, ip("192.168.86.99")),
|
||||
None
|
||||
);
|
||||
for m in ["255.255.255.255", "255.255.255.254", "0.0.0.0"] {
|
||||
assert_eq!(
|
||||
same_subnet_broadcast(ip("192.168.86.1"), ip(m), ip("192.168.86.99")),
|
||||
None
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
same_subnet_broadcast(ip("10.1.2.3"), ip("255.255.0.0"), ip("10.1.200.4")),
|
||||
Some(ip("10.1.255.255"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_srch_reaches_a_local_responder_and_only_its_reply_counts() {
|
||||
// A fake console on loopback answers like a sleeping PS5.
|
||||
let console = UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||
let addr = console.local_addr().unwrap();
|
||||
let t = std::thread::spawn(move || {
|
||||
let mut b = [0u8; 512];
|
||||
let (_, from) = console.recv_from(&mut b).unwrap();
|
||||
console
|
||||
.send_to(b"HTTP/1.1 620 Server Standby\nhost-type:PS5\n", from)
|
||||
.unwrap();
|
||||
});
|
||||
let socks = send_every_way(b"SRCH * HTTP/1.1\n\0", addr, true);
|
||||
assert!(!socks.is_empty());
|
||||
let st = first_reply(&socks, addr, Duration::from_secs(2)).unwrap();
|
||||
assert_eq!(st.code, CODE_STANDBY);
|
||||
t.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn waking_without_a_credential_is_refused_up_front() {
|
||||
// The console silently ignores a credential-less WAKEUP, so refusing
|
||||
|
||||
Reference in new issue
Block a user