Talk to the other logging computers on N1MM's own port
N1MM has two networks and this program had only one of them. Port 12060
carries XML to other programs — a spot tool, a score poster — and
`StationNetwork` already writes that. Computer to computer, N1MM uses
port 12070 and a different protocol, which is what this adds.
The wire format, from `MultiOpManager` and `MultiStation`:
DATA__07%SHACK-PC%QSO%2026-09-03 12:34:56%DL1ABC%…~__DATA
the sending station's number, its computer name, the message type and
the fields of that type. `%` and `~` cannot appear in a field, so N1MM
writes `!` in their place. `StationRecord` reads and writes that, and
holds what has arrived until a `~` says a message is whole: TCP hands
over half a message as often as two.
Discovery is a UDP broadcast to the same port, six fields wide.
**N1MM refuses a station whose version is not its own** — it says
"Software versions must match" and drops it — so the version this
program broadcasts is a setting rather than its own version, and a
station that says something else is kept in the list with `Refused` set
so the operator can see why nothing is arriving.
`StationLink` runs both sockets, opens a connection to every station it
hears, and reports what arrives. `QsoRecord` is the thirty-five fields
of a contact in N1MM's order, from `MultiOpManager.cs:1007`; points and
multiplier flags are read but the log works them out again from the
rules, so two stations cannot disagree about a score. `AddStation` names
a station by hand, for a network where a broadcast does not reach.
A station can also be named rather than heard, and one that is connected
may say nothing for a while, so `NetworkedStation` is heard-from as soon
as it exists. Left at nothing, the sweep that drops quiet stations read
every new station as quiet since the beginning of time and closed the
connection the moment it opened.
Tested as bytes and over loopback. Nothing has talked to a real N1MM.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PdAYHcdRktqKry7nk414TU
This commit is contained in:
@@ -0,0 +1,555 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using Nonemm.Core;
|
||||
|
||||
namespace Nonemm.Network;
|
||||
|
||||
/// The link between the computers of one multi-operator entry, on N1MM's own
|
||||
/// port 12070.
|
||||
///
|
||||
/// Two sockets, which is how N1MM does it:
|
||||
///
|
||||
/// | Socket | What goes over it |
|
||||
/// |---|---|
|
||||
/// | UDP, broadcast | the beacon that says a computer is here, every `BeaconInterval` |
|
||||
/// | TCP, one per station | contacts, edits, deletes, chat, everything else |
|
||||
///
|
||||
/// A station is found rather than configured: the beacon carries the address
|
||||
/// and the port to open a connection to, so an operator plugs a laptop in and
|
||||
/// the others see it. A connection is opened to every station whose beacon
|
||||
/// arrives, and both ends do it, so there are two connections per pair of
|
||||
/// stations — one each way. That is N1MM's arrangement: a station writes to the
|
||||
/// connection it opened and reads from the one that was opened to it.
|
||||
///
|
||||
/// **The version must match.** N1MM compares the version in the beacon with
|
||||
/// its own and turns away anything else, so `version` is what this program
|
||||
/// claims to be and has to be the version of the N1MM copies it is running
|
||||
/// beside. A station that says something else is kept in the list with
|
||||
/// `Refused` set, so the operator can see why it is not talking.
|
||||
///
|
||||
/// This does not score anything or touch the log. It hands what arrives to
|
||||
/// whoever owns the log through `UpdateArrived`, the same as `StationNetwork`
|
||||
/// does with the XML broadcasts on port 12060. The two run side by side: 12060
|
||||
/// is for other programs, 12070 is for other logging computers.
|
||||
public sealed class StationLink : IDisposable
|
||||
{
|
||||
/// How often the beacon goes out. N1MM broadcasts on startup, when the
|
||||
/// network status window asks, and on a timer; ten seconds is short enough
|
||||
/// that a station that joins is seen at once and long enough to be nothing
|
||||
/// on a network carrying contest traffic.
|
||||
public static readonly TimeSpan BeaconInterval = TimeSpan.FromSeconds(10);
|
||||
|
||||
/// How long a station may say nothing before the window shows it as gone.
|
||||
/// Three beacons.
|
||||
public static readonly TimeSpan Patience = TimeSpan.FromSeconds(30);
|
||||
|
||||
private readonly int port;
|
||||
private readonly string computerName;
|
||||
private readonly string version;
|
||||
private readonly CancellationTokenSource stopping = new();
|
||||
private readonly ConcurrentDictionary<string, NetworkedStation> stations = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly ConcurrentDictionary<string, TcpClient> writers = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly NetworkedStation mine;
|
||||
/// When the last echo request went out, which is what the round trip is
|
||||
/// measured against.
|
||||
private DateTime echoSentAt;
|
||||
private UdpClient? beacons;
|
||||
private TcpListener? listener;
|
||||
private readonly List<Task> loops = [];
|
||||
|
||||
public StationLink(string computerName, string version, int stationNumber = 1, int port = StationBeacon.DefaultPort)
|
||||
{
|
||||
this.computerName = computerName.ToUpperInvariant();
|
||||
this.version = version;
|
||||
this.port = port;
|
||||
StationNumber = stationNumber;
|
||||
mine = new NetworkedStation(this.computerName, "", port)
|
||||
{
|
||||
IsMine = true,
|
||||
};
|
||||
mine.StationNumber = stationNumber;
|
||||
mine.Version = version;
|
||||
stations[this.computerName] = mine;
|
||||
}
|
||||
|
||||
/// Which station of the entry this computer is. It goes into every message
|
||||
/// and into every contact this computer logs.
|
||||
public int StationNumber { get; }
|
||||
|
||||
public string ComputerName => computerName;
|
||||
|
||||
/// Who is at this radio, sent in the beacon so the other stations can show
|
||||
/// it.
|
||||
public string Operator
|
||||
{
|
||||
get => mine.Operator;
|
||||
set => mine.Operator = value;
|
||||
}
|
||||
|
||||
/// Every station, this computer included, in the order they were first
|
||||
/// heard.
|
||||
public IReadOnlyList<NetworkedStation> Stations => stations.Values.ToList();
|
||||
|
||||
/// A contact another station logged, edited or deleted.
|
||||
public event EventHandler<ContactUpdate>? UpdateArrived;
|
||||
|
||||
/// A line of chat from another operator.
|
||||
public event EventHandler<string>? TalkArrived;
|
||||
|
||||
/// Anything about the list of stations changed: one joined, one went quiet,
|
||||
/// one moved band. The status window redraws on this.
|
||||
public event EventHandler? StationsChanged;
|
||||
|
||||
public event EventHandler<string>? Failed;
|
||||
|
||||
public void Start()
|
||||
{
|
||||
if (loops.Count > 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
beacons = new UdpClient();
|
||||
beacons.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true);
|
||||
beacons.Client.Bind(new IPEndPoint(IPAddress.Any, port));
|
||||
beacons.EnableBroadcast = true;
|
||||
listener = new TcpListener(new IPEndPoint(IPAddress.Any, port));
|
||||
listener.Start();
|
||||
}
|
||||
catch (SocketException e)
|
||||
{
|
||||
Failed?.Invoke(this, $"could not take port {port}: {e.Message}");
|
||||
return;
|
||||
}
|
||||
loops.Add(Task.Run(() => BeaconLoopAsync(stopping.Token)));
|
||||
loops.Add(Task.Run(() => ListenForBeaconsAsync(stopping.Token)));
|
||||
loops.Add(Task.Run(() => AcceptAsync(stopping.Token)));
|
||||
}
|
||||
|
||||
/// A station named by hand rather than found by a beacon, and a connection
|
||||
/// opened to it. N1MM offers the same thing for a network where a broadcast
|
||||
/// does not reach every computer — its predefined stations — and it is also
|
||||
/// what a station on the other side of a VPN needs.
|
||||
public NetworkedStation AddStation(string name, string address, int stationPort)
|
||||
{
|
||||
NetworkedStation station = stations.GetOrAdd(
|
||||
name.ToUpperInvariant(),
|
||||
known => new NetworkedStation(known, address, stationPort));
|
||||
station.Address = address;
|
||||
station.Port = stationPort;
|
||||
station.LastHeardUtc = DateTime.UtcNow;
|
||||
StationsChanged?.Invoke(this, EventArgs.Empty);
|
||||
if (!writers.ContainsKey(station.ComputerName))
|
||||
{
|
||||
_ = ConnectAsync(station, stopping.Token);
|
||||
}
|
||||
return station;
|
||||
}
|
||||
|
||||
/// A message to every station that is connected. Returns how many it
|
||||
/// reached, so a caller that has to know whether anybody heard can say so.
|
||||
public async Task<int> SendAsync(StationRecord record, CancellationToken cancellation = default)
|
||||
{
|
||||
byte[] message = Encoding.UTF8.GetBytes(record.ToWire());
|
||||
int reached = 0;
|
||||
foreach ((string name, TcpClient writer) in writers)
|
||||
{
|
||||
try
|
||||
{
|
||||
await writer.GetStream().WriteAsync(message, cancellation).ConfigureAwait(false);
|
||||
reached++;
|
||||
if (stations.TryGetValue(name, out NetworkedStation? station))
|
||||
{
|
||||
station.Sent++;
|
||||
}
|
||||
}
|
||||
catch (Exception e) when (e is IOException or SocketException or ObjectDisposedException)
|
||||
{
|
||||
Drop(name, e.Message);
|
||||
}
|
||||
}
|
||||
if (reached > 0)
|
||||
{
|
||||
mine.Sent += reached;
|
||||
StationsChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
return reached;
|
||||
}
|
||||
|
||||
/// A message to one station. Returns false when it could not be written,
|
||||
/// which drops the connection: the next beacon opens a new one.
|
||||
public async Task<bool> SendToAsync(
|
||||
NetworkedStation station,
|
||||
StationRecord record,
|
||||
CancellationToken cancellation = default)
|
||||
{
|
||||
if (!writers.TryGetValue(station.ComputerName, out TcpClient? writer))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
try
|
||||
{
|
||||
await writer
|
||||
.GetStream()
|
||||
.WriteAsync(Encoding.UTF8.GetBytes(record.ToWire()), cancellation)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception e) when (e is IOException or SocketException or ObjectDisposedException)
|
||||
{
|
||||
Drop(station.ComputerName, e.Message);
|
||||
return false;
|
||||
}
|
||||
station.Sent++;
|
||||
mine.Sent++;
|
||||
StationsChanged?.Invoke(this, EventArgs.Empty);
|
||||
return true;
|
||||
}
|
||||
|
||||
public Task<int> SendLoggedAsync(Qso qso, CancellationToken cancellation = default) =>
|
||||
SendAsync(StationMessages.Logged(qso, StationNumber, computerName), cancellation);
|
||||
|
||||
public Task<int> SendEditedAsync(
|
||||
Qso qso,
|
||||
string oldCall,
|
||||
DateTime oldTimestampUtc,
|
||||
CancellationToken cancellation = default) =>
|
||||
SendAsync(
|
||||
StationMessages.Edited(qso, oldCall, oldTimestampUtc, StationNumber, computerName),
|
||||
cancellation);
|
||||
|
||||
public Task<int> SendDeletedAsync(Qso qso, CancellationToken cancellation = default) =>
|
||||
SendAsync(StationMessages.Deleted(qso, StationNumber, computerName), cancellation);
|
||||
|
||||
public Task<int> SendTalkAsync(string text, CancellationToken cancellation = default) =>
|
||||
SendAsync(StationMessages.Talk(text, StationNumber, computerName), cancellation);
|
||||
|
||||
/// Says where this station is. It goes out whenever the radio moves or the
|
||||
/// operator turns run on or off, and it is what the other stations show and
|
||||
/// what the band-change rule counts.
|
||||
public Task<int> SendBandAsync(
|
||||
Frequency frequency,
|
||||
Mode mode,
|
||||
bool running,
|
||||
int radioNumber,
|
||||
CancellationToken cancellation = default)
|
||||
{
|
||||
mine.Frequency = frequency;
|
||||
mine.Mode = mode;
|
||||
mine.IsRunning = running;
|
||||
mine.RadioNumber = radioNumber;
|
||||
StationsChanged?.Invoke(this, EventArgs.Empty);
|
||||
return SendAsync(
|
||||
StationMessages.OnBand(frequency, mode, running, radioNumber, StationNumber, computerName),
|
||||
cancellation);
|
||||
}
|
||||
|
||||
public Task<int> SendTransmittingAsync(
|
||||
bool transmitting,
|
||||
int radioNumber,
|
||||
CancellationToken cancellation = default)
|
||||
{
|
||||
mine.IsTransmitting = transmitting;
|
||||
return SendAsync(
|
||||
StationMessages.Transmitting(transmitting, radioNumber, StationNumber, computerName),
|
||||
cancellation);
|
||||
}
|
||||
|
||||
/// Asks every station whether it is there. The answer sets `EchoTime`.
|
||||
public Task<int> SendEchoRequestAsync(CancellationToken cancellation = default)
|
||||
{
|
||||
echoSentAt = DateTime.UtcNow;
|
||||
return SendAsync(StationMessages.EchoRequest(StationNumber, computerName, DateTime.UtcNow), cancellation);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
stopping.Cancel();
|
||||
foreach (TcpClient writer in writers.Values)
|
||||
{
|
||||
writer.Dispose();
|
||||
}
|
||||
writers.Clear();
|
||||
listener?.Stop();
|
||||
beacons?.Dispose();
|
||||
stopping.Dispose();
|
||||
}
|
||||
|
||||
/// The beacon, on every interface that can broadcast. It carries this
|
||||
/// computer's name, address and port, so a station that hears it knows
|
||||
/// where to open a connection.
|
||||
private async Task BeaconLoopAsync(CancellationToken cancellation)
|
||||
{
|
||||
while (!cancellation.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
StationBeacon beacon = new(computerName, Address(), port, version, mine.Operator);
|
||||
byte[] message = Encoding.UTF8.GetBytes(beacon.ToWire());
|
||||
await beacons!
|
||||
.SendAsync(message, new IPEndPoint(IPAddress.Broadcast, port), cancellation)
|
||||
.ConfigureAwait(false);
|
||||
mine.Address = beacon.Address;
|
||||
mine.LastHeardUtc = DateTime.UtcNow;
|
||||
Forget();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch (SocketException e)
|
||||
{
|
||||
Failed?.Invoke(this, $"could not broadcast: {e.Message}");
|
||||
}
|
||||
try
|
||||
{
|
||||
await Task.Delay(BeaconInterval, cancellation).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ListenForBeaconsAsync(CancellationToken cancellation)
|
||||
{
|
||||
while (!cancellation.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
UdpReceiveResult received = await beacons!.ReceiveAsync(cancellation).ConfigureAwait(false);
|
||||
if (StationBeacon.Read(Encoding.UTF8.GetString(received.Buffer)) is not { } beacon)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (beacon.ComputerName.Equals(computerName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
Heard(beacon, received.RemoteEndPoint.Address.ToString());
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch (SocketException e)
|
||||
{
|
||||
Failed?.Invoke(this, e.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A station said it is here. Its own address is believed only as far as
|
||||
/// the packet: a station behind a router announces an address nothing can
|
||||
/// reach, so the address the packet came from is what a connection is
|
||||
/// opened to.
|
||||
private void Heard(StationBeacon beacon, string from)
|
||||
{
|
||||
NetworkedStation station = stations.GetOrAdd(
|
||||
beacon.ComputerName,
|
||||
name => new NetworkedStation(name, from, beacon.Port));
|
||||
station.Address = from;
|
||||
station.Port = beacon.Port;
|
||||
station.Version = beacon.Version;
|
||||
station.Operator = beacon.Operator;
|
||||
station.LastHeardUtc = DateTime.UtcNow;
|
||||
if (beacon.Version != version)
|
||||
{
|
||||
station.Refused = $"version {beacon.Version}, this station is {version}";
|
||||
station.IsConnected = false;
|
||||
StationsChanged?.Invoke(this, EventArgs.Empty);
|
||||
return;
|
||||
}
|
||||
station.Refused = "";
|
||||
StationsChanged?.Invoke(this, EventArgs.Empty);
|
||||
if (!writers.ContainsKey(station.ComputerName))
|
||||
{
|
||||
_ = ConnectAsync(station, stopping.Token);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ConnectAsync(NetworkedStation station, CancellationToken cancellation)
|
||||
{
|
||||
TcpClient writer = new();
|
||||
try
|
||||
{
|
||||
await writer.ConnectAsync(station.Address, station.Port, cancellation).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception e) when (e is SocketException or OperationCanceledException)
|
||||
{
|
||||
writer.Dispose();
|
||||
station.Refused = e.Message;
|
||||
Failed?.Invoke(this, $"could not open a connection to {station.ComputerName} at {station.Address}:{station.Port}: {e.Message}");
|
||||
StationsChanged?.Invoke(this, EventArgs.Empty);
|
||||
return;
|
||||
}
|
||||
if (!writers.TryAdd(station.ComputerName, writer))
|
||||
{
|
||||
writer.Dispose();
|
||||
return;
|
||||
}
|
||||
station.IsConnected = true;
|
||||
station.LastHeardUtc = DateTime.UtcNow;
|
||||
StationsChanged?.Invoke(this, EventArgs.Empty);
|
||||
// N1MM's first message on a new connection, which tells the other end
|
||||
// which station of the entry this is. It goes to that station alone:
|
||||
// the others were told when their own connection opened
|
||||
await SendToAsync(station, StationMessages.IAm(StationNumber, computerName), cancellation)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task AcceptAsync(CancellationToken cancellation)
|
||||
{
|
||||
while (!cancellation.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
TcpClient reader = await listener!.AcceptTcpClientAsync(cancellation).ConfigureAwait(false);
|
||||
_ = Task.Run(() => ReadAsync(reader, cancellation), cancellation);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch (SocketException e)
|
||||
{
|
||||
Failed?.Invoke(this, e.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One connection that was opened to this station. Whatever arrives is
|
||||
/// added to what has not been read yet, and every whole message in it is
|
||||
/// handed on: TCP gives no promise about where a read ends.
|
||||
private async Task ReadAsync(TcpClient reader, CancellationToken cancellation)
|
||||
{
|
||||
byte[] buffer = new byte[8192];
|
||||
string held = "";
|
||||
try
|
||||
{
|
||||
using (reader)
|
||||
{
|
||||
NetworkStream stream = reader.GetStream();
|
||||
while (!cancellation.IsCancellationRequested)
|
||||
{
|
||||
int count = await stream.ReadAsync(buffer, cancellation).ConfigureAwait(false);
|
||||
if (count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
held += Encoding.UTF8.GetString(buffer, 0, count);
|
||||
while (StationRecord.Read(ref held) is { } record)
|
||||
{
|
||||
Arrived(record);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception e) when (e is IOException or SocketException or OperationCanceledException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// What one message means. The station it came from is credited with it
|
||||
/// whatever the type is, so the window's Read count and Last heard are
|
||||
/// right even for the messages this program does nothing with.
|
||||
private void Arrived(StationRecord record)
|
||||
{
|
||||
NetworkedStation station = stations.GetOrAdd(
|
||||
record.ComputerName,
|
||||
name => new NetworkedStation(name, "", port));
|
||||
station.LastHeardUtc = DateTime.UtcNow;
|
||||
station.LastMessage = record.Type;
|
||||
station.Read++;
|
||||
mine.Read++;
|
||||
switch (record.Type)
|
||||
{
|
||||
case "IAM":
|
||||
station.StationNumber = record.Number(0);
|
||||
break;
|
||||
case "BANDMAP":
|
||||
station.Frequency = Frequency.FromKilohertz(record.Decimal(0));
|
||||
station.Mode = Modes.Parse(record.Field(1));
|
||||
station.IsRunning = record.Flag(2);
|
||||
station.RadioNumber = Math.Max(1, record.Number(3));
|
||||
break;
|
||||
case "XMIT":
|
||||
station.IsTransmitting = record.Flag(0);
|
||||
station.RadioNumber = Math.Max(1, record.Number(1));
|
||||
break;
|
||||
case "PASSFREQ":
|
||||
station.PassFrequency = Frequency.FromKilohertz(record.Decimal(0));
|
||||
station.PassCall = record.Field(1);
|
||||
break;
|
||||
case "ECHOREQ":
|
||||
_ = SendAsync(StationMessages.Echo(record, StationNumber, computerName));
|
||||
break;
|
||||
case "ECHO":
|
||||
station.EchoTime = DateTime.UtcNow - echoSentAt;
|
||||
break;
|
||||
case "TALK":
|
||||
TalkArrived?.Invoke(this, record.Field(0));
|
||||
break;
|
||||
default:
|
||||
if (StationMessages.Read(record) is { } update)
|
||||
{
|
||||
UpdateArrived?.Invoke(this, update);
|
||||
}
|
||||
break;
|
||||
}
|
||||
StationsChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
/// Closes a connection that has failed or gone quiet. The station stays in
|
||||
/// the list: it
|
||||
/// is still part of the entry, and the window showing it as not connected
|
||||
/// is the point.
|
||||
private void Drop(string name, string why)
|
||||
{
|
||||
if (writers.TryRemove(name, out TcpClient? writer))
|
||||
{
|
||||
writer.Dispose();
|
||||
}
|
||||
if (stations.TryGetValue(name, out NetworkedStation? station))
|
||||
{
|
||||
station.IsConnected = false;
|
||||
station.Refused = why;
|
||||
}
|
||||
StationsChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
/// Marks as not connected any station that has said nothing for
|
||||
/// `Patience`, and lets go of its connection so the next beacon opens a
|
||||
/// new one.
|
||||
private void Forget()
|
||||
{
|
||||
DateTime cutoff = DateTime.UtcNow - Patience;
|
||||
foreach (NetworkedStation station in stations.Values)
|
||||
{
|
||||
if (!station.IsMine && station.IsConnected && station.LastHeardUtc < cutoff)
|
||||
{
|
||||
Drop(station.ComputerName, "said nothing for half a minute");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// This computer's address on the network it can reach the others on. It is
|
||||
/// only for the beacon: the other end uses the address the packet came
|
||||
/// from, so a wrong answer here costs nothing.
|
||||
private static string Address()
|
||||
{
|
||||
try
|
||||
{
|
||||
using Socket probe = new(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
|
||||
probe.Connect("8.8.8.8", 65530);
|
||||
return probe.LocalEndPoint is IPEndPoint local ? local.Address.ToString() : "";
|
||||
}
|
||||
catch (SocketException)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user