Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
226 changes: 217 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# Yllibed HttpServer
This is a versatile http server designed to be used in mobile/UWP applications.

This is a versatile http server designed to be used in mobile/UWP applications and any applications which need to expose a simple web server.

## Packages and NuGet Statistics

Expand All @@ -17,9 +18,34 @@ This is a versatile http server designed to be used in mobile/UWP applications.

2. Register a server in your app:
```csharp
var myServer = new Server(8080); // create a web server on port 8080
myServer.RegisterHandler(new StaticHandler("/", "text/plain", "Hello, world!")); // register a handler for the root
var (uri4, uri6) = myServer.Start(); // start the server and get the URIs
var myServer = new Server(); // Uses dynamic port (recommended)
myServer.RegisterHandler(new StaticHandler("/", "text/plain", "Hello, world!"));
var (uri4, uri6) = myServer.Start(); // Get the actual URIs with assigned ports

Console.WriteLine($"Server running on: {uri4}");
```

Or specify a fixed port:
```csharp
var myServer = new Server(8080); // Fixed port
myServer.RegisterHandler(new StaticHandler("/", "text/plain", "Hello, world!"));
var (uri4, uri6) = myServer.Start();
```

Or with dependency injection and configuration:
```csharp
var services = new ServiceCollection();
services.AddYllibedHttpServer(opts =>
{
opts.Port = 0; // Dynamic port (recommended)
opts.Hostname4 = "127.0.0.1";
opts.Hostname6 = "::1";
});

var sp = services.BuildServiceProvider();
var server = sp.GetRequiredService<Server>();
server.RegisterHandler(new StaticHandler("/", "text/plain", "Hello, world!"));
var (uri4, uri6) = server.Start();
```

## What it is
Expand All @@ -30,17 +56,158 @@ This is a versatile http server designed to be used in mobile/UWP applications.
* This HTTP server is not designed for performance or high capacity
* It's perfect for small applications, or small need, like to act as _return url_ for OAuth2 authentication using external browser.

## Features
* Simple, lightweight, self-contained HTTP server
* Supports IPv4 and IPv6
* Supports HTTP 1.1 (limited: no keep-alive, no chunked encoding)
* Supports GET, POST, PUT, DELETE, HEAD, OPTIONS, TRACE, PATCH - even custom methods
* Supports static files
* Supports custom headers
* Supports custom status codes
* Supports custom content types
* Supports custom content encodings
* Supports dependency injection and configuration via `IOptions<ServerOptions>`
* Configurable bind addresses and hostnames for IPv4/IPv6
* Supports dynamic port assignment

## Common use cases
* Return URL for OAuth2 authentication using external browser
* Remote diagnostics/monitoring on your app
* Building a headless Windows IoT app (for SSDP discovery or simply end-user configuration)
* Any other use case where you need to expose a simple web server

## Limitations
* There is no support for HTTP 2.0+ (yet) or WebSockets
* There is no support for HTTPS (TLS)

## What you can do with it
* Use it for building a headless Windows IoT app (for SSDP discovery or simply end-user configuration)
* Use it for remote diagnostics/monitoring on your app
## Security and Intended Use (No TLS)
This server uses plain HTTP with no transport encryption. It is primarily intended for:
- Localhost usage (loopback) during development or as an OAuth redirect target.
- Internal communication on trusted networks, e.g., between Docker containers on the same host or in a private overlay network.
- Embedded/local scenarios (IoT, diagnostics) where transport security is handled elsewhere.

If you need to expose it on a public or untrusted network:
- Put it behind a reverse proxy that terminates TLS (e.g., Nginx, Traefik, Caddy, IIS/ASP.NET Core reverse proxy) and forward to this server over HTTP on a private interface.
- Alternatively, use a secure tunnel (SSH, Cloudflare Tunnel, etc.).
- Bind to loopback only (127.0.0.1 / ::1) when you want to ensure local-only access.

Note: Authentication/authorization is not built-in; implement it in your handlers or at the proxy layer as needed.

## Configuration and Dependency Injection

The server can now be configured via a `ServerOptions` POCO. You can construct `Server` directly with a `ServerOptions` instance, or register it with Microsoft.Extensions.DependencyInjection using `IOptions<ServerOptions>`.

### ServerOptions Properties

* `Port` - Port number to listen to (0 = dynamic)
* `BindAddress4` - IPv4 address to bind the listener to (defaults to `IPAddress.Any`)
* `BindAddress6` - IPv6 address to bind the listener to (defaults to `IPAddress.IPv6Any`)
* `Hostname4` - Hostname used to compose the public IPv4 URI (defaults to "127.0.0.1")
* `Hostname6` - Hostname used to compose the public IPv6 URI (defaults to "::1")

### Dynamic Port Assignment (Recommended)

Using port `0` enables **dynamic port assignment**, which is the **recommended approach** for most applications:

**Key Advantages:**
- **Zero port conflicts**: The operating system automatically assigns an available port
- **Perfect for testing**: Multiple test instances can run simultaneously without conflicts
- **Microservices architecture**: Each service gets its own unique port automatically
- **Team collaboration**: No need to coordinate port assignments between developers
- **CI/CD friendly**: Parallel builds and tests work seamlessly

**Best Practices with Dynamic Ports:**

```csharp
// ✅ Recommended: Use dynamic ports (default behavior)
var server = new Server(); // Port 0 by default
server.RegisterHandler(new StaticHandler("/", "text/plain", "Hello!"));
var (uri4, uri6) = server.Start();

// Capture the actual assigned port for logging or service discovery
var actualPort = new Uri(uri4).Port;
Console.WriteLine($"Server started on port: {actualPort}");
Console.WriteLine($"Access your service at: {uri4}");

// For service registration with discovery systems
await RegisterWithServiceDiscovery(uri4);
```

**In production with Dependency Injection:**

# Tips
```csharp
services.AddYllibedHttpServer(); // Uses port 0 by default - no conflicts!

## Opening port on Windows 10 IoT (typically on a Raspberry Pi)
// Or be explicit for documentation purposes:
services.AddYllibedHttpServer(opts =>
{
opts.Port = 0; // Dynamic port - recommended
opts.BindAddress4 = IPAddress.Any; // Accept from all interfaces
opts.Hostname4 = Environment.MachineName; // Use machine name in URIs
});
```

**When NOT to use dynamic ports:**
- Public-facing web services requiring well-known ports (80, 443)
- Legacy systems expecting fixed ports
- Load balancer configurations requiring static endpoints
- Development scenarios where you need predictable URLs

### Basic Configuration Example

```csharp
var serverOptions = new ServerOptions
{
Port = 5000,
Hostname4 = "192.168.1.100", // Custom hostname for callbacks
BindAddress4 = IPAddress.Any // Listen on all interfaces
};

var server = new Server(serverOptions);
server.Start();
```

### Dependency Injection Example

The cleanest approach using extension methods:

```csharp
var services = new ServiceCollection();
services.AddYllibedHttpServer(opts =>
{
opts.Port = 5000;
opts.Hostname4 = "127.0.0.1";
opts.Hostname6 = "::1";
opts.BindAddress4 = IPAddress.Parse("0.0.0.0");
});

var sp = services.BuildServiceProvider();
var server = sp.GetRequiredService<Server>();
server.Start();
```

Alternative using `services.Configure<>()` and automatic constructor selection:

```csharp
var services = new ServiceCollection();
services.Configure<ServerOptions>(opts =>
{
opts.Port = 5000;
opts.Hostname4 = "127.0.0.1";
opts.Hostname6 = "::1";
});
services.AddSingleton<Server>(); // Uses IOptions<ServerOptions> constructor automatically

var sp = services.BuildServiceProvider();
var server = sp.GetRequiredService<Server>();
server.Start();
```

This allows you to control the bind addresses and hostnames used to compose the public URIs that the server logs, which is especially useful for OAuth callbacks and REST API applications.

## Tips

### Opening port on Windows 10 IoT (typically on a Raspberry Pi)
If you want to open "any" port on a Raspberry Pi running Windows 10 IoT, you may
need to open a port.

Expand All @@ -52,3 +219,44 @@ need to open a port.
```shell
netsh advfirewall firewall add rule name="My Application Webserver" dir=in action=allow protocol=TCP localport=8080
```

### OAuth2 Callback Configuration
When using this server for OAuth2 callbacks, dynamic ports are especially useful:

```csharp
services.AddYllibedHttpServer(); // Dynamic port prevents conflicts

var server = serviceProvider.GetRequiredService<Server>();
server.RegisterHandler(new MyOAuthCallbackHandler());
var (uri4, uri6) = server.Start();

// Use the actual URI for OAuth redirect registration
var redirectUri = $"{uri4}/oauth/callback";
Console.WriteLine($"Register this redirect URI with your OAuth provider: {redirectUri}");

// No port conflicts even if multiple OAuth flows run simultaneously!
```

For scenarios requiring fixed callback URLs:
```csharp
services.Configure<ServerOptions>(opts =>
{
opts.Port = 5001;
opts.Hostname4 = "127.0.0.1"; // Match your OAuth redirect URI
opts.BindAddress4 = IPAddress.Loopback; // Only accept local connections
});
```

### Listening on All Interfaces
To accept connections from other machines on your network:

```csharp
var serverOptions = new ServerOptions
{
Port = 8080,
BindAddress4 = IPAddress.Any, // Listen on all IPv4 interfaces
BindAddress6 = IPAddress.IPv6Any, // Listen on all IPv6 interfaces
Hostname4 = "192.168.1.100", // Your actual IP for public URIs
Hostname6 = "::1" // IPv6 loopback
};
```
34 changes: 15 additions & 19 deletions Yllibed.HttpServer.Json.Tests/FixtureBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,26 +4,22 @@
using System.Threading;
using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace Yllibed.HttpServer.Json.Tests
{
public class FixtureBase
{
protected CancellationToken CT;
private CancellationTokenSource _ctSource;
namespace Yllibed.HttpServer.Json.Tests;

[TestInitialize]
public void Initialize()
{
_ctSource = Debugger.IsAttached
? new CancellationTokenSource()
: new CancellationTokenSource(TimeSpan.FromSeconds(10));
CT = _ctSource.Token;
}
public class FixtureBase
{
protected CancellationToken CT;
private CancellationTokenSource _ctSource;

[TestCleanup]
public void Terminate()
{
_ctSource.Dispose();
}
[TestInitialize]
public void Initialize()
{
_ctSource = Debugger.IsAttached
? new CancellationTokenSource()
: new CancellationTokenSource(TimeSpan.FromSeconds(10));
CT = _ctSource.Token;
}

[TestCleanup]
public void Terminate() => _ctSource.Dispose();
}
74 changes: 39 additions & 35 deletions Yllibed.HttpServer.Json.Tests/JsonHandlerBaseFixture.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,59 +7,63 @@
using FluentAssertions;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Newtonsoft.Json;
using Yllibed.HttpServer.Json;

namespace Yllibed.HttpServer.Json.Tests
namespace Yllibed.HttpServer.Json.Tests;

[TestClass]
public class JsonHandlerBaseFixture : FixtureBase
{
[TestClass]
public class JsonHandlerBaseFixture : FixtureBase
[TestMethod]
public async Task TestSimpleGet_WithJsonHandler()
{
[TestMethod]
public async Task TestSimpleGet_WithJsonHandler()
{
var server = new Server();
var server = new Server();

var (uri4, uri6) = server.Start();
var requestUri = uri4 + "abcd?a=1&b=%20%2020";
var (uri4, uri6) = server.Start();
var requestUri = uri4 + "abcd?a=1&b=%20%2020";

var sut = new MyJsonHandler("GET", "abcd");
var sut = new MyJsonHandler("GET", "abcd");

using (server.RegisterHandler(sut))
using (server.RegisterHandler(sut))
{
using (var client = new HttpClient())
{
using (var client = new HttpClient())
{
var response = await client.GetAsync(requestUri, CT);
response.StatusCode.Should().Be(HttpStatusCode.OK);
var response = await client.GetAsync(requestUri, CT);
response.StatusCode.Should().Be(HttpStatusCode.OK);

var result = JsonConvert.DeserializeObject<MyResultPayload>(await response.Content.ReadAsStringAsync(CT));
result.Should().NotBeNull();
result.Should().BeEquivalentTo(new { A = "1", B = " 20" });
}
var result = JsonConvert.DeserializeObject<MyResultPayload>(await response.Content.ReadAsStringAsync(CT).ConfigureAwait(false));
result.Should().NotBeNull();
result.Should().BeEquivalentTo(new { A = "1", B = " 20" });
}
}
}

public class MyResultPayload
public class MyResultPayload
{
public string? A { get; set; }
public string? B { get; set; }
}

private sealed class MyJsonHandler : JsonHandlerBase<MyResultPayload>
{
public MyJsonHandler(string method, string path) : base(method, path)
{
public string? A { get; set; }
public string? B { get; set; }
}

Comment on lines +47 to 52
Copy link

Copilot AI Sep 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The constructor change from primary constructor syntax to explicit constructor appears to be a regression. Consider keeping the more concise primary constructor syntax: private sealed class MyJsonHandler(string method, string path) : JsonHandlerBase<MyResultPayload>(method, path)

Suggested change
private sealed class MyJsonHandler : JsonHandlerBase<MyResultPayload>
{
public MyJsonHandler(string method, string path) : base(method, path)
{
public string? A { get; set; }
public string? B { get; set; }
}
private sealed class MyJsonHandler(string method, string path) : JsonHandlerBase<MyResultPayload>(method, path)
{

Copilot uses AI. Check for mistakes.
private sealed class MyJsonHandler(string method, string path) : JsonHandlerBase<MyResultPayload>(method, path)
protected override async Task<(MyResultPayload result, ushort statusCode)> ProcessRequest(CancellationToken ct, string relativePath, IDictionary<string, string[]> queryParameters)
{
protected override async Task<(MyResultPayload result, ushort statusCode)> ProcessRequest(CancellationToken ct, string relativePath, IDictionary<string, string[]> queryParameters)
{
queryParameters.TryGetValue("a", out var a);
queryParameters.TryGetValue("b", out var b);
queryParameters.TryGetValue("a", out var a);
queryParameters.TryGetValue("b", out var b);

await Task.Yield();
await Task.Yield();

var result = new MyResultPayload
{
A = a?.FirstOrDefault(),
B = b?.FirstOrDefault(),
};
var result = new MyResultPayload
{
A = a?.FirstOrDefault(),
B = b?.FirstOrDefault(),
};

return (result, 200);
}
return (result, 200);
}
}
}
Loading
Loading