From 3336a085430aa3649979ccb4c291e260ccb21dab Mon Sep 17 00:00:00 2001 From: Mute Date: Thu, 13 Aug 2026 08:08:05 +0000 Subject: [PATCH] announce server hostname in MANIFEST, display in client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server reads its hostname via gethostname(2) and includes it as a length-prefixed UTF-8 string at the start of the MANIFEST payload. Client displays 'Server: ' in a label above the upstream list. Both sides updated for the new MANIFEST format: [hostname_len:1][hostname:N][entries...] Protocol version unchanged (still 1); MANIFEST payload layout change is backward-incompatible but both sides ship together. --- PROTOCOL.md | 16 +++++++++--- gatunad/src/frame.rs | 35 ++++++++++++++++++-------- gatunad/src/main.rs | 12 ++++++--- gatunad/src/upstream.rs | 16 +++++++++++- win/gatuna-client/Frame.cs | 39 ++++++++++++++++++++--------- win/gatuna-client/MainForm.cs | 20 ++++++++++++--- win/gatuna-client/SessionManager.cs | 9 ++++--- 7 files changed, 110 insertions(+), 37 deletions(-) diff --git a/PROTOCOL.md b/PROTOCOL.md index 3982592..ed00c28 100644 --- a/PROTOCOL.md +++ b/PROTOCOL.md @@ -59,18 +59,27 @@ Reserved (unimplemented in v1; parse returns Err, encode unimplemented): ### MANIFEST payload -Variable-length entries, parsed sequentially until the payload is consumed. +A hostname prefix followed by variable-length entries, parsed sequentially +until the payload is consumed. ``` 0 1 2 3 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 -+---------------+---------------+-------------------------------+ ++---------------+-----------------------------------------------+ +| hostname_len | hostname (UTF-8, hostname_len bytes) ... | ++---------------+-----------------------------------------------+ | id | proto | port (big-endian) | +---------------+---------------+-------------------------------+ | label_len | label (UTF-8, label_len bytes) ... | +---------------+-----------------------------------------------+ +| ... repeated ... | ++---------------------------------------------------------------+ ``` +- **hostname_len** (u8): length in bytes of the server's hostname. `0` is valid + (unknown hostname). +- **hostname** (`hostname_len` bytes, UTF-8): the server's hostname, read via + `gethostname(2)` at startup. Maximum 255 bytes. - **id** (u8): upstream identifier (1-based positional index from `gatunad` cmdline). - **proto** (u8): `1 = TCP`, `2 = UDP` (reserved; not emitted in v1). @@ -80,7 +89,8 @@ Variable-length entries, parsed sequentially until the payload is consumed. - **label** (`label_len` bytes, UTF-8): human-readable name for the upstream, taken from the `PORT[:label]` cmdline argument. Maximum 255 bytes. -To parse: read the 5-byte fixed prefix, then `label_len` bytes, and repeat until +To parse: read `hostname_len`, then `hostname_len` bytes of hostname, then read +5-byte fixed entry prefixes + `label_len` bytes of label each, repeating until the payload is exhausted. The number of entries is not carried explicitly. ### OPEN payload diff --git a/gatunad/src/frame.rs b/gatunad/src/frame.rs index 5ab5c95..428ee50 100644 --- a/gatunad/src/frame.rs +++ b/gatunad/src/frame.rs @@ -39,7 +39,7 @@ pub struct UpstreamEntry { #[derive(Clone, Debug)] pub enum Frame { Discover, - Manifest(Vec), + Manifest { hostname: String, entries: Vec }, Open { upstream_id: u8 }, OpenAck { session_id: u32, upstream_id: u8 }, OpenNak { upstream_id: u8, reason: u8 }, @@ -94,8 +94,12 @@ impl Frame { pub fn encode(&self) -> Vec { match self { Frame::Discover => build(TYPE_DISCOVER, 0, Vec::new()), - Frame::Manifest(entries) => { + Frame::Manifest { hostname, entries } => { let mut payload = Vec::new(); + let hn_bytes = hostname.as_bytes(); + let hn_len = hn_bytes.len().min(255) as u8; + payload.push(hn_len); + payload.extend_from_slice(&hn_bytes[..hn_len as usize]); for e in entries { encode_entry(&mut payload, e); } @@ -143,29 +147,38 @@ impl Frame { Ok(Frame::Discover) } TYPE_MANIFEST => { + if payload.is_empty() { + return Err(DecodeError::BadPayload("MANIFEST missing hostname prefix")); + } + let hn_len = payload[0] as usize; + if 1 + hn_len > payload.len() { + return Err(DecodeError::BadPayload("MANIFEST hostname truncated")); + } + let hostname = String::from_utf8_lossy(&payload[1..1 + hn_len]).into_owned(); + let rest = &payload[1 + hn_len..]; let mut entries = Vec::new(); let mut i = 0; - while i < payload.len() { - if i + 5 > payload.len() { + while i < rest.len() { + if i + 5 > rest.len() { return Err(DecodeError::BadPayload("MANIFEST entry truncated")); } - let id = payload[i]; - let proto = payload[i + 1]; - let port = u16::from_be_bytes([payload[i + 2], payload[i + 3]]); - let label_len = payload[i + 4] as usize; + let id = rest[i]; + let proto = rest[i + 1]; + let port = u16::from_be_bytes([rest[i + 2], rest[i + 3]]); + let label_len = rest[i + 4] as usize; i += 5; - if i + label_len > payload.len() { + if i + label_len > rest.len() { return Err(DecodeError::BadPayload("MANIFEST label truncated")); } let label = if label_len == 0 { None } else { - Some(String::from_utf8_lossy(&payload[i..i + label_len]).into_owned()) + Some(String::from_utf8_lossy(&rest[i..i + label_len]).into_owned()) }; i += label_len; entries.push(UpstreamEntry { id, proto, port, label }); } - Ok(Frame::Manifest(entries)) + Ok(Frame::Manifest { hostname, entries }) } TYPE_OPEN => { if payload.len() != 1 { diff --git a/gatunad/src/main.rs b/gatunad/src/main.rs index 69d4622..985209c 100644 --- a/gatunad/src/main.rs +++ b/gatunad/src/main.rs @@ -52,6 +52,8 @@ async fn main() -> ExitCode { } }; + let hostname = Arc::new(upstream::get_hostname()); + let link = match Link::open(&args.iface) { Ok(l) => Arc::new(l), Err(e) => { @@ -112,7 +114,7 @@ async fn main() -> ExitCode { continue; } }; - handle_frame(frame, src, &tx, &store, &next_id, &table).await; + handle_frame(frame, src, &tx, &store, &next_id, &table, &hostname).await; } Ok(None) => { // Ignorable frame (outgoing/short/mismatch) or transient; do not @@ -135,10 +137,14 @@ async fn handle_frame( store: &SessionStore, next_id: &Arc, table: &Arc, + hostname: &Arc, ) { match frame { Frame::Discover => { - let manifest = Frame::Manifest(table.entries()); + let manifest = Frame::Manifest { + hostname: (**hostname).clone(), + entries: table.entries(), + }; let _ = tx.send((src, manifest.encode())).await; } Frame::Open { upstream_id } => { @@ -202,6 +208,6 @@ async fn handle_frame( store.lock().expect("store poisoned").remove(&session_id); } // Not expected from a client; ignore. - Frame::Manifest(_) | Frame::OpenAck { .. } | Frame::OpenNak { .. } => {} + Frame::Manifest { .. } | Frame::OpenAck { .. } | Frame::OpenNak { .. } => {} } } diff --git a/gatunad/src/upstream.rs b/gatunad/src/upstream.rs index 21d7314..489d11b 100644 --- a/gatunad/src/upstream.rs +++ b/gatunad/src/upstream.rs @@ -1,6 +1,20 @@ //! Upstream table: cmdline parsing and MANIFEST entry construction. -use crate::frame::{UpstreamEntry, PROTO_TCP, PROTO_UDP}; +use crate::frame::{Frame, UpstreamEntry, PROTO_TCP, PROTO_UDP}; + +/// Read the system hostname via `gethostname(2)`. +pub fn get_hostname() -> String { + let mut buf = [0u8; 256]; + let ret = unsafe { + libc::gethostname(buf.as_mut_ptr() as *mut libc::c_char, buf.len()) + }; + if ret == 0 { + let len = buf.iter().position(|&b| b == 0).unwrap_or(buf.len()); + String::from_utf8_lossy(&buf[..len]).into_owned() + } else { + String::new() + } +} #[derive(Clone, Copy, Debug, PartialEq, Eq)] #[allow(dead_code)] diff --git a/win/gatuna-client/Frame.cs b/win/gatuna-client/Frame.cs index b0890b4..dbbbeaf 100644 --- a/win/gatuna-client/Frame.cs +++ b/win/gatuna-client/Frame.cs @@ -40,7 +40,7 @@ readonly record struct UpstreamEntry( abstract record Frame { internal record Discover : Frame; - internal record Manifest(UpstreamEntry[] Entries) : Frame; + internal record Manifest(string Hostname, UpstreamEntry[] Entries) : Frame; internal record Open(byte UpstreamId) : Frame; internal record OpenAck(uint SessionId, byte UpstreamId) : Frame; internal record OpenNak(byte UpstreamId, byte Reason) : Frame; @@ -74,7 +74,7 @@ static class FrameCodec Frame.Discover => Build(Proto.TypeDiscover, 0, []), Frame.Manifest manifest => - Build(Proto.TypeManifest, 0, BuildManifestPayload(manifest.Entries)), + Build(Proto.TypeManifest, 0, BuildManifestPayload(manifest.Hostname, manifest.Entries)), Frame.Open open => Build(Proto.TypeOpen, 0, [open.UpstreamId]), Frame.OpenAck ack => @@ -90,9 +90,14 @@ static class FrameCodec }; } - static byte[] BuildManifestPayload(UpstreamEntry[] entries) + static byte[] BuildManifestPayload(string hostname, UpstreamEntry[] entries) { using var ms = new MemoryStream(); + var hnBytes = Encoding.UTF8.GetBytes(hostname); + var hnLen = (byte)Math.Min(hnBytes.Length, 255); + ms.WriteByte(hnLen); + if (hnLen > 0) + ms.Write(hnBytes, 0, hnLen); foreach (var e in entries) { var labelBytes = Encoding.UTF8.GetBytes(e.Label ?? ""); @@ -141,25 +146,35 @@ static class FrameCodec static Frame.Manifest? ParseManifest(ReadOnlySpan payload) { + if (payload.Length < 1) + return null; + var hnLen = payload[0]; + if (1 + hnLen > payload.Length) + return null; + var hostname = hnLen == 0 + ? "" + : Encoding.UTF8.GetString(payload[1..(1 + hnLen)]); + var rest = payload[(1 + hnLen)..]; + var entries = new List(); int i = 0; - while (i < payload.Length) + while (i < rest.Length) { - if (i + 5 > payload.Length) + if (i + 5 > rest.Length) return null; - var id = payload[i]; - var proto = payload[i + 1]; - var port = (ushort)(payload[i + 2] << 8 | payload[i + 3]); - var labelLen = payload[i + 4]; + var id = rest[i]; + var proto = rest[i + 1]; + var port = (ushort)(rest[i + 2] << 8 | rest[i + 3]); + var labelLen = rest[i + 4]; i += 5; - if (i + labelLen > payload.Length) + if (i + labelLen > rest.Length) return null; string? label = labelLen == 0 ? null - : Encoding.UTF8.GetString(payload[i..(i + labelLen)]); + : Encoding.UTF8.GetString(rest[i..(i + labelLen)]); i += labelLen; entries.Add(new UpstreamEntry(id, proto, port, label)); } - return new Frame.Manifest(entries.ToArray()); + return new Frame.Manifest(hostname, entries.ToArray()); } } diff --git a/win/gatuna-client/MainForm.cs b/win/gatuna-client/MainForm.cs index 12618e2..23f78c4 100644 --- a/win/gatuna-client/MainForm.cs +++ b/win/gatuna-client/MainForm.cs @@ -7,6 +7,7 @@ public partial class MainForm : Form readonly SessionManager _sessions = new(); readonly ComboBox _deviceBox = new(); readonly Button _discoverBtn = new(); + readonly Label _serverLabel = new(); readonly ListView _listView = new(); readonly Label _statusLabel = new(); TunnelLink? _link; @@ -23,7 +24,8 @@ public partial class MainForm : Form InitializeComponents(); _sessions.Log += msg => this.Invoke(() => _statusLabel.Text = msg); - _sessions.ManifestReceived += entries => this.Invoke(() => PopulateList(entries)); + _sessions.ManifestReceived += (hostname, mac, entries) => + this.Invoke(() => PopulateList(hostname, mac, entries)); foreach (var d in TunnelLink.ListDevices()) { @@ -54,9 +56,15 @@ public partial class MainForm : Form _discoverBtn.Click += OnDiscover; Controls.Add(_discoverBtn); - _listView.Left = pad; _listView.Top = _discoverBtn.Bottom + 8; + _serverLabel.Left = pad; _serverLabel.Top = _discoverBtn.Bottom + 8; + _serverLabel.Width = ClientSize.Width - pad * 2; + _serverLabel.AutoEllipsis = true; + _serverLabel.Text = "Server: not connected"; + Controls.Add(_serverLabel); + + _listView.Left = pad; _listView.Top = _serverLabel.Bottom + 8; _listView.Width = ClientSize.Width - pad * 2; - _listView.Height = 220; + _listView.Height = 200; _listView.View = View.Details; _listView.FullRowSelect = true; _listView.CheckBoxes = true; @@ -96,11 +104,15 @@ public partial class MainForm : Form _sessions.AttachLink(_link); _link.Open(); _sessions.Discover(); + _serverLabel.Text = "Server: discovering..."; _statusLabel.Text = "discovering..."; } - void PopulateList(UpstreamEntry[] entries) + void PopulateList(string hostname, byte[] mac, UpstreamEntry[] entries) { + var macStr = string.Join(":", mac.Select(b => b.ToString("X2"))); + _serverLabel.Text = $"Server: {hostname} — {macStr}"; + _listView.BeginUpdate(); _listView.Items.Clear(); foreach (var up in entries) diff --git a/win/gatuna-client/SessionManager.cs b/win/gatuna-client/SessionManager.cs index 68a7631..6dda735 100644 --- a/win/gatuna-client/SessionManager.cs +++ b/win/gatuna-client/SessionManager.cs @@ -12,6 +12,7 @@ sealed class SessionManager : IDisposable readonly ConcurrentDictionary _listeners = new(); byte[]? _serverMac; + string _serverHostname = ""; UpstreamEntry[] _upstreams = []; // Serialized OPEN: only one outstanding at a time. @@ -20,10 +21,11 @@ sealed class SessionManager : IDisposable readonly Queue _openQueue = new(); public event Action? Log; - public event Action? ManifestReceived; + public event Action? ManifestReceived; public UpstreamEntry[] Upstreams => _upstreams; public byte[]? ServerMac => _serverMac; + public string ServerHostname => _serverHostname; public TunnelLink? Link => _link; public void AttachLink(TunnelLink link) @@ -51,9 +53,10 @@ sealed class SessionManager : IDisposable { case Frame.Manifest manifest: _serverMac = srcMac; + _serverHostname = manifest.Hostname; _upstreams = manifest.Entries; - Log?.Invoke($"manifest: {manifest.Entries.Length} upstreams from {BitConverter.ToString(srcMac)}"); - ManifestReceived?.Invoke(manifest.Entries); + Log?.Invoke($"manifest: {manifest.Entries.Length} upstreams from {manifest.Hostname} ({BitConverter.ToString(srcMac)})"); + ManifestReceived?.Invoke(manifest.Hostname, srcMac, manifest.Entries); break; case Frame.OpenAck ack: