mirror of
https://github.com/Corfirean/coa-server-manager.git
synced 2026-11-11 03:51:01 +00:00
Let the person choose and keep the LAN address friends connect to
Sharing on the local network used to take whatever address the Manager found first, which is often a virtual adapter. The Friends page now lists the usable IPv4 addresses of the computer (the automatic one marked) and accepts a custom one. The choice is kept independently of the sharing mode, so it survives restarts and switching to other modes. A chosen address that is not available is reported instead of silently switching to another adapter. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3f700ee8f4
commit
8490dc9983
14 files changed
+292
-17
No files matched your search
Generated
+11
@@ -499,6 +499,7 @@ dependencies = [
|
||||
"ed25519-dalek",
|
||||
"fs4",
|
||||
"hex",
|
||||
"if-addrs",
|
||||
"num-bigint",
|
||||
"rand_core",
|
||||
"reqwest 0.12.28",
|
||||
@@ -1913,6 +1914,16 @@ dependencies = [
|
||||
"icu_properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "if-addrs"
|
||||
version = "0.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0a05c691e1fae256cf7013d99dad472dc52d5543322761f83ec8d47eab40d2b"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "1.9.3"
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
area: manager
|
||||
type: fixed
|
||||
audience: admins
|
||||
title: Choose and remember the local network address friends use to connect
|
||||
---
|
||||
Select an adapter or custom IPv4 address when sharing on LAN. Your choice survives restarts and mode changes; unavailable addresses show a warning instead of silently switching adapters.
|
||||
@@ -25,6 +25,7 @@ zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
sha1 = "0.10"
|
||||
tracing-subscriber = { version = "0.3", default-features = false, features = ["fmt", "std"] }
|
||||
tempfile = "3"
|
||||
if-addrs = "0.15"
|
||||
|
||||
[dev-dependencies]
|
||||
rand_core = { version = "0.6", features = ["getrandom"] }
|
||||
|
||||
@@ -31,11 +31,25 @@ pub struct Settings {
|
||||
pub mode: Mode,
|
||||
/// The address friends type into their client for this mode.
|
||||
pub host: Option<String>,
|
||||
/// Independent of the effective host of the current mode. None means automatic LAN detection.
|
||||
#[serde(default)]
|
||||
pub lan_address_override: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Settings { mode: Mode::Local, host: None }
|
||||
Settings { mode: Mode::Local, host: None, lan_address_override: None }
|
||||
}
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
/// Only LAN application changes the LAN preference. Other modes keep it for the next LAN session.
|
||||
pub fn select_mode(&mut self, mode: Mode, host: String, lan_override: Option<String>) {
|
||||
if mode == Mode::Lan {
|
||||
self.lan_address_override = lan_override;
|
||||
}
|
||||
self.mode = mode;
|
||||
self.host = Some(host);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,7 +247,7 @@ CoA.AllowRemoteClients = 0
|
||||
fn a_shared_server_gets_its_bind_address_back_before_start_and_a_local_one_is_left_alone() {
|
||||
let (_d, root, meta) = setup();
|
||||
assert!(!ensure_bind(&root, &meta).unwrap(), "local mode: nothing to do");
|
||||
save(&meta, &Settings { mode: Mode::Private, host: Some("100.64.1.2".into()) }).unwrap();
|
||||
save(&meta, &Settings { mode: Mode::Private, host: Some("100.64.1.2".into()), ..Settings::default() }).unwrap();
|
||||
assert!(ensure_bind(&root, &meta).unwrap(), "templates were reset to 127.0.0.1 -> reopened");
|
||||
assert!(bind_is_open(&root));
|
||||
assert!(!ensure_bind(&root, &meta).unwrap(), "already open: idempotent");
|
||||
@@ -243,11 +257,39 @@ CoA.AllowRemoteClients = 0
|
||||
fn settings_round_trip_and_default_to_local() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
assert_eq!(load(d.path()).mode, Mode::Local);
|
||||
save(d.path(), &Settings { mode: Mode::Private, host: Some("100.64.1.2".into()) }).unwrap();
|
||||
save(d.path(), &Settings { mode: Mode::Private, host: Some("100.64.1.2".into()), ..Settings::default() }).unwrap();
|
||||
let s = load(d.path());
|
||||
assert_eq!((s.mode, s.host.as_deref()), (Mode::Private, Some("100.64.1.2")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_settings_load_without_migrating_effective_host_to_override() {
|
||||
let s: Settings = serde_json::from_str(r#"{"mode":"lan","host":"192.168.1.50"}"#).unwrap();
|
||||
assert_eq!(s.mode, Mode::Lan);
|
||||
assert_eq!(s.host.as_deref(), Some("192.168.1.50"));
|
||||
assert_eq!(s.lan_address_override, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lan_preference_survives_save_load_and_every_other_mode_and_can_be_cleared() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
let mut s = Settings::default();
|
||||
s.select_mode(Mode::Lan, "192.168.1.50".into(), Some("192.168.1.50".into()));
|
||||
for (mode, host) in [(Mode::Private, "100.101.20.5"), (Mode::Direct, "203.0.113.9"), (Mode::Local, "127.0.0.1")] {
|
||||
s.select_mode(mode, host.into(), None);
|
||||
save(d.path(), &s).unwrap();
|
||||
s = load(d.path());
|
||||
assert_eq!(s.lan_address_override.as_deref(), Some("192.168.1.50"));
|
||||
assert_eq!(s.host.as_deref(), Some(host));
|
||||
let lan = crate::net::resolve_lan_host(s.lan_address_override.as_deref(), Some("192.168.0.169".parse().unwrap())).unwrap();
|
||||
s.select_mode(Mode::Lan, lan, s.lan_address_override.clone());
|
||||
assert_eq!(s.host.as_deref(), Some("192.168.1.50"));
|
||||
}
|
||||
s.select_mode(Mode::Lan, "192.168.0.169".into(), None);
|
||||
save(d.path(), &s).unwrap();
|
||||
assert_eq!(load(d.path()).lan_address_override, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn friend_package_contains_only_whitelisted_files() {
|
||||
let (d, root, _m) = setup();
|
||||
@@ -274,4 +316,23 @@ CoA.AllowRemoteClients = 0
|
||||
}
|
||||
assert!(make_friend_package(&root, "bad host; x", false, &d.path().join("x.zip")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_lan_host_survives_startup_bind_restoration_and_drives_friend_package() {
|
||||
let (d, root, meta) = setup();
|
||||
let mut s = Settings::default();
|
||||
s.select_mode(Mode::Lan, "192.168.1.50".into(), Some("192.168.1.50".into()));
|
||||
save(&meta, &s).unwrap();
|
||||
assert!(ensure_bind(&root, &meta).unwrap());
|
||||
assert!(bind_is_open(&root));
|
||||
let restored = load(&meta);
|
||||
assert_eq!(restored.host.as_deref(), Some("192.168.1.50"));
|
||||
assert_eq!(restored.lan_address_override.as_deref(), Some("192.168.1.50"));
|
||||
let package = d.path().join("manual-lan.zip");
|
||||
make_friend_package(&root, restored.host.as_deref().unwrap(), false, &package).unwrap();
|
||||
let mut zip = zip::ZipArchive::new(fs::File::open(package).unwrap()).unwrap();
|
||||
let mut realm = String::new();
|
||||
std::io::Read::read_to_string(&mut zip.by_name("realmlist.wtf").unwrap(), &mut realm).unwrap();
|
||||
assert_eq!(realm, "set realmlist 192.168.1.50\r\n");
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,51 @@ pub fn lan_ip() -> Option<Ipv4Addr> {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct LanAddress {
|
||||
pub interface: String,
|
||||
pub address: String,
|
||||
pub is_default: bool,
|
||||
}
|
||||
|
||||
/// Suitable unicast IPv4 addresses; shared carrier/Tailscale space belongs to Private mode.
|
||||
pub fn is_lan_address(ip: Ipv4Addr) -> bool {
|
||||
!ip.is_unspecified() && !ip.is_loopback() && !ip.is_link_local()
|
||||
&& !ip.is_multicast() && !ip.is_broadcast() && ip.octets()[0] != 0
|
||||
&& ip.octets()[0] < 240 && !is_cgnat_range(ip)
|
||||
}
|
||||
|
||||
pub fn validate_lan_address(address: &str) -> Result<Ipv4Addr> {
|
||||
address.parse::<Ipv4Addr>().ok().filter(|ip| is_lan_address(*ip))
|
||||
.ok_or_else(|| Error::Invalid("Invalid IPv4 address for local network.".into()))
|
||||
}
|
||||
|
||||
/// An absent/disconnected manual address is intentional: never fall back to another adapter.
|
||||
pub fn resolve_lan_host(manual: Option<&str>, automatic: Option<Ipv4Addr>) -> Result<String> {
|
||||
match manual {
|
||||
Some(address) => validate_lan_address(address).map(|ip| ip.to_string()),
|
||||
None => automatic.filter(|ip| is_lan_address(*ip)).map(|ip| ip.to_string())
|
||||
.ok_or_else(|| Error::Invalid("This computer has no usable local network address.".into())),
|
||||
}
|
||||
}
|
||||
|
||||
fn lan_candidates(rows: impl IntoIterator<Item = (String, Ipv4Addr)>, automatic: Option<Ipv4Addr>) -> Vec<LanAddress> {
|
||||
let mut rows: Vec<_> = rows.into_iter().filter(|(_, ip)| is_lan_address(*ip)).collect();
|
||||
rows.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
rows.into_iter().filter(|(_, ip)| seen.insert(*ip)).map(|(interface, ip)| LanAddress {
|
||||
interface, address: ip.to_string(), is_default: Some(ip) == automatic,
|
||||
}).collect()
|
||||
}
|
||||
|
||||
/// Native OS enumeration (GetAdaptersAddresses on Windows, getifaddrs on Unix).
|
||||
pub fn lan_addresses(automatic: Option<Ipv4Addr>) -> Result<Vec<LanAddress>> {
|
||||
let interfaces = if_addrs::get_if_addrs()?;
|
||||
Ok(lan_candidates(interfaces.into_iter().filter(|i| i.is_oper_up()).filter_map(|i| {
|
||||
match i.ip() { IpAddr::V4(ip) => Some((i.name, ip)), _ => None }
|
||||
}), automatic))
|
||||
}
|
||||
|
||||
pub fn is_private(ip: Ipv4Addr) -> bool {
|
||||
ip.is_private() || ip.is_loopback() || ip.is_link_local()
|
||||
}
|
||||
@@ -132,6 +177,42 @@ mod tests {
|
||||
s.parse().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lan_validation_and_resolution_never_replace_manual_addresses() {
|
||||
for good in ["192.168.1.20", "10.0.0.5", "172.16.2.4"] {
|
||||
assert!(validate_lan_address(good).is_ok());
|
||||
}
|
||||
for bad in ["abc", "999.1.1.1", "0.0.0.0", "0.1.2.3", "127.0.0.1", "127.9.1.2", "169.254.10.20", "100.101.20.5", "224.0.0.1", "255.255.255.255", "240.0.0.1", "192.168.1.1'; DROP TABLE realmlist"] {
|
||||
assert!(validate_lan_address(bad).is_err(), "{bad}");
|
||||
}
|
||||
let automatic = Some(ip("192.168.0.169"));
|
||||
assert_eq!(resolve_lan_host(None, automatic).unwrap(), "192.168.0.169");
|
||||
assert_eq!(resolve_lan_host(Some("192.168.1.50"), automatic).unwrap(), "192.168.1.50");
|
||||
assert_eq!(resolve_lan_host(Some("192.168.1.50"), None).unwrap(), "192.168.1.50");
|
||||
assert!(resolve_lan_host(Some("invalid"), automatic).is_err());
|
||||
assert!(resolve_lan_host(None, None).is_err());
|
||||
assert!(resolve_lan_host(None, Some(ip("169.254.1.2"))).is_err());
|
||||
assert!(resolve_lan_host(None, Some(ip("100.100.1.2"))).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candidates_are_filtered_deduplicated_deterministic_and_default_marked() {
|
||||
let rows = vec![
|
||||
("Wi-Fi", "192.168.0.25"), ("Ethernet 2", "192.168.1.50"),
|
||||
("Ethernet", "192.168.0.169"), ("Duplicate", "192.168.1.50"),
|
||||
("Loopback", "127.0.0.2"), ("APIPA", "169.254.1.2"),
|
||||
("Tailscale", "100.101.20.5"), ("Empty", "0.0.0.0"),
|
||||
("Hyper-V Virtual", "10.0.0.5"),
|
||||
];
|
||||
let make = |rows: Vec<(&str, &str)>| lan_candidates(rows.into_iter().map(|(name, address)| (name.into(), ip(address))), Some(ip("192.168.0.169")));
|
||||
let result = make(rows.clone());
|
||||
assert_eq!(result.len(), 4);
|
||||
assert_eq!(result.iter().filter(|a| a.is_default).count(), 1);
|
||||
assert!(result.iter().any(|a| a.interface == "Hyper-V Virtual"));
|
||||
assert!(result.iter().any(|a| a.is_default && a.address == "192.168.0.169"));
|
||||
assert_eq!(result, make(rows.into_iter().rev().collect()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn address_classes() {
|
||||
assert!(is_private(ip("192.168.1.5")) && is_private(ip("10.0.0.2")) && is_private(ip("172.16.4.4")) && is_private(ip("127.0.0.1")));
|
||||
|
||||
+10
-4
@@ -1249,6 +1249,7 @@ async fn play(state: State<'_, AppState>, id: String) -> std::result::Result<Dri
|
||||
struct FriendsStatus {
|
||||
settings: coa_core::friends::Settings,
|
||||
lan_ip: Option<String>,
|
||||
lan_addresses: Vec<coa_core::net::LanAddress>,
|
||||
exposure: Vec<coa_core::net::Exposure>,
|
||||
/// The configuration lets other computers reach the login and world servers.
|
||||
servers_open: bool,
|
||||
@@ -1328,9 +1329,11 @@ async fn friends_status(state: State<'_, AppState>, id: String) -> std::result::
|
||||
blocking(move || {
|
||||
let meta = meta_dir(&root)?;
|
||||
let ports = layout::read_ports(&root);
|
||||
let automatic = coa_core::net::lan_ip().filter(|ip| coa_core::net::is_lan_address(*ip));
|
||||
Ok(FriendsStatus {
|
||||
settings: coa_core::friends::load(&meta),
|
||||
lan_ip: coa_core::net::lan_ip().map(|a| a.to_string()),
|
||||
lan_ip: automatic.map(|a| a.to_string()),
|
||||
lan_addresses: coa_core::net::lan_addresses(automatic).unwrap_or_default(),
|
||||
exposure: coa_core::net::exposure(&ports),
|
||||
servers_open: coa_core::friends::bind_is_open(&root),
|
||||
firewall: coa_core::firewall::status(),
|
||||
@@ -1383,9 +1386,10 @@ async fn friends_enable(
|
||||
id: String,
|
||||
mode: coa_core::friends::Mode,
|
||||
host: Option<String>,
|
||||
lan_address_override: Option<String>,
|
||||
use_upnp: bool,
|
||||
) -> std::result::Result<FriendsResult, UiError> {
|
||||
use coa_core::friends::{self, Mode, Settings};
|
||||
use coa_core::friends::{self, Mode};
|
||||
let root = path_of(&state, &id)?;
|
||||
let _guard = BusyGuard::acquire(&state, &id)?;
|
||||
blocking(move || {
|
||||
@@ -1396,7 +1400,7 @@ async fn friends_enable(
|
||||
let secondary = realms.secondary_world_port.filter(|_| realms.simultaneous);
|
||||
let host = match mode {
|
||||
Mode::Local => "127.0.0.1".to_string(),
|
||||
Mode::Lan => coa_core::net::lan_ip().ok_or_else(|| Error::Invalid("This computer has no network address.".into()))?.to_string(),
|
||||
Mode::Lan => coa_core::net::resolve_lan_host(lan_address_override.as_deref(), coa_core::net::lan_ip())?,
|
||||
Mode::Direct => host.filter(|h| !h.is_empty()).ok_or_else(|| Error::Invalid("Check your connection first to learn your public address.".into()))?,
|
||||
Mode::Private => coa_core::net::tailscale().ip.ok_or_else(|| Error::Invalid("Tailscale is not connected. Install it, sign in, then try again.".into()))?,
|
||||
};
|
||||
@@ -1417,7 +1421,9 @@ async fn friends_enable(
|
||||
None => note = Some("Your router does not support automatic setup; forward the two game ports by hand or use the private network.".to_string()),
|
||||
}
|
||||
}
|
||||
friends::save(&meta, &Settings { mode, host: Some(host.clone()) })?;
|
||||
let mut settings = friends::load(&meta);
|
||||
settings.select_mode(mode, host.clone(), lan_address_override);
|
||||
friends::save(&meta, &settings)?;
|
||||
let running = coa_core::process::observe(&root, &ports).world.state == coa_core::process::ServiceState::Running;
|
||||
if running {
|
||||
friends::apply_realm_address(&root, &host)?;
|
||||
|
||||
@@ -816,6 +816,14 @@ const de: Partial<Record<keyof typeof en, string>> = {
|
||||
"upd.current": "Du bist auf dem neuesten Stand (Version {v}).",
|
||||
"client.job.checking": "Client wird geprüft…",
|
||||
"client.job.updating": "Client wird aktualisiert",
|
||||
"fr.lan.address": "Netzwerkadresse",
|
||||
"fr.lan.automatic": "Automatisch",
|
||||
"fr.lan.default": "Standard",
|
||||
"fr.lan.custom": "Eigene Adresse…",
|
||||
"fr.lan.customIpv4": "Eigene IPv4-Adresse",
|
||||
"fr.lan.unavailable": "Ausgewählte Adresse nicht verfügbar",
|
||||
"fr.lan.unavailableText": "Die ausgewählte Adresse ist derzeit keinem erkannten lokalen Netzwerkadapter zugewiesen. Wählen Sie eine andere Adresse oder Automatisch, oder behalten Sie diese Adresse bewusst bei.",
|
||||
"fr.lan.invalid": "Ungültige IPv4-Adresse",
|
||||
};
|
||||
|
||||
export default de;
|
||||
@@ -837,6 +837,14 @@ const en = {
|
||||
"upd.current": "You are up to date (version {v}).",
|
||||
"client.job.checking": "Checking the client…",
|
||||
"client.job.updating": "Updating the client",
|
||||
"fr.lan.address": "Network address",
|
||||
"fr.lan.automatic": "Automatic",
|
||||
"fr.lan.default": "Default",
|
||||
"fr.lan.custom": "Custom address…",
|
||||
"fr.lan.customIpv4": "Custom IPv4 address",
|
||||
"fr.lan.unavailable": "Selected address unavailable",
|
||||
"fr.lan.unavailableText": "The selected address is not currently assigned to a detected local interface. Choose another address or switch to Automatic, or keep this address intentionally.",
|
||||
"fr.lan.invalid": "Invalid IPv4 address",
|
||||
} as const;
|
||||
|
||||
export default en;
|
||||
@@ -816,6 +816,14 @@ const es: Partial<Record<keyof typeof en, string>> = {
|
||||
"upd.current": "Estás al día (versión {v}).",
|
||||
"client.job.checking": "Comprobando el cliente…",
|
||||
"client.job.updating": "Actualizando el cliente",
|
||||
"fr.lan.address": "Dirección de red",
|
||||
"fr.lan.automatic": "Automática",
|
||||
"fr.lan.default": "Predeterminada",
|
||||
"fr.lan.custom": "Dirección personalizada…",
|
||||
"fr.lan.customIpv4": "Dirección IPv4 personalizada",
|
||||
"fr.lan.unavailable": "Dirección seleccionada no disponible",
|
||||
"fr.lan.unavailableText": "La dirección seleccionada no está asignada actualmente a ninguna interfaz local detectada. Elige otra dirección o Automática, o conserva esta dirección si así lo deseas.",
|
||||
"fr.lan.invalid": "Dirección IPv4 no válida",
|
||||
};
|
||||
|
||||
export default es;
|
||||
@@ -816,6 +816,14 @@ const fr: Partial<Record<keyof typeof en, string>> = {
|
||||
"upd.current": "Vous êtes à jour (version {v}).",
|
||||
"client.job.checking": "Vérification du client…",
|
||||
"client.job.updating": "Mise à jour du client",
|
||||
"fr.lan.address": "Adresse réseau",
|
||||
"fr.lan.automatic": "Automatique",
|
||||
"fr.lan.default": "Par défaut",
|
||||
"fr.lan.custom": "Adresse personnalisée…",
|
||||
"fr.lan.customIpv4": "Adresse IPv4 personnalisée",
|
||||
"fr.lan.unavailable": "Adresse sélectionnée indisponible",
|
||||
"fr.lan.unavailableText": "Cette adresse n’est actuellement attribuée à aucune interface locale détectée. Choisissez une autre adresse ou le mode Automatique, ou conservez volontairement cette adresse.",
|
||||
"fr.lan.invalid": "Adresse IPv4 non valide",
|
||||
};
|
||||
|
||||
export default fr;
|
||||
@@ -835,6 +835,14 @@ const ru: Partial<Record<keyof typeof en, string>> = {
|
||||
"upd.current": "У вас последняя версия ({v}).",
|
||||
"client.job.checking": "Проверка клиента…",
|
||||
"client.job.updating": "Обновление клиента",
|
||||
"fr.lan.address": "Сетевой адрес",
|
||||
"fr.lan.automatic": "Автоматически",
|
||||
"fr.lan.default": "По умолчанию",
|
||||
"fr.lan.custom": "Другой адрес…",
|
||||
"fr.lan.customIpv4": "Свой IPv4-адрес",
|
||||
"fr.lan.unavailable": "Выбранный адрес недоступен",
|
||||
"fr.lan.unavailableText": "Выбранный адрес сейчас не назначен обнаруженному сетевому интерфейсу. Выберите другой адрес, переключитесь на автоматический выбор или намеренно сохраните этот адрес.",
|
||||
"fr.lan.invalid": "Некорректный IPv4-адрес",
|
||||
};
|
||||
|
||||
export default ru;
|
||||
@@ -818,6 +818,14 @@ const zh = {
|
||||
"client.job.updating": "正在更新游戏客户端",
|
||||
"err.docker_unavailable.title": "Docker 不可用",
|
||||
"err.docker_unavailable.message": "此服务器通过 Docker 运行。请安装并启动 Docker,确保当前用户有权使用它,然后重试。",
|
||||
"fr.lan.address": "网络地址",
|
||||
"fr.lan.automatic": "自动",
|
||||
"fr.lan.default": "默认",
|
||||
"fr.lan.custom": "自定义地址…",
|
||||
"fr.lan.customIpv4": "自定义 IPv4 地址",
|
||||
"fr.lan.unavailable": "所选地址不可用",
|
||||
"fr.lan.unavailableText": "所选地址目前未分配给检测到的本地网络接口。请选择其他地址、切换到自动,或有意保留此地址。",
|
||||
"fr.lan.invalid": "无效的 IPv4 地址",
|
||||
} as const;
|
||||
|
||||
export default zh;
|
||||
+10
-3
@@ -348,9 +348,16 @@ export interface ClientDownloadCheck {
|
||||
|
||||
export type FriendsMode = "local" | "lan" | "direct" | "private";
|
||||
|
||||
export interface LanAddress {
|
||||
interface: string;
|
||||
address: string;
|
||||
is_default: boolean;
|
||||
}
|
||||
|
||||
export interface FriendsStatus {
|
||||
settings: { mode: FriendsMode; host: string | null };
|
||||
settings: { mode: FriendsMode; host: string | null; lan_address_override: string | null };
|
||||
lan_ip: string | null;
|
||||
lan_addresses: LanAddress[];
|
||||
exposure: { port: number; what: string; reachable_from_network: boolean; listening: boolean }[];
|
||||
servers_open: boolean;
|
||||
firewall: { auth: boolean; world: boolean };
|
||||
@@ -456,8 +463,8 @@ export const api = {
|
||||
play: (id: string) => invoke<DriverOutcome>("play", { id }),
|
||||
friendsStatus: (id: string) => invoke<FriendsStatus>("friends_status", { id }),
|
||||
friendsCheckInternet: () => invoke<InternetCheck>("friends_check_internet"),
|
||||
friendsEnable: (id: string, mode: FriendsMode, host?: string, useUpnp = false) =>
|
||||
invoke<{ host: string; restart_required: boolean; note: string | null }>("friends_enable", { id, mode, host: host ?? null, useUpnp }),
|
||||
friendsEnable: (id: string, mode: FriendsMode, host?: string, useUpnp = false, lanAddressOverride: string | null = null) =>
|
||||
invoke<{ host: string; restart_required: boolean; note: string | null }>("friends_enable", { id, mode, host: host ?? null, useUpnp, lanAddressOverride }),
|
||||
friendsPackage: (id: string) => invoke<string>("friends_package", { id }),
|
||||
runDiagnostics: (id: string) => invoke<{ checks: DiagCheck[]; problems: number }>("run_diagnostics", { id }),
|
||||
verifyFiles: (id: string) => invoke<{ path: string; kind: "missing" | "changed" }[]>("verify_files", { id }),
|
||||
|
||||
@@ -5,6 +5,15 @@ import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { useHuman, useT, type Key } from "@/i18n";
|
||||
|
||||
/** Convenience only; Rust validates again before applying or saving. */
|
||||
function validLanIpv4(value: string): boolean {
|
||||
if (!/^(0|[1-9]\d{0,2})(\.(0|[1-9]\d{0,2})){3}$/.test(value)) return false;
|
||||
const octets = value.split(".").map(Number);
|
||||
const [a, b] = octets;
|
||||
return octets.every((n) => n <= 255) && a > 0 && a < 224 && a !== 127
|
||||
&& !(a === 169 && b === 254) && !(a === 100 && b >= 64 && b <= 127);
|
||||
}
|
||||
|
||||
function Line({ ok, children, warn }: { ok: boolean; warn?: boolean; children: React.ReactNode }) {
|
||||
const t = useT();
|
||||
return (
|
||||
@@ -81,11 +90,28 @@ export function FriendsPage({ serverId }: { serverId: string }) {
|
||||
const [note, setNote] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [needsRestart, setNeedsRestart] = useState(false);
|
||||
const [lanChoice, setLanChoice] = useState("automatic");
|
||||
const [customLan, setCustomLan] = useState("");
|
||||
|
||||
const refresh = useCallback(() => api.friendsStatus(serverId).then(setSt).catch((e) => setError(asUiError(e))), [serverId]);
|
||||
const refresh = useCallback(() => api.friendsStatus(serverId).then((status) => {
|
||||
setSt(status);
|
||||
}).catch((e) => setError(asUiError(e))), [serverId]);
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
let cancelled = false;
|
||||
setSt(null);
|
||||
setNet(null);
|
||||
void api.friendsStatus(serverId).then((status) => {
|
||||
if (cancelled) return;
|
||||
const saved = status.settings.lan_address_override;
|
||||
setCustomLan(saved ?? "");
|
||||
setLanChoice(saved === null ? "automatic" : status.lan_addresses.some((a) => a.address === saved) ? saved : "custom");
|
||||
setSt(status);
|
||||
}).catch((e) => { if (!cancelled) setError(asUiError(e)); });
|
||||
return () => { cancelled = true; };
|
||||
}, [serverId]);
|
||||
|
||||
const lanOverride = lanChoice === "automatic" ? null : lanChoice === "custom" ? customLan.trim() : lanChoice;
|
||||
const lanValid = lanOverride === null || validLanIpv4(lanOverride);
|
||||
|
||||
async function run(label: string, fn: () => Promise<string | void>) {
|
||||
setBusy(label);
|
||||
@@ -109,7 +135,12 @@ export function FriendsPage({ serverId }: { serverId: string }) {
|
||||
|
||||
const enable = (mode: FriendsMode) =>
|
||||
run(`enable-${mode}`, async () => {
|
||||
const r = await api.friendsEnable(serverId, mode, net?.public_ip ?? undefined, mode === "direct" && !!net?.router_found);
|
||||
const r = await api.friendsEnable(
|
||||
serverId, mode,
|
||||
mode === "direct" ? net?.public_ip ?? undefined : undefined,
|
||||
mode === "direct" && !!net?.router_found,
|
||||
mode === "lan" ? lanOverride : null,
|
||||
);
|
||||
setNeedsRestart(r.restart_required);
|
||||
return [r.restart_required ? t("fr.restartToApply") : null, r.note ? (r.note.startsWith("Your router was asked") ? t("fr.note.forwarded") : r.note.startsWith("Your router does not support") ? t("fr.note.manual") : r.note) : null].filter(Boolean).join(" ") || t("fr.ready");
|
||||
});
|
||||
@@ -119,6 +150,8 @@ export function FriendsPage({ serverId }: { serverId: string }) {
|
||||
const cur = st.settings;
|
||||
const exposed = st.exposure.filter((e) => (e.what === "database" || e.what === "server console") && e.reachable_from_network);
|
||||
const connection = cur.mode === "local" ? null : `set realmlist ${cur.host}`;
|
||||
const selectedLan = lanOverride ?? st.lan_ip;
|
||||
const lanUnavailable = lanOverride !== null && lanValid && !st.lan_addresses.some((a) => a.address === lanOverride);
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl">
|
||||
@@ -157,8 +190,28 @@ export function FriendsPage({ serverId }: { serverId: string }) {
|
||||
|
||||
{m.id === "lan" && (
|
||||
<>
|
||||
<p className="mt-2 text-sm">{t("fr.yourLan")} <b className="selectable">{st.lan_ip ?? t("fr.unknown")}</b></p>
|
||||
{st.lan_ip && cur.mode !== m.id && <RealmlistLine host={st.lan_ip} />}
|
||||
<label htmlFor="lan-address" className="mt-3 block text-sm">{t("fr.lan.address")}</label>
|
||||
<select id="lan-address" value={lanChoice} disabled={!!busy}
|
||||
className="mt-1 w-full rounded-md border border-line bg-bg px-3 py-2 text-sm outline-none focus:border-gold"
|
||||
onChange={(e) => {
|
||||
if (e.target.value === "custom" && lanOverride) setCustomLan(lanOverride);
|
||||
setLanChoice(e.target.value);
|
||||
}}>
|
||||
<option value="automatic">{t("fr.lan.automatic")} — {st.lan_ip ?? t("fr.unknown")}</option>
|
||||
{st.lan_addresses.map((a) => <option key={a.address} value={a.address}>{a.interface} — {a.address}{a.is_default ? ` (${t("fr.lan.default")})` : ""}</option>)}
|
||||
{lanChoice !== "automatic" && lanChoice !== "custom" && !st.lan_addresses.some((a) => a.address === lanChoice) && <option value={lanChoice}>{lanChoice}</option>}
|
||||
<option value="custom">{t("fr.lan.custom")}</option>
|
||||
</select>
|
||||
{lanChoice === "custom" && <>
|
||||
<label htmlFor="lan-custom" className="mt-2 block text-sm">{t("fr.lan.customIpv4")}</label>
|
||||
<input id="lan-custom" value={customLan} disabled={!!busy} spellCheck={false} inputMode="decimal"
|
||||
aria-invalid={!lanValid} aria-describedby={!lanValid ? "lan-invalid" : undefined}
|
||||
className="mt-1 w-full rounded-md border border-line bg-bg px-3 py-2 text-sm outline-none focus:border-gold"
|
||||
onChange={(e) => setCustomLan(e.target.value)} />
|
||||
</>}
|
||||
{!lanValid && <p id="lan-invalid" className="mt-2 text-sm text-bad" role="alert">{t("fr.lan.invalid")}</p>}
|
||||
{lanUnavailable && <p className="mt-2 text-sm text-warn" role="status"><b>{t("fr.lan.unavailable")}</b> {t("fr.lan.unavailableText")}</p>}
|
||||
{selectedLan && lanValid && <RealmlistLine host={selectedLan} />}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -221,7 +274,7 @@ export function FriendsPage({ serverId }: { serverId: string }) {
|
||||
<Button
|
||||
size="sm"
|
||||
variant={m.id === "private" && net?.reachability === "cgnat" ? "primary" : "secondary"}
|
||||
disabled={!!busy || (m.id === "direct" && !net?.public_ip) || (m.id === "private" && !st.tailscale.connected)}
|
||||
disabled={!!busy || (m.id === "lan" && (!lanValid || !selectedLan)) || (m.id === "direct" && !net?.public_ip) || (m.id === "private" && !st.tailscale.connected)}
|
||||
onClick={() => void enable(m.id)}
|
||||
title={m.id === "direct" && !net?.public_ip ? t("fr.checkFirst") : undefined}
|
||||
>
|
||||
|
||||
Reference in new issue
Block a user