Skip to content

Commit ebbf523

Browse files
Merge pull request #1 from AvionBlock/dev
MVP
2 parents a816ce0 + fc7e339 commit ebbf523

47 files changed

Lines changed: 3430 additions & 100 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [ main, master ]
6+
pull_request:
7+
branches: [ main, master ]
8+
9+
jobs:
10+
build-test-pack:
11+
runs-on: ubuntu-latest
12+
13+
steps:
14+
- name: Checkout
15+
uses: actions/checkout@v4
16+
17+
- name: Setup .NET
18+
uses: actions/setup-dotnet@v4
19+
with:
20+
dotnet-version: 10.0.x
21+
22+
- name: Restore
23+
run: dotnet restore OpenPort.Net.sln
24+
25+
- name: Build
26+
run: dotnet build OpenPort.Net.sln --configuration Release --no-restore
27+
28+
- name: Test
29+
run: dotnet test OpenPort.Net.sln --configuration Release --no-build --verbosity normal
30+
31+
- name: Pack
32+
run: dotnet pack OpenPort.Net/OpenPort.Net.csproj --configuration Release --no-build --output artifacts

OpenPort.Net.sln

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,66 @@
22
Microsoft Visual Studio Solution File, Format Version 12.00
33
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenPort.Net", "OpenPort.Net\OpenPort.Net.csproj", "{1FBFE6D8-7A0D-4967-B073-29EA63B5186F}"
44
EndProject
5+
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}"
6+
EndProject
7+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenPort.Net.Tests", "tests\OpenPort.Net.Tests\OpenPort.Net.Tests.csproj", "{B95309B1-ED32-4197-ABAF-39A5DF0FF45F}"
8+
EndProject
9+
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{5D20AA90-6969-D8BD-9DCD-8634F4692FDA}"
10+
EndProject
11+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenPort.Net.Sample", "samples\OpenPort.Net.Sample\OpenPort.Net.Sample.csproj", "{098F1BA7-20E9-47BF-BBBA-AD8F7C92D363}"
12+
EndProject
513
Global
614
GlobalSection(SolutionConfigurationPlatforms) = preSolution
715
Debug|Any CPU = Debug|Any CPU
16+
Debug|x64 = Debug|x64
17+
Debug|x86 = Debug|x86
818
Release|Any CPU = Release|Any CPU
19+
Release|x64 = Release|x64
20+
Release|x86 = Release|x86
921
EndGlobalSection
1022
GlobalSection(ProjectConfigurationPlatforms) = postSolution
1123
{1FBFE6D8-7A0D-4967-B073-29EA63B5186F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
1224
{1FBFE6D8-7A0D-4967-B073-29EA63B5186F}.Debug|Any CPU.Build.0 = Debug|Any CPU
25+
{1FBFE6D8-7A0D-4967-B073-29EA63B5186F}.Debug|x64.ActiveCfg = Debug|Any CPU
26+
{1FBFE6D8-7A0D-4967-B073-29EA63B5186F}.Debug|x64.Build.0 = Debug|Any CPU
27+
{1FBFE6D8-7A0D-4967-B073-29EA63B5186F}.Debug|x86.ActiveCfg = Debug|Any CPU
28+
{1FBFE6D8-7A0D-4967-B073-29EA63B5186F}.Debug|x86.Build.0 = Debug|Any CPU
1329
{1FBFE6D8-7A0D-4967-B073-29EA63B5186F}.Release|Any CPU.ActiveCfg = Release|Any CPU
1430
{1FBFE6D8-7A0D-4967-B073-29EA63B5186F}.Release|Any CPU.Build.0 = Release|Any CPU
31+
{1FBFE6D8-7A0D-4967-B073-29EA63B5186F}.Release|x64.ActiveCfg = Release|Any CPU
32+
{1FBFE6D8-7A0D-4967-B073-29EA63B5186F}.Release|x64.Build.0 = Release|Any CPU
33+
{1FBFE6D8-7A0D-4967-B073-29EA63B5186F}.Release|x86.ActiveCfg = Release|Any CPU
34+
{1FBFE6D8-7A0D-4967-B073-29EA63B5186F}.Release|x86.Build.0 = Release|Any CPU
35+
{B95309B1-ED32-4197-ABAF-39A5DF0FF45F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
36+
{B95309B1-ED32-4197-ABAF-39A5DF0FF45F}.Debug|Any CPU.Build.0 = Debug|Any CPU
37+
{B95309B1-ED32-4197-ABAF-39A5DF0FF45F}.Debug|x64.ActiveCfg = Debug|Any CPU
38+
{B95309B1-ED32-4197-ABAF-39A5DF0FF45F}.Debug|x64.Build.0 = Debug|Any CPU
39+
{B95309B1-ED32-4197-ABAF-39A5DF0FF45F}.Debug|x86.ActiveCfg = Debug|Any CPU
40+
{B95309B1-ED32-4197-ABAF-39A5DF0FF45F}.Debug|x86.Build.0 = Debug|Any CPU
41+
{B95309B1-ED32-4197-ABAF-39A5DF0FF45F}.Release|Any CPU.ActiveCfg = Release|Any CPU
42+
{B95309B1-ED32-4197-ABAF-39A5DF0FF45F}.Release|Any CPU.Build.0 = Release|Any CPU
43+
{B95309B1-ED32-4197-ABAF-39A5DF0FF45F}.Release|x64.ActiveCfg = Release|Any CPU
44+
{B95309B1-ED32-4197-ABAF-39A5DF0FF45F}.Release|x64.Build.0 = Release|Any CPU
45+
{B95309B1-ED32-4197-ABAF-39A5DF0FF45F}.Release|x86.ActiveCfg = Release|Any CPU
46+
{B95309B1-ED32-4197-ABAF-39A5DF0FF45F}.Release|x86.Build.0 = Release|Any CPU
47+
{098F1BA7-20E9-47BF-BBBA-AD8F7C92D363}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
48+
{098F1BA7-20E9-47BF-BBBA-AD8F7C92D363}.Debug|Any CPU.Build.0 = Debug|Any CPU
49+
{098F1BA7-20E9-47BF-BBBA-AD8F7C92D363}.Debug|x64.ActiveCfg = Debug|Any CPU
50+
{098F1BA7-20E9-47BF-BBBA-AD8F7C92D363}.Debug|x64.Build.0 = Debug|Any CPU
51+
{098F1BA7-20E9-47BF-BBBA-AD8F7C92D363}.Debug|x86.ActiveCfg = Debug|Any CPU
52+
{098F1BA7-20E9-47BF-BBBA-AD8F7C92D363}.Debug|x86.Build.0 = Debug|Any CPU
53+
{098F1BA7-20E9-47BF-BBBA-AD8F7C92D363}.Release|Any CPU.ActiveCfg = Release|Any CPU
54+
{098F1BA7-20E9-47BF-BBBA-AD8F7C92D363}.Release|Any CPU.Build.0 = Release|Any CPU
55+
{098F1BA7-20E9-47BF-BBBA-AD8F7C92D363}.Release|x64.ActiveCfg = Release|Any CPU
56+
{098F1BA7-20E9-47BF-BBBA-AD8F7C92D363}.Release|x64.Build.0 = Release|Any CPU
57+
{098F1BA7-20E9-47BF-BBBA-AD8F7C92D363}.Release|x86.ActiveCfg = Release|Any CPU
58+
{098F1BA7-20E9-47BF-BBBA-AD8F7C92D363}.Release|x86.Build.0 = Release|Any CPU
59+
EndGlobalSection
60+
GlobalSection(SolutionProperties) = preSolution
61+
HideSolutionNode = FALSE
62+
EndGlobalSection
63+
GlobalSection(NestedProjects) = preSolution
64+
{B95309B1-ED32-4197-ABAF-39A5DF0FF45F} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
65+
{098F1BA7-20E9-47BF-BBBA-AD8F7C92D363} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA}
1566
EndGlobalSection
1667
EndGlobal
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
using System.Net;
2+
using OpenPort.Net.Internal;
3+
4+
namespace OpenPort.Net.Discovery;
5+
6+
internal sealed class GatewayDiscovery
7+
{
8+
public IPAddress? DiscoverGatewayAddress() => NetworkUtils.GetDefaultGatewayAddress();
9+
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
using System.Net;
2+
using System.Net.Sockets;
3+
using System.Text;
4+
5+
namespace OpenPort.Net.Discovery;
6+
7+
internal sealed class SsdpDiscovery
8+
{
9+
private static readonly IPEndPoint MulticastEndPoint = new(IPAddress.Parse("239.255.255.250"), 1900);
10+
private readonly TimeSpan _timeout;
11+
12+
public SsdpDiscovery(TimeSpan timeout)
13+
{
14+
_timeout = timeout;
15+
}
16+
17+
public async Task<IReadOnlyList<Uri>> DiscoverInternetGatewayDevicesAsync(CancellationToken cancellationToken)
18+
{
19+
var searchTargets = new[]
20+
{
21+
"urn:schemas-upnp-org:device:InternetGatewayDevice:1",
22+
"urn:schemas-upnp-org:service:WANIPConnection:1",
23+
"urn:schemas-upnp-org:service:WANIPConnection:2",
24+
"urn:schemas-upnp-org:service:WANPPPConnection:1"
25+
};
26+
27+
var locations = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
28+
29+
using var udpClient = new UdpClient(AddressFamily.InterNetwork);
30+
udpClient.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true);
31+
32+
foreach (var searchTarget in searchTargets)
33+
{
34+
var request = BuildSearchRequest(searchTarget);
35+
var bytes = Encoding.ASCII.GetBytes(request);
36+
await udpClient.SendAsync(bytes, bytes.Length, MulticastEndPoint).ConfigureAwait(false);
37+
}
38+
39+
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
40+
timeout.CancelAfter(_timeout);
41+
42+
while (!timeout.IsCancellationRequested)
43+
{
44+
try
45+
{
46+
var response = await ReceiveAsync(udpClient, timeout.Token).ConfigureAwait(false);
47+
if (response is null)
48+
{
49+
break;
50+
}
51+
52+
var text = Encoding.ASCII.GetString(response.Value.Buffer);
53+
var location = ParseHeader(text, "LOCATION");
54+
if (Uri.TryCreate(location, UriKind.Absolute, out var uri))
55+
{
56+
locations.Add(uri.AbsoluteUri);
57+
}
58+
}
59+
catch (OperationCanceledException) when (timeout.IsCancellationRequested)
60+
{
61+
break;
62+
}
63+
}
64+
65+
return locations.Select(location => new Uri(location)).ToList();
66+
}
67+
68+
private static string BuildSearchRequest(string searchTarget) =>
69+
"M-SEARCH * HTTP/1.1\r\n" +
70+
"HOST: 239.255.255.250:1900\r\n" +
71+
"MAN: \"ssdp:discover\"\r\n" +
72+
"MX: 2\r\n" +
73+
$"ST: {searchTarget}\r\n\r\n";
74+
75+
private static string? ParseHeader(string response, string name)
76+
{
77+
foreach (var line in response.Split(new[] { "\r\n", "\n" }, StringSplitOptions.RemoveEmptyEntries))
78+
{
79+
var separator = line.IndexOf(':');
80+
if (separator <= 0)
81+
{
82+
continue;
83+
}
84+
85+
if (string.Equals(line[..separator].Trim(), name, StringComparison.OrdinalIgnoreCase))
86+
{
87+
return line[(separator + 1)..].Trim();
88+
}
89+
}
90+
91+
return null;
92+
}
93+
94+
private static async Task<UdpReceiveResult?> ReceiveAsync(UdpClient udpClient, CancellationToken cancellationToken)
95+
{
96+
#if NET8_0_OR_GREATER
97+
return await udpClient.ReceiveAsync(cancellationToken).ConfigureAwait(false);
98+
#else
99+
var receiveTask = udpClient.ReceiveAsync();
100+
var completed = await Task.WhenAny(receiveTask, Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken)).ConfigureAwait(false);
101+
cancellationToken.ThrowIfCancellationRequested();
102+
return completed == receiveTask ? receiveTask.Result : null;
103+
#endif
104+
}
105+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
#if NETSTANDARD2_1
2+
namespace System.Runtime.CompilerServices;
3+
4+
internal static class IsExternalInit
5+
{
6+
}
7+
#endif
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
using System.Net;
2+
using OpenPort.Net.Models;
3+
4+
namespace OpenPort.Net.Internal;
5+
6+
internal static class NatPmpMessage
7+
{
8+
public const byte Version = 0;
9+
public const byte ExternalAddressOpcode = 0;
10+
public const byte UdpMapOpcode = 1;
11+
public const byte TcpMapOpcode = 2;
12+
public const byte ResponseOffset = 128;
13+
14+
public static byte[] CreateExternalAddressRequest() => [Version, ExternalAddressOpcode];
15+
16+
public static byte[] CreateMapRequest(PortProtocol protocol, int internalPort, int externalPort, uint lifetimeSeconds)
17+
{
18+
var request = new byte[12];
19+
request[0] = Version;
20+
request[1] = ToMapOpcode(protocol);
21+
NetworkUtils.WriteUInt16BigEndian(request, 4, internalPort);
22+
NetworkUtils.WriteUInt16BigEndian(request, 6, externalPort);
23+
NetworkUtils.WriteUInt32BigEndian(request, 8, lifetimeSeconds);
24+
return request;
25+
}
26+
27+
public static bool TryParseExternalAddressResponse(
28+
byte[] response,
29+
out ushort resultCode,
30+
out IPAddress? externalAddress)
31+
{
32+
resultCode = 0;
33+
externalAddress = null;
34+
35+
if (response.Length < 8 || response[0] != Version || response[1] != ExternalAddressOpcode + ResponseOffset)
36+
{
37+
return false;
38+
}
39+
40+
resultCode = NetworkUtils.ReadUInt16BigEndian(response, 2);
41+
if (resultCode != 0)
42+
{
43+
return true;
44+
}
45+
46+
if (response.Length < 12)
47+
{
48+
return false;
49+
}
50+
51+
externalAddress = new IPAddress(response.Skip(8).Take(4).ToArray());
52+
return true;
53+
}
54+
55+
public static bool TryParseMapResponse(
56+
byte[] response,
57+
PortProtocol protocol,
58+
out NatPmpMapResponse mapResponse)
59+
{
60+
mapResponse = default;
61+
var opcode = ToMapOpcode(protocol);
62+
63+
if (response.Length < 8 || response[0] != Version || response[1] != opcode + ResponseOffset)
64+
{
65+
return false;
66+
}
67+
68+
var resultCode = NetworkUtils.ReadUInt16BigEndian(response, 2);
69+
if (resultCode != 0)
70+
{
71+
mapResponse = new NatPmpMapResponse(resultCode, 0, 0, 0);
72+
return true;
73+
}
74+
75+
if (response.Length < 16)
76+
{
77+
return false;
78+
}
79+
80+
mapResponse = new NatPmpMapResponse(
81+
resultCode,
82+
NetworkUtils.ReadUInt16BigEndian(response, 8),
83+
NetworkUtils.ReadUInt16BigEndian(response, 10),
84+
NetworkUtils.ReadUInt32BigEndian(response, 12));
85+
return true;
86+
}
87+
88+
public static OpenPortStatus MapResultCode(ushort code) =>
89+
code switch
90+
{
91+
0 => OpenPortStatus.Success,
92+
1 => OpenPortStatus.NotSupported,
93+
2 => OpenPortStatus.Unauthorized,
94+
3 => OpenPortStatus.Failed,
95+
4 => OpenPortStatus.NoResources,
96+
5 => OpenPortStatus.NotSupported,
97+
_ => OpenPortStatus.Failed
98+
};
99+
100+
public static string GetResultName(ushort code) =>
101+
code switch
102+
{
103+
0 => "Success",
104+
1 => "UnsupportedVersion",
105+
2 => "NotAuthorized",
106+
3 => "NetworkFailure",
107+
4 => "OutOfResources",
108+
5 => "UnsupportedOpcode",
109+
_ => "Unknown"
110+
};
111+
112+
private static byte ToMapOpcode(PortProtocol protocol) =>
113+
protocol == PortProtocol.Udp ? UdpMapOpcode : TcpMapOpcode;
114+
}
115+
116+
internal readonly struct NatPmpMapResponse
117+
{
118+
public NatPmpMapResponse(ushort resultCode, int internalPort, int externalPort, uint lifetimeSeconds)
119+
{
120+
ResultCode = resultCode;
121+
InternalPort = internalPort;
122+
ExternalPort = externalPort;
123+
LifetimeSeconds = lifetimeSeconds;
124+
}
125+
126+
public ushort ResultCode { get; }
127+
public int InternalPort { get; }
128+
public int ExternalPort { get; }
129+
public uint LifetimeSeconds { get; }
130+
}

0 commit comments

Comments
 (0)