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
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,20 +70,22 @@ MSI を先に入れてもいい。設定が無いとサービスの起動に失
| `acme.email` | Let's Encrypt の連絡先。`acme.staging: true` で試験用の環境を使う |
| `oidc` | `issuer`、`client_id`、`client_secret`(または `client_secret_file`。1 行目を読む)。`required_role` を書くと、ID トークンの `roles` にその値が無いユーザーは入れない |
| `targets` | つなぐ先。`name`、`host`、`port`(既定 3389)。1 つだけなら選択欄は出ない。省略するとこのマシン(127.0.0.1:3389) |
| `listen` | 待ち受け。既定は `{ "https": 443, "http": 80, "hostname": "::" }`(IPv4 も受ける) |
| `status.port` | ローカルからだけ使える状態の API(既定 8788、`0` で無効)。`GET /status` で版、接続数、証明書の残り日数、更新の有無を返す。`POST /update`(ヘッダー `x-mado: 1`)で更新をすぐ確認する。タスクトレイが使う |
| `listen` | 待ち受け。既定は `{ "https": 443, "http": 80, "hostname": "::" }`(IPv4 も受ける)。MSI が開けるファイアウォールは TCP 80 / 443 と UDP 443 だけなので、port を変えたら自分で開ける |
| `status.port` | ローカルからだけ使える状態の API(既定 8788、`0` で無効)。`GET /status` で版、外向きの origin、起動した時刻、接続数、証明書の残り日数、更新の有無を返す。`POST /update`(ヘッダー `x-mado: 1`)で更新をすぐ確認する。タスクトレイが使う |
| `update` | 自動更新。既定は `{ "enabled": true, "check_hours": 6, "repo": "danything/mado" }` |
| `tls` | `"off"` で TLS と ACME を使わず http で待ち受ける。localhost で試すとき用 |
| `webtransport` | WebTransport の UDP の port。既定は `tls` が `acme` のとき https と同じ port(443)。`{ "port": 8443 }` で変え、`false` か port `0` で使わない。msquic が読めないときや UDP で待ち受けられないときは、ログに書いて WebSocket だけで動く |

IdP には戻り先として `https://<host>/callback` を登録する。Entra ID ならアプリロールを作ってユーザーに割り当て、その値を `required_role` に書く。
`/logout` は mado のセッションの Cookie を消すだけで、IdP からはログアウトしない。

ログは `C:\ProgramData\mado\logs\mado.log`。10 MB を超えると `.1` に移す。

## 画面

- 解像度は窓の大きさに合わせ、窓を変えると追従する。回線や端末が追いつかないときは解像度を 75%、50% に下げ、余裕が戻ったら上げる
- 画面の転送方式は自動で選ぶ。GFX を使い、ブラウザが H.264 を復号できれば H.264 も使う。接続先が GFX に対応していなければ旧来のビットマップ / RemoteFX になる。途中で復号に失敗したら、一段下の方式でつなぎ直す
- 送る量を減らすため、壁紙、ウィンドウをドラッグ中の中身、メニューのアニメーション、テーマ、カーソルの影を切ってもらう。GFX を使えない相手では、RDP の一括圧縮(MPPC)も使う
- キーボードは OS の配列から JIS か US かを判定して接続先に伝える
- つなぐ先ごとに、前に使った Windows のユーザー名を覚えておく。まだ使っていない先では、最後に使ったユーザー名を入れておく
- ツールバー: Ctrl+Alt+Del、Windows キー、診断、全画面、切断。診断は方式や負荷、直近のエラーを左下に出す。クリップボードはテキストだけ共有する
Expand All @@ -103,7 +105,7 @@ IdP には戻り先として `https://<host>/callback` を登録する。Entra I
秘密鍵は GitHub の secret `MADO_SIGNING_KEY`、公開鍵は exe に入っている(`server/Updater.cs` の `PublicKey`)。
タグも署名に含むので、古い版を最新と偽って入れさせることはできない。今より新しい版しか受け付けない
- 誰もつないでいないときに入れる。`msiexec` でメジャー アップグレードし、サービスを止めて入れ替え、起動し直す。
MSI は `C:\ProgramData\mado\data\updates\`、ログは `logs\msi-<タグ>.log` に置く
MSI は `C:\ProgramData\mado\data\updates\`、ログは mado.log と同じ `logs` に `msi-<タグ>.log` として置く
- 署名の鍵を作り直したときは、新しい公開鍵を `PublicKey` に入れてリリースし、その版だけは手で入れる。古い mado は新しい鍵の署名を受け付けない

## 開発
Expand Down
3 changes: 2 additions & 1 deletion docs/rdp-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,9 @@ WebSocket に移るときは、`newToken` でトークンを取り直す。mado
- H.264 を使わないときも、Windows は ClearCodec(NSCodec を含む)と RemoteFX Progressive(写真やスクロールで新しく見えた部分)で送る
- 版 8.1(AVC420)だけを宣言すると、Windows は版 8 を選んで H.264 を使わなかった。そのため版 10 以上も宣言する。宣言する組み合わせは FreeRDP の `rdpgfx_send_supported_caps` と同じ。選ばれた版は診断の「GFX: 版=」で分かる
- Progressive は、同じフレームの前の PDU で送ったタイルを、後の PDU の矩形にも描かせる(矩形だけでタイルの無い PDU も来る。符号化の文脈 codecContextId を並行して使う)。そのため、PDU ごとに「このフレームで復号したタイル全部」をその PDU の矩形で切って描く(FreeRDP の `update_tiles` と同じ)。その PDU のタイルだけを描くと、窓を動かしたあとの空いたところが描かれずに残像になった
- 接続の種類(Client Core の connectionType)を AUTODETECT にすると、Windows は Client Info の performanceFlags を無視して見た目を自分で決め、壁紙も出した。そのため connectionType は LAN にし、回線の測定は SUPPORT_NETCHAR_AUTODETECT だけで続ける。performanceFlags では、壁紙、ドラッグ中の中身、メニューのアニメーション、テーマ、カーソルの影を切る
- 版 10.7 を選んだ Windows は、既定の設定では静止した画面を ClearCodec / Progressive で送り、H.264 は動きの多い部分に使う
- グループ ポリシー「H.264/AVC 444 グラフィックス モードを優先する」を有効にすると、全面を AVC444 で送ってくる。ブラウザ側は 2 本の復号と CPU での合成で 1 フレーム約 20ms かかり、動きが続くと遅延が溜まる。既定の設定のほうが軽い
- グループ ポリシー「H.264/AVC 444 グラフィックス モードを優先する」を有効にすると、全面を AVC444 で送ってくる。ブラウザ側は 2 本の復号と面の読み出しで 1 フレーム約 15ms かかる(合成は GPU で約 1ms)。動きが続くと遅延が溜まるので、既定の設定のほうが軽い
- GPU を割り当てた VM(`tools/hyperv-test/add-gpu.ps1`)で「すべてのリモート デスクトップ サービス セッションでハードウェアの既定のグラフィックス アダプターを使用する」を有効にすると、接続から最初の画面まで約 40 秒かかった。無効なら約 3 秒

## AVC444
Expand Down
2 changes: 1 addition & 1 deletion installer/mado.wxs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
設定が無いうちはサービスの起動が失敗するが、起動は待たないので、インストールは止まらない。

ビルド: wix build installer/mado.wxs -arch x64 -ext WixToolset.Firewall.wixext -ext WixToolset.Util.wixext
-d Version=0.4.0 -d PublishDir=publish -o dist/mado.msi
-d Version=<版> -d PublishDir=publish -o dist/mado.msi(CI の release.yml と同じ)
-->
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs"
xmlns:fw="http://wixtoolset.org/schemas/v4/wxs/firewall"
Expand Down
25 changes: 16 additions & 9 deletions server/Acme.cs
Original file line number Diff line number Diff line change
Expand Up @@ -204,22 +204,29 @@ async Task<string> NewNonce()
static string B64(string s) => Base64Url.EncodeToString(Encoding.UTF8.GetBytes(s));

/// <summary>PEM の最初の証明書の残り日数</summary>
public static double DaysLeft(string pem) =>
(X509Certificate2.CreateFromPem(pem).NotAfter.ToUniversalTime() - DateTime.UtcNow).TotalDays;
public static double DaysLeft(string pem)
{
using var cert = X509Certificate2.CreateFromPem(pem);
return (cert.NotAfter.ToUniversalTime() - DateTime.UtcNow).TotalDays;
}

/// <summary>PEM の 2 つ目からの証明書。Let's Encrypt の中間証明書</summary>
public static X509Certificate2Collection Intermediates(string certPem)
{
var all = new X509Certificate2Collection();
all.ImportFromPem(certPem);
if (all.Count > 0) all.RemoveAt(0);
return all;
}

/// <summary>
/// Kestrel に渡す形にする。Windows の SChannel は PEM から作った一時的な鍵を受けないので、一度 PKCS#12 にして読み直す。
/// Let's Encrypt の中間証明書も一緒に送る
/// 中間証明書も一緒に送る
/// </summary>
public static SslStreamCertificateContext Context(string keyPem, string certPem)
{
using var leaf = X509Certificate2.CreateFromPem(certPem, keyPem);
var cert = X509CertificateLoader.LoadPkcs12(leaf.Export(X509ContentType.Pkcs12), null);
var all = new X509Certificate2Collection();
all.ImportFromPem(certPem);
var intermediates = new X509Certificate2Collection();
foreach (var c in all)
if (c.Thumbprint != cert.Thumbprint) intermediates.Add(c);
return SslStreamCertificateContext.Create(cert, intermediates, offline: true);
return SslStreamCertificateContext.Create(cert, Intermediates(certPem), offline: true);
}
}
4 changes: 2 additions & 2 deletions server/Mado.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<ImplicitUsings>enable</ImplicitUsings>
<AssemblyName>mado</AssemblyName>
<RootNamespace>Mado</RootNamespace>
<Version>0.8.4</Version>
<Version>0.8.5</Version>
<!-- 常駐 1 プロセスで動的コードも無いので Native AOT。exe 1 つで .NET を入れなくても動く -->
<PublishAot>true</PublishAot>
<InvariantGlobalization>true</InvariantGlobalization>
Expand All @@ -32,7 +32,7 @@
<PropertyGroup>
<MsQuicDll>$(PkgMicrosoft_Native_Quic_MsQuic_Schannel)/build/native/bin/x64/msquic.dll</MsQuicDll>
<MsQuicDll Condition="$(RuntimeIdentifier.EndsWith('-arm64'))">$(PkgMicrosoft_Native_Quic_MsQuic_Schannel)/build/native/bin/arm64/msquic.dll</MsQuicDll>
<!-- Windows 向けのときだけ。Linux では libmsquic(パッケージの libmsquic)を使う -->
<!-- Windows 向けのときだけ。Linux では OS に入れた libmsquic を使う -->
<WithMsQuic Condition="$(RuntimeIdentifier.StartsWith('win')) or ('$(RuntimeIdentifier)' == '' and $([MSBuild]::IsOSPlatform('Windows')))">true</WithMsQuic>
</PropertyGroup>
<Target Name="CopyMsQuicBuild" AfterTargets="Build" Condition="'$(WithMsQuic)' == 'true'">
Expand Down
8 changes: 1 addition & 7 deletions server/MadoJson.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ namespace Mado;
[JsonSerializable(typeof(Config.Raw))]
internal sealed partial class MadoJson : JsonSerializerContext;

/// <summary>API の返事、署名付きの値、OIDC・ACME・GitHub の API。名前は変換しない。camelCase か snake_case かは型の側で書く</summary>
/// <summary>API の返事、署名付きの値、OIDC と GitHub の API。名前は変換しない。camelCase か snake_case かは型の側で書く。ACME は JsonNode で読み書きする</summary>
[JsonSourceGenerationOptions(DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
[JsonSerializable(typeof(SessionInfo))]
[JsonSerializable(typeof(TokenInfo))]
Expand All @@ -29,12 +29,6 @@ internal sealed partial class MadoJson : JsonSerializerContext;
[JsonSerializable(typeof(Jwks))]
[JsonSerializable(typeof(JwtHeader))]
[JsonSerializable(typeof(JwtClaims))]
[JsonSerializable(typeof(JsonElement))]
[JsonSerializable(typeof(Dictionary<string, JsonElement>))]
[JsonSerializable(typeof(Dictionary<string, string>))]
[JsonSerializable(typeof(Dictionary<string, object>))]
[JsonSerializable(typeof(object[]))]
[JsonSerializable(typeof(string[]))]
internal sealed partial class ApiJson : JsonSerializerContext;

internal sealed record SessionInfo(
Expand Down
42 changes: 18 additions & 24 deletions server/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@
// https://<host>/ 画面。web/ の RDP クライアントを bun でビルドして exe に埋め込んである
// https://<host>/login OIDC のログインへ
// https://<host>/callback OIDC から戻ってくる口
// https://<host>/logout ログアウト
// https://<host>/api/session ログイン中の名前・版・つなぐ先
// https://<host>/api/token ?target=<name>。つなぐ直前に取る使い捨ての中継のトークン
// wss://<host>/ws RDCleanPath の中継。Relay.cs
// https://<host>/wt (UDP) 同じ中継を WebTransport(HTTP/3)で。WebTransportRelay.cs と Quic/
// http://<host>/.well-known/acme-challenge/* Let's Encrypt の確認。80 番のほかの道は https へ回す
// http://127.0.0.1:8788/status この PC の中からだけ見られる状態
// http://127.0.0.1:8788/status この PC の中からだけ見られる状態。POST /update で更新をすぐ確かめる

using System.Collections.Concurrent;
using System.Net;
Expand All @@ -29,7 +30,8 @@
return 1;
}

var log = new FileLog(Path.Combine(Path.GetDirectoryName(config.File)!, "logs", "mado.log"));
var logDir = Path.Combine(Path.GetDirectoryName(config.File)!, "logs");
var log = new FileLog(Path.Combine(logDir, "mado.log"));
var version = "v" + (typeof(Config).Assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion
.Split('+')[0] ?? "0.0.0");
log.Write($"mado {version}: {config.File} を読んだ({config.Host}、つなぐ先 {string.Join(' ', config.Targets.Select(t => $"{t.Name}={t.Host}:{t.Port}"))})");
Expand All @@ -53,8 +55,9 @@
SslStreamCertificateContext? current = null;
int? certDaysLeft = null;
var sessions = 0;
void CountSession(int d) => Interlocked.Add(ref sessions, d);
var startedAt = DateTimeOffset.UtcNow.ToString("o");
var updater = new Updater(config.Update.Repo, version, config.DataDir, http, log.Write);
var updater = new Updater(config.Update.Repo, version, config.DataDir, logDir, http, log.Write);
// 自分で版を上げるのはサービスとして動いているときだけ。手元の dotnet run には入れる MSI が無い
var canUpdate = config.Update.Enabled && OperatingSystem.IsWindows()
&& Microsoft.Extensions.Hosting.WindowsServices.WindowsServiceHelpers.IsWindowsService();
Expand All @@ -66,14 +69,15 @@
});
builder.Host.UseWindowsService(o => o.ServiceName = "mado");
builder.Logging.ClearProviders();
// 待ち受けるアドレス。null は全部。WebTransport も同じ
var listenIp = config.Listen.Hostname is "::" or "0.0.0.0" or "" ? null : IPAddress.Parse(config.Listen.Hostname);
builder.WebHost.ConfigureKestrel(k =>
{
var any = config.Listen.Hostname is "::" or "0.0.0.0" or "" ? null : IPAddress.Parse(config.Listen.Hostname);
void Listen(int port, Action<Microsoft.AspNetCore.Server.Kestrel.Core.ListenOptions>? configure = null)
{
// IPv6Any はデュアルスタックなので IPv4 も受ける
if (any is null) k.ListenAnyIP(port, configure ?? (_ => { }));
else k.Listen(any, port, configure ?? (_ => { }));
if (listenIp is null) k.ListenAnyIP(port, configure ?? (_ => { }));
else k.Listen(listenIp, port, configure ?? (_ => { }));
}
Listen(config.Listen.Http);
if (secure)
Expand All @@ -93,6 +97,7 @@ void Listen(int port, Action<Microsoft.AspNetCore.Server.Kestrel.Core.ListenOpti

IResult Text(int status, string body) => Results.Text(body, "text/plain; charset=utf-8", statusCode: status);
User? SessionOf(HttpContext ctx) => signer.Verify(ctx.Request.Cookies[SessionCookie], ApiJson.Default.User);
IResult NeedLogin() => Results.Json(new LoginRequired("/login?to=/"), ApiJson.Default.LoginRequired, statusCode: 401);
CookieOptions Cookie(int maxAge) => new()
{
HttpOnly = true, Secure = secure, SameSite = SameSiteMode.Lax, Path = "/", MaxAge = TimeSpan.FromSeconds(maxAge),
Expand All @@ -106,12 +111,11 @@ void Listen(int port, Action<Microsoft.AspNetCore.Server.Kestrel.Core.ListenOpti
WebTransportServer? wt = null;
if (config.WebTransportPort != 0)
{
var ip = config.Listen.Hostname is "::" or "0.0.0.0" or "" ? null : IPAddress.Parse(config.Listen.Hostname);
// CONNECT でトークンを使う。Cookie が無いので sub は見ない(WebTransportRelay.cs)
var handler = new WebTransportRelay(origin, token =>
tokens.Consume(token, null) is { } t && TargetOf(t.Target) is { } target ? (target, t.Name ?? t.Sub) : null,
log.Write, d => Interlocked.Add(ref sessions, d), app.Lifetime.ApplicationStopping);
wt = WebTransportServer.TryCreate(handler, ip, (ushort)config.WebTransportPort, log.Write);
log.Write, CountSession, app.Lifetime.ApplicationStopping);
wt = WebTransportServer.TryCreate(handler, listenIp, (ushort)config.WebTransportPort, log.Write);
app.Lifetime.ApplicationStopping.Register(() => wt?.Dispose());
}
var wtUrl = $"https://{config.Host}{(config.WebTransportPort == 443 ? "" : $":{config.WebTransportPort}")}{WebTransportRelay.Path}";
Expand Down Expand Up @@ -197,15 +201,15 @@ void Listen(int port, Action<Microsoft.AspNetCore.Server.Kestrel.Core.ListenOpti
app.MapGet("/api/session", (HttpContext ctx) =>
{
ctx.Response.Headers.CacheControl = "no-store";
if (SessionOf(ctx) is not { } s) return Results.Json(new LoginRequired("/login?to=/"), ApiJson.Default.LoginRequired, statusCode: 401);
if (SessionOf(ctx) is not { } s) return NeedLogin();
var ws = (origin.StartsWith("https") ? "wss" : "ws") + origin[(origin.IndexOf(':'))..] + "/ws";
return Results.Json(new SessionInfo(s.Name, version, [.. config.Targets.Select(t => t.Name)], ws, wt is { IsListening: true } ? wtUrl : null), ApiJson.Default.SessionInfo);
});

app.MapGet("/api/token", (HttpContext ctx) =>
{
ctx.Response.Headers.CacheControl = "no-store";
if (SessionOf(ctx) is not { } s) return Results.Json(new LoginRequired("/login?to=/"), ApiJson.Default.LoginRequired, statusCode: 401);
if (SessionOf(ctx) is not { } s) return NeedLogin();
if (TargetOf(ctx.Request.Query["target"]) is not { } target) return Text(404, "そのつなぐ先は無い");
var token = tokens.Issue(new RelayToken(s.Sub, target.Name, Oidc.RandomToken(16), s.Name));
return Results.Json(new TokenInfo(token), ApiJson.Default.TokenInfo);
Expand All @@ -218,23 +222,13 @@ void Listen(int port, Action<Microsoft.AspNetCore.Server.Kestrel.Core.ListenOpti
if (SessionOf(ctx) is not { } user) { await Text(401, "ログインしていない").ExecuteAsync(ctx); return; }
if (!ctx.WebSockets.IsWebSocketRequest) { await Text(400, "WebSocket で来て").ExecuteAsync(ctx); return; }
using var ws = await ctx.WebSockets.AcceptWebSocketAsync();
Interlocked.Increment(ref sessions);
log.Write($"ws open: {user.Name}");
try
{
await Relay.Run(ws, auth => tokens.Consume(auth, user.Sub) is { } t ? TargetOf(t.Target) : null, m => log.Write($"{user.Name}: {m}"), ctx.RequestAborted);
}
finally
{
Interlocked.Decrement(ref sessions);
log.Write($"ws close: {user.Name}");
}
await Relay.Serve("ws", user.Name, new WebSocketChannel(ws), auth => tokens.Consume(auth, user.Sub) is { } t ? TargetOf(t.Target) : null,
log.Write, CountSession, ctx.RequestAborted);
});

app.MapGet("/{**path}", async (HttpContext ctx) =>
{
if (!WebAssets.Has(ctx.Request.Path.Value ?? "/")) { await Text(404, "not found").ExecuteAsync(ctx); return; }
await WebAssets.Serve(ctx);
if (!await WebAssets.TryServe(ctx)) await Text(404, "not found").ExecuteAsync(ctx);
});

if (secure)
Expand Down
Loading
Loading