diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 0000000..28b413a --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-ef": { + "version": "10.0.12", + "commands": ["dotnet-ef"], + "rollForward": false + } + } +} diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 13c434f..06627d2 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -18,6 +18,8 @@ jobs: runtime: win-x64 - os: ubuntu-latest runtime: linux-x64 + - os: ubuntu-latest + runtime: linux-bionic-arm64 runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 @@ -38,3 +40,35 @@ jobs: name: test-results-${{ matrix.runtime }} path: artifacts/test-results/ if-no-files-found: ignore + + postgres: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:18 + env: + POSTGRES_USER: workspace_test + POSTGRES_PASSWORD: workspace-test-only + POSTGRES_DB: workspace_access_test + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U workspace_test -d workspace_access_test" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + env: + WORKSPACE_ACCESS_TEST_DATABASE: Host=127.0.0.1;Port=5432;Database=workspace_access_test;Username=workspace_test;Password=workspace-test-only;Timeout=5 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + - name: Verify with PostgreSQL 18 + run: bash ./build.sh --target Verify --locked-restore + - uses: actions/upload-artifact@v4 + if: always() + with: + name: test-results-postgres + path: artifacts/test-results/ + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index 3d22d6d..7ddb875 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,7 @@ artifacts/ *.suo appsettings.Local.json .env +/workspace-access.config +temp/ +/ssh_key/ +/.cr/ diff --git a/.nuke/build.schema.json b/.nuke/build.schema.json index 95d81e8..47eb4f3 100644 --- a/.nuke/build.schema.json +++ b/.nuke/build.schema.json @@ -28,8 +28,8 @@ "Compile", "Publish", "Restore", - "RunAgent", - "RunHost", + "RunClient", + "RunServer", "Test", "Verify" ] @@ -115,7 +115,7 @@ }, "Runtime": { "type": "string", - "description": "Publish runtime: win-x64, win-arm64, linux-x64 or linux-arm64" + "description": "Publish runtime: win-x64, win-arm64, linux-x64, linux-arm64 or linux-bionic-arm64 (client only)" } } }, diff --git a/AGENTS.md b/AGENTS.md index 38fcd20..6b05d56 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,19 +2,19 @@ ## 入口 -阅读 `docs/overview.md`、`docs/architecture.md` 和涉及的 `docs/platforms/` 文档。当前实现状态以 `docs/implementation-status.md` 为准。 +阅读 `docs/overview.md`、`docs/architecture.md` 和涉及的 `docs/platforms/` 文档。实现状态以 `docs/implementation-status.md` 为准。 ## 实现 -- 使用 .NET 10、nullable 和集中包版本管理。 -- 通过 `build.ps1` 或 `build.sh` 调用 NUKE;交付前执行 `Verify`。 -- 产品项目为 `src/WorkspaceAccessHost.csproj`,代码直接在 `src/` 下通过目录和命名空间区分职责。 -- 领域逻辑保持操作系统与传输框架无关;平台调用分别放入 `Platforms/Windows`、`Platforms/Linux`。 -- 同一程序通过 `host`、`agent` 参数选择进程角色,按目标平台发布一套产品产物。 -- 连接接管、断线及输入派发遵守 host 独占和工作区归属规则。 -- 认证、授权、用户映射完成后才可调用连接控制核心。 -- 增加真实 PTY、IPC、OIDC 实现时,同步维护 readiness 和状态文档。 +- 使用 .NET 10、nullable、集中包版本管理和依赖锁文件。SDK 接受 .NET 10 或更高稳定版本。 +- 程序入口使用显式 `Program.Main`,不使用顶级语句。 +- 产品模块优先使用具体类型,通过目录和命名空间划分;只有确实需要多种实现或明确替换边界时才引入接口。 +- 服务端项目为 `src/server/WorkspaceAccessServer.csproj`,CLI 客户端项目为 `src/client/WorkspaceAccessClient.csproj`。 +- Server 以系统服务运行,管理 authentik 身份、SSH CA、内网 SSH 连接及工作区;不自行启动用户 Shell 或实现平台 PTY。 +- 每个工作区独立仲裁控制权。认证、授权和系统账户映射完成后才能获取控制权;接管与输入派发保持确定顺序。 +- 客户端断线不能直接关闭后端 SSH 工作区。Server 停止或内网 SSH 断线后的行为必须显式定义并测试。 +- 通过 `build.ps1` 或 `build.sh` 调用 NUKE,交付前执行 `Verify`。接入真实依赖时同步维护 readiness。 ## 文档 -文档库是面向所有仓库访问者的当前设计说明。使用中文正文和准确的 API 名称;描述目标行为、运行前提、实现状态和验收条件。保持总功能表中的每项平台差异与一份独立说明文档对应。新增页面加入导航,并通过 `CheckDocs`。 +文档库面向所有仓库访问者,使用中文正文和准确的 API 名称。描述目标行为、前提、实现状态与验收条件。总功能表的每项平台差异都链接一份独立说明文档;新增页面加入导航并通过 `CheckDocs`。 \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 80ba18d..931ac9e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,14 +1,7 @@ # 贡献指南 -首先阅读[总功能描述](docs/overview.md)、[架构](docs/architecture.md)与[开发流程](docs/development.md)。通过 `Verify` 后提交变更。 +首先阅读[总功能描述](docs/overview.md)、[架构](docs/architecture.md)与[开发流程](docs/development.md)。提交前运行 `Verify`。 -## 变更要求 +服务端代码放在 `src/server/`,CLI 客户端放在 `src/client/`。连接控制与身份边界应保持独立于 SSH 库和 WebSocket 框架。修改 Windows 或 Linux 的 SSH 部署行为时,同步更新对应平台文档和验收项。 -- 产品代码直接位于 `src/`,领域逻辑按职责放入目录,原生调用位于 `Platforms/Windows` 或 `Platforms/Linux`。 -- 修改平台行为时,同步更新总功能表和对应差异说明页。 -- 文档面向仓库访问者,描述当前设计、运行条件、行为和验收标准。 -- 新增身份、连接、生命周期行为时,为权限边界和并发结果增加测试。 -- 密钥通过部署环境配置管理;示例使用占位值。 -- 包版本集中维护在 `Directory.Packages.props`,依赖升级后更新锁文件并执行 `Verify`。 - -提交说明包含具体行为变化、验证结果及尚待平台环境验证的内容。 +包版本集中维护在 `Directory.Packages.props`。新增身份、控制权、断线或会话生命周期行为时,添加能够验证边界的测试。凭据通过部署环境管理,示例使用占位值。提交说明包含行为变化、验证结果和尚待平台环境验收的内容。 \ No newline at end of file diff --git a/Directory.Build.props b/Directory.Build.props index c1e01f2..78b8d3a 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,11 +1,16 @@ net10.0 + 0.1.0 enable enable true latest true true + $(MSBuildThisFileDirectory)temp/$(MSBuildProjectName)/bin/ + $(MSBuildThisFileDirectory)temp/$(MSBuildProjectName)/obj/ + $(BaseIntermediateOutputPath) + $(DefaultItemExcludes);**/bin/**;**/obj/** diff --git a/Directory.Packages.props b/Directory.Packages.props index 7f132d6..b2de1d4 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,9 +5,14 @@ + + + + + diff --git a/README.md b/README.md index 0aa5376..e4f7eb8 100644 --- a/README.md +++ b/README.md @@ -1,73 +1,41 @@ -# Workspace Access Host +# Workspace Access -面向 Windows 和 Linux 的持久终端工作区服务,使用 C# / .NET 10 开发,使用 NUKE 统一构建、测试、文档校验和发布。 +面向 Windows 和 Linux 目标的远程 SSH 工作区管理服务,使用 .NET 10 开发;CLI 客户端另支持 Android ARM64 的 Termux 终端发布。服务端与 CLI 客户端分别位于 `src/server/` 和 `src/client/`。 -产品设计:通过 authentik OIDC 认证后,经 WebSocket 连接宿主上的用户终端。每台宿主拥有一个有效远程控制连接,终端进程在连接断开期间持续运行。 +## 目标架构 -## 当前交付 +客户端通过 authentik OIDC 登录,经 HTTPS/WebSocket 连接 Server。Server 校验身份与目标系统账户映射,使用后端 SSH 凭据连接内网 Windows 或 Linux 的 `sshd`。Session Manager 持有每个工作区的 SSH 连接,客户端断线只解除附着;Server 持续消费输出,同一身份重连时重新附着。Windows OpenSSH 负责 ConPTY 和用户 Shell,Linux OpenSSH 负责 POSIX PTY。 -当前处于框架阶段。已提供服务与用户代理入口、平台契约、单连接控制核心及测试、部署模板和文档库。OIDC、WebSocket、代理 IPC、ConPTY / POSIX PTY 和画面恢复处于待实现阶段。服务启动后,存活检查返回 200,就绪检查返回 503 并列出集成项。 +SSH CA 是后端凭据的可选方案;正式用户登录使用 OIDC。首期工作区在 Server 与后端 SSH 连接存活期间持续运行;Server 重启或后端 SSH 断线后的恢复另行验收。 -## 快速开始 +## 当前状态 -安装 [global.json](global.json) 指定的 .NET SDK,在仓库根目录执行: +当前已实现 Server 服务入口、EF Core/PostgreSQL 授权、OIDC 令牌校验、密码 SSH Broker、Session Manager 与 WebSocket;Client 提供 `login` 登录验证及 `connect` 的资源选择、交互创建/重新附着。demo 的真实 authentik PKCE 登录已通过,正式 Client 登录和 Server 验签已实测通过,资源选择 → SSH 整链路仍待验收,托管后端 SSH 凭据、自动重连和画面恢复待完善。`/health/live` 返回 200,`/health/ready` 返回 503,检查真实数据库状态并列出待集成项,数据库配置与迁移见[数据库文档](docs/database.md)。 + +## 构建与运行 + +安装 .NET 10 或更高稳定版 SDK,在仓库根目录运行: ```powershell -# Windows PowerShell ./build.ps1 --target Verify -./build.ps1 --target RunHost -``` - -```bash -# Linux -bash ./build.sh --target Verify -bash ./build.sh --target RunHost -``` - -宿主默认监听 `http://127.0.0.1:5080`,健康检查为 `/health/live` 与 `/health/ready`。用户代理入口为 `RunAgent`。使用 Ctrl+C 结束前台开发进程。 - -发布目标平台的独立运行产物: - -```powershell +./build.ps1 --target RunServer +dotnet run --project src/client -- status --server http://127.0.0.1:5080 +dotnet run --project src/client -- login --server http://127.0.0.1:5080 +dotnet run --project src/client -- connect --server http://127.0.0.1:5080 ./build.ps1 --target Publish --runtime win-x64 -./build.ps1 --target Publish --runtime linux-x64 ``` -产物位于 `artifacts/publish//`,包含 `WorkspaceAccessHost` 程序、运行时依赖、配置和 `deploy/` 部署模板。同一程序通过参数选择角色: - -```text -WorkspaceAccessHost host -WorkspaceAccessHost agent -``` - -Windows 使用 `WorkspaceAccessHost.exe`。两个角色在各自的进程和系统用户上下文中运行。 - -## 阅读入口 - -- [文档库](docs/README.md) -- [总功能与平台差异表](docs/overview.md) -- [架构与项目职责](docs/architecture.md) -- [开发与 NUKE 构建](docs/development.md) -- [交付状态与后续实现](docs/implementation-status.md) -- [贡献指南](CONTRIBUTING.md) -- [仓库开发约定](AGENTS.md) +Android 客户端可用 `./build.ps1 --target Publish --runtime linux-bionic-arm64` 发布,详见[Android CLI](docs/platforms/android-client.md)。Linux 使用 `bash ./build.sh`。Server 使用 INI 配置域名和监听地址;开发时读取仓库根目录的 `workspace-access.config`,发布时用 `--config <路径>` 指向独立配置文件。构建中间文件与二进制统一位于根目录 `temp/<项目名>/`。Windows/Linux 发布产物分别位于 `artifacts/publish//server/` 与 `client/`;Android/Termux 仅发布 `client/`。 ## 目录 ```text -src/ 单一产品项目 - WorkspaceAccessHost.csproj 产品项目文件 - Program.cs 角色选择入口 - Core/ 共享模型与契约 - Authentication/ 外部身份与认证 - Connections/ 连接归属与控制权 - Terminals/ PTY 契约与终端逻辑 - Platforms/Windows/ Windows 适配 - Platforms/Linux/ Linux 适配 - Hosting/ 服务宿主角色 - Agent/ 用户代理角色 -tests/WorkspaceAccessHost.Tests/ 测试项目 -build/ NUKE 构建项目 -docs/ 产品、架构、平台差异及开发文档 -deploy/ Windows Service 与 systemd 部署模板 +src/server/ Server 项目与连接仲裁 +src/client/ CLI 客户端项目 +tests/WorkspaceAccessServer.Tests/ 核心测试 +build/ NUKE 构建 +docs/ 设计、状态与验收文档 +deploy/ Windows Service 与 systemd 模板 ``` + +阅读[文档库](docs/README.md)、[单域名通信入口](docs/client-server-transport.md)、[架构](docs/architecture.md)、[交付状态](docs/implementation-status.md)和[贡献指南](CONTRIBUTING.md)。 \ No newline at end of file diff --git a/WorkspaceAccess.slnx b/WorkspaceAccess.slnx new file mode 100644 index 0000000..f638ecb --- /dev/null +++ b/WorkspaceAccess.slnx @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/WorkspaceAccessHost.slnx b/WorkspaceAccessHost.slnx deleted file mode 100644 index d5e77ee..0000000 --- a/WorkspaceAccessHost.slnx +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/build/Build.cs b/build/Build.cs index 056ef15..eb59abc 100644 --- a/build/Build.cs +++ b/build/Build.cs @@ -5,7 +5,7 @@ using Nuke.Common.Tools.DotNet; using Serilog; using static Nuke.Common.Tools.DotNet.DotNetTasks; -namespace WorkspaceAccess.Build; +namespace Charac.Build; internal sealed class Build : NukeBuild { @@ -18,15 +18,16 @@ internal sealed class Build : NukeBuild [Parameter("Build configuration (Debug or Release).")] readonly string Configuration = "Release"; - [Parameter("Publish runtime: win-x64, win-arm64, linux-x64 or linux-arm64.")] + [Parameter("Publish runtime: win-x64, win-arm64, linux-x64, linux-arm64 or linux-bionic-arm64 (client only).")] readonly string? Runtime; [Parameter("Require dependency versions from packages.lock.json.")] readonly bool LockedRestore; - AbsolutePath SolutionFile => RootDirectory / "WorkspaceAccessHost.slnx"; - AbsolutePath ProductProject => RootDirectory / "src/WorkspaceAccessHost.csproj"; - AbsolutePath TestProject => RootDirectory / "tests/WorkspaceAccessHost.Tests/WorkspaceAccessHost.Tests.csproj"; + AbsolutePath SolutionFile => RootDirectory / "WorkspaceAccess.slnx"; + AbsolutePath ServerProject => RootDirectory / "src/server/WorkspaceAccessServer.csproj"; + AbsolutePath ClientProject => RootDirectory / "src/client/WorkspaceAccessClient.csproj"; + AbsolutePath TestProject => RootDirectory / "tests/WorkspaceAccessServer.Tests/WorkspaceAccessServer.Tests.csproj"; AbsolutePath Artifacts => RootDirectory / "artifacts"; Target Restore => _ => _ @@ -63,42 +64,56 @@ internal sealed class Build : NukeBuild .Requires(() => Runtime) .Executes(() => { - string[] supported = ["win-x64", "win-arm64", "linux-x64", "linux-arm64"]; + string[] supported = ["win-x64", "win-arm64", "linux-x64", "linux-arm64", "linux-bionic-arm64"]; if (!supported.Contains(Runtime, StringComparer.Ordinal)) { throw new ArgumentException($"Runtime must be one of: {string.Join(", ", supported)}."); } var destination = Artifacts / "publish" / Runtime!; + if (Runtime != "linux-bionic-arm64") + { + DotNetPublish(settings => settings + .SetProject(ServerProject) + .SetConfiguration(Configuration) + .SetRuntime(Runtime) + .SetProcessAdditionalArguments("--disable-build-servers") + .SetSelfContained(true) + .SetProperty("RestoreLockedMode", LockedRestore) + .SetOutput(destination / "server")); + } + DotNetPublish(settings => settings - .SetProject(ProductProject) + .SetProject(ClientProject) .SetConfiguration(Configuration) .SetRuntime(Runtime) .SetProcessAdditionalArguments("--disable-build-servers") - .SetSelfContained(true) + .SetSelfContained(Runtime != "linux-bionic-arm64") .SetProperty("RestoreLockedMode", LockedRestore) - .SetOutput(destination)); + .SetOutput(destination / "client")); - var deployment = Runtime!.StartsWith("win-", StringComparison.Ordinal) ? "windows" : "linux"; - CopyDirectory(RootDirectory / "deploy" / deployment, destination / "deploy"); - Log.Information("Published WorkspaceAccessHost to {Directory}", destination); + if (Runtime != "linux-bionic-arm64") + { + var deployment = Runtime!.StartsWith("win-", StringComparison.Ordinal) ? "windows" : "linux"; + CopyDirectory(RootDirectory / "deploy" / deployment, destination / "server" / "deploy"); + } + Log.Information("Published {Runtime} artifacts to {Directory}", Runtime, destination); }); - Target RunHost => _ => _ + Target RunServer => _ => _ .DependsOn(Compile) .Executes(() => DotNetRun(settings => settings - .SetProjectFile(ProductProject) + .SetProjectFile(ServerProject) .SetConfiguration(Configuration) - .SetApplicationArguments("host") .EnableNoBuild() .EnableNoRestore())); - Target RunAgent => _ => _ + Target RunClient => _ => _ .DependsOn(Compile) .Executes(() => DotNetRun(settings => settings - .SetProjectFile(ProductProject) + .SetProjectFile(ClientProject) .SetConfiguration(Configuration) - .SetApplicationArguments("agent") + .SetApplicationArguments("--help") .EnableNoBuild() .EnableNoRestore())); diff --git a/build/Build.csproj b/build/Build.csproj index 5429b8a..b2500a1 100644 --- a/build/Build.csproj +++ b/build/Build.csproj @@ -2,7 +2,7 @@ Exe false - WorkspaceAccess.Build + Charac.Build $(NoWarn);CS0649 diff --git a/build/DocumentationChecks.cs b/build/DocumentationChecks.cs index abfa373..f66decd 100644 --- a/build/DocumentationChecks.cs +++ b/build/DocumentationChecks.cs @@ -1,7 +1,7 @@ using System.Text.RegularExpressions; using Serilog; -namespace WorkspaceAccess.Build; +namespace Charac.Build; internal static partial class DocumentationChecks { diff --git a/client.config.example b/client.config.example new file mode 100644 index 0000000..81cc854 --- /dev/null +++ b/client.config.example @@ -0,0 +1,3 @@ +; User-level Client configuration. No token or SSH key belongs here. +[client] +server=https://access.example.com \ No newline at end of file diff --git a/demo/oidc-cli/OidcCliDemo.csproj b/demo/oidc-cli/OidcCliDemo.csproj new file mode 100644 index 0000000..9dc0559 --- /dev/null +++ b/demo/oidc-cli/OidcCliDemo.csproj @@ -0,0 +1,17 @@ + + + Exe + Charac.Client + + + + + + + + + + + + + diff --git a/demo/oidc-cli/Program.cs b/demo/oidc-cli/Program.cs new file mode 100644 index 0000000..a3d7805 --- /dev/null +++ b/demo/oidc-cli/Program.cs @@ -0,0 +1,88 @@ +using System.Net.Http.Headers; +using System.Text.Json; +using Charac.Server.Authentication; +using Charac.Server.Hosting; + +namespace Charac.Client; + +internal static class Program +{ + public static async Task Main(string[] args) + { + if (args is ["--help"] or ["-h"]) + { + PrintUsage(); + return 0; + } + + if (!TryReadConfigPath(args, out var configPath)) + { + PrintUsage(); + return 2; + } + + try + { + var configuration = WorkspaceAccessConfiguration.Load(configPath); + var oidc = OidcSettings.FromConfiguration(configuration) ?? + throw new InvalidOperationException("Configure [oidc] issuer and client_id before running this demo."); + var server = ReadServerUri(configuration["oidc:server_url"]); + + using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) }; + var accessToken = await OidcLogin.LoginAsync(http, oidc.Issuer, oidc.ClientId, + oidc.DiscoveryUrl); + Console.WriteLine("authentik/OIDC login succeeded. Access token remains in this process."); + + if (server is not null) + { + using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(server, "/identity")); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + using var response = await http.SendAsync(request); + response.EnsureSuccessStatusCode(); + using var identity = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync()); + Console.WriteLine($"Verified issuer: {identity.RootElement.GetProperty("issuer").GetString()}"); + Console.WriteLine($"Verified subject: {identity.RootElement.GetProperty("subject").GetString()}"); + } + + return 0; + } + catch (Exception exception) when (exception is HttpRequestException or TaskCanceledException or + OperationCanceledException or InvalidOperationException or JsonException or IOException or + KeyNotFoundException or ArgumentException) + { + Console.Error.WriteLine($"OIDC demo failed: {exception.Message}"); + return 1; + } + } + + private static bool TryReadConfigPath(string[] args, out string? configPath) + { + configPath = null; + if (args.Length == 0) + return true; + if (args is ["--config", var path] && !string.IsNullOrWhiteSpace(path) && + !path.StartsWith("--", StringComparison.Ordinal)) + { + configPath = path; + return true; + } + return false; + } + + private static Uri? ReadServerUri(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return null; + if (!Uri.TryCreate(value.Trim(), UriKind.Absolute, out var server) || + (server.Scheme != Uri.UriSchemeHttps && !(server.Scheme == Uri.UriSchemeHttp && server.IsLoopback)) || + server.UserInfo.Length != 0 || server.AbsolutePath != "/" || + server.Query.Length != 0 || server.Fragment.Length != 0) + throw new InvalidOperationException("[oidc] server_url must be an HTTPS origin or a loopback HTTP origin."); + return server; + } + + private static void PrintUsage() + { + Console.WriteLine("Usage: dotnet run --project demo/oidc-cli -- [--config ]"); + } +} diff --git a/demo/oidc-cli/README.md b/demo/oidc-cli/README.md new file mode 100644 index 0000000..f9c585d --- /dev/null +++ b/demo/oidc-cli/README.md @@ -0,0 +1,34 @@ +# CLI 唤起 authentik:OIDC Playground + +这个独立小程序演示现有 Client 的登录代码:读取 authentik 的 OIDC discovery,生成 PKCE 与 `state`,监听 `127.0.0.1` 临时端口,通过系统浏览器打开授权页面,接收 `/callback` 授权码,再向 token endpoint 交换 access token。它复用 `src/client/OidcLogin.cs`、Server 的 OIDC 设置校验及 INI 路径解析,不需要额外 NuGet 包,也不建立 SSH 会话。 + +先在 authentik 创建 OAuth2/OIDC Application 与 **public client**,允许 Authorization Code + PKCE,配置 `openid` scope、非对称签名密钥,以及允许的 loopback Redirect URI。动态端口可使用 Redirect URIs 正则 `^http://127\.0\.0\.1:[0-9]+/callback$`。不要把 Client Secret 放进 CLI。步骤参考 [authentik 官方文档](https://docs.goauthentik.io/add-secure-apps/providers/oauth2/)。 + +在仓库根目录的 `workspace-access.config` 中配置与 Server 相同的 `[oidc]` 字段: + +```ini +[oidc] +issuer=https://auth.example.com/application/o/workspace-access/ +client_id= +; 只有 discovery 路径不能由 issuer 推导时才填写,例如 authentik 全局 issuer 模式 +; discovery_url=https://auth.example.com/application/o/workspace-access/.well-known/openid-configuration + +; 可选:调用已启动的 Server /identity,让 Server 验证令牌并显示身份键 +server_url=http://127.0.0.1:5080 +``` + +在仓库根目录运行: + +```powershell +dotnet run --project demo/oidc-cli +``` + +需要使用其他文件时,只传路径:`dotnet run --project demo/oidc-cli -- --config .\workspace-access.config`。未指定时,优先查找仓库根目录的 `workspace-access.config`,与 Server 相同。程序会尝试打开浏览器,并同时打印授权 URL。浏览器回到本机后会显示 RAC 的授权回调页;它只表示已收到授权码,CLI 仍需向 authentik 交换令牌。只有终端显示登录成功才算完成。令牌交换失败时会显示 HTTP 状态及 OAuth 错误代码,不输出 access token。配置 `[oidc] server_url` 且 Server 已启用 OIDC 和数据库时,demo 还会请求 `/identity`,显示用于映射的 `(issuer, subject)`;不配置该字段时只验证 authentik 令牌交换。 + +默认 discovery URL 为 `/.well-known/openid-configuration`。authentik 使用全局 issuer 模式,或其他 Provider 的 discovery 路径不同的时候,请在 `[oidc]` 设置 `discovery_url`。`issuer` 必须与 discovery 文档中的 `issuer` 完全一致。浏览器回调必须发生在运行 CLI 的同一台设备上;无本机浏览器的设备授权流程不在此演示范围。 + +Server 返回的 `(issuer, subject)` 是稳定的外部身份键,不是用户资料。Server 本地若需要易读名称,应称为 **user label**:它只用于管理员识别和显示,不能代替身份键,也不参与授权判断。用户的姓名、邮箱等资料由 authentik 或其他身份提供方管理;RAC 不应把自己的 PostgreSQL 身份映射表当作用户资料主库。 + +回调页的西文使用随程序集嵌入的 IBM Plex Mono Regular/Bold,中文使用系统字体回退。字体以 data URL 随 HTML 返回,无需外部字体请求;CSP 仅为字体允许 `data:` 来源。字体来自 [IBM Plex 官方仓库固定版本](https://github.com/IBM/plex/tree/763c36ef9117782905ae010056dfbe8fd2653a25/packages/plex-mono),按 [SIL Open Font License 1.1](../../src/client/Assets/Fonts/IBM-Plex-LICENSE.txt) 分发,许可证随构建与发布输出保留。 + +该流程现已接入正式 Client:使用 `login --server ` 验证登录,或 `connect --server --target ` 登录并进入交互工作区。正式 Client 从 Server 取得 OIDC 配置;本 demo 仍直接读 INI,用于独立排查身份提供方。命令及验收边界见[正式 Client 流程](../../docs/client-server-transport.md)。 diff --git a/demo/oidc-cli/packages.lock.json b/demo/oidc-cli/packages.lock.json new file mode 100644 index 0000000..6afd678 --- /dev/null +++ b/demo/oidc-cli/packages.lock.json @@ -0,0 +1,6 @@ +{ + "version": 2, + "dependencies": { + "net10.0": {} + } +} \ No newline at end of file diff --git a/deploy/linux/README.md b/deploy/linux/README.md index 4b253a2..3430236 100644 --- a/deploy/linux/README.md +++ b/deploy/linux/README.md @@ -1,32 +1,16 @@ -# Linux 部署模板 +# Linux Server 部署模板 -当前产物用于服务宿主与用户代理框架验证。远程工作区各组件的状态由 readiness 报告。 +执行 bash ./build.sh --target Publish --runtime linux-x64,将 artifacts/publish/linux-x64/server/ 安装到 /opt/workspace-access;ARM64 使用 linux-arm64。创建受限的 workspace-access 服务账户,安装 deploy/linux/workspace-access-server.service 并按本机路径调整。目标 Linux 主机单独部署 sshd,限制为内网访问。 -## 发布 - -执行 `bash ./build.sh --target Publish --runtime linux-x64`。将发布目录整体部署到 `/opt/workspace-access/`。ARM64 使用 `linux-arm64`。服务与用户代理共用同一程序,通过 `host`、`agent` 参数选择角色。赋予入口文件执行权限: - -```bash -sudo chmod 0755 /opt/workspace-access/WorkspaceAccessHost -``` - -## 系统服务 - -由部署人员建立 `workspace-access` 系统用户及同名组,并赋予安装目录读取和执行权限。把 `workspace-access-host.service` 复制到 `/etc/systemd/system/` 后执行: - -```bash +```sh +sudo install -d -m 0750 /opt/workspace-access +sudo install -m 0755 char_rac_server /opt/workspace-access/char_rac_server +sudo install -m 0644 workspace-access-server.service /etc/systemd/system/workspace-access-server.service sudo systemctl daemon-reload -sudo systemctl enable --now workspace-access-host.service -systemctl status workspace-access-host.service -journalctl -u workspace-access-host.service +sudo systemctl enable --now workspace-access-server +sudo systemctl status workspace-access-server ``` -模板使用低权限 broker 账户,运行目录为 `/run/workspace-access`,状态目录为 `/var/lib/workspace-access`。用户上下文启动组件按专门权限边界接入。HTTP 健康端点位于 `127.0.0.1:5080`。 +其余发布文件和运行时依赖也必须部署到同一目录。目标主机密钥、账户权限与网络策略需要另行配置;改用证书登录时再配置 SSH CA 信任。密码与私钥 SSH Broker、OIDC、Session Manager 和 WebSocket 已接入;`/health/ready` 在剩余验收项完成前仍返回 503。可在 systemd 的 `ExecStart` 末尾添加 `--config /etc/workspace-access/workspace-access.config`,由服务账户读取该文件,无需复制到发布目录。 -## 用户代理 - -`workspace-access-agent.service` 为 systemd user unit。以目标用户身份将其放入 `~/.config/systemd/user/`,通过 `systemctl --user daemon-reload` 和 `systemctl --user enable --now workspace-access-agent.service` 启动。用户管理器随登录和 linger 策略运行,部署时明确相应生命周期。 - -此模板用于验证现有用户上下文中的 Agent 入口;完整的按需账户启动流程在用户启动组件中实现。 - -源仓库设计文档:`docs/platforms/user-environment.md`、`docs/platforms/session-lifecycle.md`、`docs/implementation-status.md`。 +本机管理通道只允许服务账户连接。服务启动后,可用相同的 `--config` 路径执行 `status` 和 `reload`,例如 `sudo -u workspace-access /opt/workspace-access/char_rac_server --config /etc/workspace-access/workspace-access.config status`。`reload` 目前只在线应用 `[Logging]`;其他配置变化需要计划性重启。服务单元没有 `PrivateTmp`,因为 .NET 在 Linux 上通过 `/tmp` 中的 Unix 域套接字实现命名管道;`UMask=0077` 与同账户检查限制本地访问。 diff --git a/deploy/linux/workspace-access-agent.service b/deploy/linux/workspace-access-agent.service deleted file mode 100644 index eca3332..0000000 --- a/deploy/linux/workspace-access-agent.service +++ /dev/null @@ -1,12 +0,0 @@ -[Unit] -Description=Workspace Access User Agent - -[Service] -Type=simple -ExecStart=/opt/workspace-access/WorkspaceAccessHost agent -Restart=on-failure -RestartSec=5 -UMask=0077 - -[Install] -WantedBy=default.target diff --git a/deploy/linux/workspace-access-host.service b/deploy/linux/workspace-access-server.service similarity index 71% rename from deploy/linux/workspace-access-host.service rename to deploy/linux/workspace-access-server.service index 6eb4d24..3662971 100644 --- a/deploy/linux/workspace-access-host.service +++ b/deploy/linux/workspace-access-server.service @@ -1,5 +1,5 @@ [Unit] -Description=Workspace Access Host +Description=Workspace Access Server After=network.target [Service] @@ -7,12 +7,11 @@ Type=notify User=workspace-access Group=workspace-access WorkingDirectory=/opt/workspace-access -ExecStart=/opt/workspace-access/WorkspaceAccessHost host +ExecStart=/opt/workspace-access/char_rac_server Restart=on-failure RestartSec=5 TimeoutStopSec=30 NoNewPrivileges=true -PrivateTmp=true ProtectSystem=strict ProtectHome=true RuntimeDirectory=workspace-access @@ -20,4 +19,4 @@ StateDirectory=workspace-access UMask=0077 [Install] -WantedBy=multi-user.target +WantedBy=multi-user.target \ No newline at end of file diff --git a/deploy/windows/README.md b/deploy/windows/README.md index 6bebf6c..6b5aaca 100644 --- a/deploy/windows/README.md +++ b/deploy/windows/README.md @@ -1,27 +1,17 @@ -# Windows 部署模板 +# Windows Server 部署模板 -当前产物用于服务宿主与代理框架验证,健康状态列出远程工作区的集成进度。 +执行 ./build.ps1 --target Publish --runtime win-x64,将 artifacts/publish/win-x64/server/ 部署到受保护的服务目录;ARM64 使用 win-arm64。发布目录中的 char_rac_server.exe 是唯一服务端入口,不需要 host 或 agent 参数。 -## 发布与目录 +以受限服务账户注册 Windows Service,并按环境配置监听地址及反向代理。目标 Windows 主机需单独安装和配置 OpenSSH Server,限制为内网访问,并配置目标账户映射和服务器主机密钥验证;改用证书登录时再配置 SSH CA 信任。密码和私钥 SSH Broker、OIDC、Session Manager 与 WebSocket 已接入;正式 Client 已在 Windows 开发机经 authentik 连接 Linux `.101` 并执行交互命令,目标 Windows sshd 尚待验收。可用 `--config` 指向单独维护的 INI 文件,无需复制到发布目录。 -执行 `./build.ps1 --target Publish --runtime win-x64`,将发布目录整体部署到 `C:\Program Files\WorkspaceAccessHost`。ARM64 使用 `win-arm64`。服务与用户代理共用目录中的 `WorkspaceAccessHost.exe`,分别传入 `host` 和 `agent`。 - -服务账户需要读取安装目录和配置。框架阶段服务使用 LocalService。正式代理 IPC 的管道 ACL 配置与服务账户保持一致。 - -## 注册服务 - -在管理员 PowerShell 中执行: +示例(请先按实际路径与服务账户调整): ```powershell -sc.exe create WorkspaceAccessHost binPath= '"C:\Program Files\WorkspaceAccessHost\WorkspaceAccessHost.exe" host' start= auto obj= 'NT AUTHORITY\LocalService' -sc.exe start WorkspaceAccessHost -sc.exe query WorkspaceAccessHost +sc.exe create CharacServer binPath= '"C:\Program Files\Charac\char_rac_server.exe" --config "C:\ProgramData\Charac\workspace-access.config"' start= auto obj= 'NT AUTHORITY\LocalService' +sc.exe start CharacServer +sc.exe query CharacServer ``` -健康地址为 `http://127.0.0.1:5080/health/live`。就绪地址为 `/health/ready`。日志由 .NET 日志提供程序输出。 +/health/live 返回存活状态;/health/ready 在集成完成前返回 503。 -## 用户代理 - -以目标普通用户运行 `WorkspaceAccessHost.exe agent` 验证入口。用户登录启动任务的可执行路径指向 `WorkspaceAccessHost.exe`,参数填写 `agent`,并选择目标用户的交互登录上下文。代理 IPC 注册与终端集成完成后,按部署策略启用该启动任务。 - -源仓库设计文档:`docs/platforms/service-hosting.md`、`docs/platforms/user-environment.md`、`docs/platforms/storage.md`。 +本机 `status` 和 `reload` 命令连接正在运行的服务,不启动第二份实例。命令使用与服务启动时相同的 `--config` 路径,并须在服务账户上下文中执行;上面的 `LocalService` 示例不能直接从普通管理员终端调用同账户管道。`reload` 当前只在线应用 `[Logging]` 设置,其余配置修改需要重启服务。 diff --git a/docs/README.md b/docs/README.md index 789bf73..cc1c167 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,16 +6,22 @@ - [组件架构](architecture.md) - [身份认证和授权](authentication.md) - [连接与会话协议](connection-protocol.md) +- [Client 与 Server 单域名入口](client-server-transport.md) +- [Server SSH 会话设计](ssh-session.md) +- [PostgreSQL 管理数据](database.md) - [交付状态](implementation-status.md) ## 平台专题 -总功能描述中的差异表链接到每项差异的独立文档,覆盖服务宿主、用户环境、PTY、进程权限、生命周期、文件存储、系统管理和发布部署。 +- [Android CLI 客户端](platforms/android-client.md) + +总功能描述中的差异表链接到每项差异的独立文档,覆盖服务宿主、用户环境、SSH 终端、进程权限、生命周期、文件存储、系统管理和发布部署。 ## 开发与运行 - [开发与构建](development.md) - [验证与验收](testing.md) +- [CLI 唤起 authentik Playground](../demo/oidc-cli/README.md) - [Windows 部署模板](../deploy/windows/README.md) - [Linux 部署模板](../deploy/linux/README.md) diff --git a/docs/architecture.md b/docs/architecture.md index aa22e09..d7e492a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,53 +1,33 @@ # 组件架构 -## 运行结构 +## 目标链路 ```mermaid flowchart LR - Client[远程客户端] -->|OIDC 登录| Auth[authentik] - Client -->|WSS| Broker[Host / Broker] - Broker --> Policy[认证校验与系统账户映射] - Broker --> Gate[Host 独占控制权] - Broker -->|本机认证 IPC| Agent[用户会话代理] - Agent --> Terminal[PTY 与终端状态] - Terminal --> Shell[shell / TUI 进程] + Client[CLI] -->|OIDC| Auth[authentik] + Client -->|HTTPS / WebSocket| Server[Workspace Access Server] + Server --> Policy[授权与账户映射] + Policy --> Manager[Session Manager] + Manager --> Gate[工作区控制权] + Manager --> SSHAuth[后端 SSH 凭据与主机信任] + Manager -->|内网 SSH| Sshd[Windows / Linux sshd] + Sshd --> Shell[用户 shell / TUI] ``` -## 代码组织 +Server 对每个工作区持有一条后端 SSH 会话。WebSocket 只是客户端附着;断开时 Server 继续读取 SSH 输出。工作区之间有独立归属与控制权。身份由 `(issuer, subject)` 确定,系统账户由服务端映射。 -解决方案包含一个产品项目 `src/WorkspaceAccessHost.csproj`、一个测试项目 `WorkspaceAccessHost.Tests` 和一个 NUKE 构建项目 `Build`。产品代码直接在 `src/` 下通过目录和命名空间组织职责: +## 项目组织 -| 产品目录 | 职责 | -| --- | --- | -| `Core/` | 共享模型、平台描述和契约 | -| `Authentication/` | 外部身份、认证与系统账户映射 | -| `Connections/` | 连接控制权、接管和工作区归属 | -| `Terminals/` | PTY 契约、终端状态与输入输出 | -| `Platforms/Windows/` | Windows 平台策略及 ConPTY、用户令牌、命名管道的实现位置 | -| `Platforms/Linux/` | Linux 平台策略及 PTY、UID/GID、Unix socket 的实现位置 | -| `Hosting/` | 服务生命周期、网络入口、连接仲裁和健康检查 | -| `Agent/` | 用户上下文中的终端、子进程和画面状态 | +解决方案包含 `src/server/WorkspaceAccessServer.csproj`、`src/client/WorkspaceAccessClient.csproj`、`tests/WorkspaceAccessServer.Tests` 和 `build/Build.csproj`。Server 的 `Authentication/` 放 OIDC 配置、身份模型与授权解析,`Connections/` 放工作区控制权,`Hosting/` 放 HTTP/WebSocket 入口。`Ssh/` 已实现密码及托管私钥 SSH Broker、主机公钥校验、Session Manager 和本机诊断命令;SSH CA 与密钥实机验收仍待完成,详见[SSH 会话设计](ssh-session.md)。 -领域逻辑依赖接口与共享模型,系统调用集中在对应平台目录,入口负责注册平台实现。 +CLI 的 `login` 和 `connect` 通过单一域名发现 OIDC 配置,复用 PKCE 浏览器登录与回调页,并让 Server 验证身份;`connect` 从受保护的 `/resources` 取得当前授权目标及会话状态,选择后按目标或会话 ID 的 query 建立正式 WebSocket 交互;也支持直接指定 ID。原有一次性 Linux `ls` 命令继续用于诊断。demo 和正式 Client 的真实 authentik 登录及 Server 身份验证已实测通过,正式整链路的交互验收尚待完成,见[单域名入口](client-server-transport.md)。Caddy 等反向代理可承接公网 TLS 入口;内网 `sshd` 只允许 Server 访问。Windows OpenSSH 已使用 ConPTY,因此 Server 不再自己启动 `pwsh.exe` 或实现平台 PTY。 -## 程序角色 +## 信任与生命周期 -同一套发布产物以 `WorkspaceAccessHost host` 运行服务宿主,以 `WorkspaceAccessHost agent` 运行用户代理。Windows 可执行文件带 `.exe` 扩展名。角色参数后的选项传递给对应宿主构建器;无参数或 `--help` 显示使用方式,未知角色以退出码 2 结束。 +OIDC 授权与 SSH 后端认证是两个边界。Server 只能为已授权的目标账户建立 SSH 会话,需校验目标主机密钥;后端 SSH 凭据不能代替外部连接的 OIDC 授权判断。若未来选择 SSH CA,还需在目标 OpenSSH 版本验收证书和账户映射。 -Host 和 Agent 分别运行在服务账户与目标用户上下文中,各自拥有进程生命周期,通过本机 IPC 协作。测试与 NUKE 项目承担验证和开发控制职责。 +首期断线持续性以 Server 与后端 SSH 连接存活为条件。Server 崩溃或内网 SSH 断开会失去该 SSH 终端;要跨越这一边界,未来需要目标机器上独立的会话持有者。画面恢复还需要持续消费 VT 输出、保持容量上限并生成一致的快照。 -## 生命周期和信任边界 +## 已有框架 -authentik 身份、broker 连接、系统登录上下文、工作区和终端拥有独立标识。客户端的系统用户名是授权请求;最终账户由服务端映射规则确定。 - -代理 IPC 需要验证对端的系统身份、目标用户、host 实例和协议版本。Windows 使用命名管道 ACL,Linux 使用 socket 文件权限与对端凭据。代理仅接受经验证的 broker 指令。 - -broker 使用服务账户运行。Windows 用户代理通过目标用户登录启动;Linux 启动组件负责完成组、用户和会话设置后交给用户代理。系统身份切换保持在明确的本机接口中。 - -首期 broker 进程生命周期是连接控制状态的边界。用户代理在 WebSocket 断开时持续存在;broker 重启后的代理重新登记、归属恢复与控制权版本同步需要完成集成验收。 - -## 终端契约 - -`IPtySessionFactory` 在已建立的用户上下文中启动终端。`IPtySession` 提供输入、输出、尺寸变更、退出状态和显式终止。工作区管理器拥有会话,传输关闭只解除附着。 - -代理持续消费输出,维护有容量上限的历史和终端状态。快照与输出序列号形成重连边界,输出积压策略由协议规定。 +`SshWorkspaceManager` 为每个工作区建立一份 `WorkspaceConnectionGate`,以不可伪造的 lease 和递增 generation 仲裁接管,并在同一临界区校验输入入队。认证、授权与账户映射完成后才可调用该核心。Data/ 已实现 PostgreSQL 管理模型与迁移,Authentication/SshAccessResolver 通过一次 LINQ 查询解析授权和目标信任配置,见[数据库](database.md)。当前已有 OIDC、内部 SSH 和 WebSocket 的首版集成;托管密钥的真实 SSH 连接与正式端到端环境验收仍待完成,见[交付状态](implementation-status.md)。 diff --git a/docs/authentication.md b/docs/authentication.md index 53653de..b5b0451 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -1,23 +1,29 @@ # 身份认证与授权 -## 身份来源 +CLI 已实现 authentik OIDC Authorization Code + PKCE 的浏览器登录和本机回调;令牌仅保存在当前 CLI 进程内。Server 使用 JWT Bearer 中间件从 HTTPS OIDC discovery 获取签名密钥,校验签名、issuer、audience 和有效期,再以稳定的 `(iss, sub)` 识别用户。资源列表、创建、附着、结束工作区均重新查询数据库授权。需在 authentik 创建 public client、启用 PKCE、设置允许的 `http://127.0.0.1:<动态端口>/callback` 回调,并为 Provider 配置非对称 JWT 签名密钥。Server 配置 `[oidc] issuer` 和 `client_id`;若采用 authentik 的全局 issuer 模式,还需设置指向应用 slug 下 discovery 文档的 `discovery_url`。2026-10-03 已通过真实 authentik public client 的 demo 浏览器登录与令牌交换;2026-10-04 正式 Client 的 `login` 已通过 Server 令牌校验;2026-10-05 已在 `.100 → .101` 实测目标授权、资源选择和交互式 `ls`;正常退出及重新附着待复测。 -authentik 提供 OIDC 身份认证、MFA 和应用访问策略。操作系统管理系统账户、组和文件权限。Host 配置受信任 issuer、客户端标识和账户映射规则,运行数据记录身份与工作区的关系。 +管理员将外部身份映射到允许访问的目标机器和系统账户。Windows 目标使用账户 SID,Linux 目标使用 UID/GID;实际登录环境及文件权限由目标 sshd 和操作系统建立。不同外部身份即使映射到同一个系统账户,工作区归属仍独立。 -稳定外部身份为经过验证的 `(iss, sub)`。Windows 账户解析为 SID,Linux 账户解析为 UID、主组和附加组。映射配置可来自管理员配置或受管理员控制的可信声明。 +Server 当前的 `access.identities` 只保存 `(issuer, subject)` 身份键和启用状态,并不保存用户资料。若未来加入本地易读名称,应命名为 **user label**,仅供管理员识别和显示;标签不能作为认证或授权依据,也不能声称是从身份平台实时同步的资料。姓名、邮箱等真正的用户资料由 authentik 或其他 OIDC Provider 管理;Server 不直接读取 authentik 的 PostgreSQL。正式 Client 的 `login` 和 `connect` 使用相同的浏览器登录与回调页;独立的 [OIDC CLI Playground](../demo/oidc-cli/README.md) 保留为身份提供方调试入口。具体命令见[正式 Client 流程](client-server-transport.md)。 -## 登录流程 +当前建连支持 Server 托管的 SSH 登录私钥或一次性输入的 SSH 密码,两者均继续验证数据库中登记的目标主机公钥。密钥授权不要求 Client 提供 SSH 密码;密码授权的密码在 HTTPS 请求体中传给 Server,不写入数据库或日志。私钥内容只保存在服务账户可访问的文件中,数据库保存引用。目标 sshd 只在内网对 Server 开放。正式用户登录只使用 OIDC;SSH CA 仍是可选的后端凭据方案,不是用户登录步骤。 -浏览器客户端采用 Authorization Code + PKCE,应用校验 state、nonce、签名、issuer、audience 和有效期。会话密钥与客户端密钥通过部署配置管理。浏览器会话使用 Secure、HttpOnly Cookie,并在握手时校验 Origin;变更操作采用 CSRF 防护。 +认证失败、映射缺失或授权撤销时,不允许调用连接控制核心;已附着连接应关闭并撤销控制权。审计记录身份、目标、系统账户、工作区、操作及结果,避免记录令牌和终端内容。实现进度见[交付状态](implementation-status.md)。 -完成登录后,按 host 访问规则、目标系统账户映射和代理状态进行授权,再进入连接控制权获取阶段。每次重连与接管均重新检查授权。授权撤销时关闭传输并撤销控制权,工作区结束策略由 host 管理配置确定。 +身份映射和目标授权的数据模型与 LINQ 解析已实现,详见[数据库管理与撤销边界](database.md)。受保护的 `/session` 路由将已验证的 OIDC 身份交给授权解析;未在数据库登记对应 `(iss, sub)` 的用户会收到 403。数据库授权查询用于会话创建、附着、接管和结束,不用于逐次终端输入。数据库中的启用和到期状态目前不自动终止现有 SSH 连接;在线撤销机制仍待实现。 -## 会话归属 +本机 ssh broker-probe 在专用测试数据库中保存虚拟身份,供开发者检查记录并验证授权查询与 Broker;它不是 OIDC 登录入口,不对 WAN 开放。诊断记录在 SSH 失败后仍会保留,需要手动清理。 -host 工作区有一个外部身份所有者。断线保留所有者;同身份可重连或显式接管。工作区终止并清理后释放归属。两个外部身份即使映射到同一系统账户,也保持各自的工作区授权边界。 +## 当前测试库中的 OIDC 登记 -## 审计 +先在 authentik 创建 OAuth2/OpenID Provider:使用 public client、Authorization Code + PKCE、`openid` scope、非对称签名密钥,允许 CLI 的 loopback 回调。动态端口可在 Redirect URIs 中配置为正则 `^http://127\.0\.0\.1:[0-9]+/callback$`;具体选项见[authentik OAuth2 Provider 文档](https://docs.goauthentik.io/add-secure-apps/providers/oauth2/)。在根目录的 `workspace-access.config` 加入: -记录事件时间、host、身份标识、系统账户标识、连接标识、控制权版本及结果。认证凭据、Cookie 和终端内容采用独立的数据保护策略。常规审计聚焦身份与生命周期事件。 +```ini +[oidc] +issuer=https://auth.example.com/application/o/workspace-access/ +client_id= +; 只有 discovery 文档不在 issuer 下时才填写,例如 authentik 全局 issuer 模式 +; discovery_url=https://auth.example.com/application/o/workspace-access/.well-known/openid-configuration +``` -参考:[authentik OIDC](https://docs.goauthentik.io/add-secure-apps/providers/oauth2)。实现阶段见[交付状态](implementation-status.md)。 +CLI 的 `login`(或 `whoami`)会在浏览器登录后向 Server 请求已验证的 `issuer` 和 `subject`。它们必须与数据库中的身份记录精确一致;`broker-probe` 生成的虚拟身份不能代表真实 OIDC 用户。在专用测试库中,可使用 Server 的本机 `database grant-probe` 命令为一个已保存的 `broker-probe` 目标登记真实 OIDC 身份和 `[ssh_probe] account` 授权。此命令仅接受库名含 `test` 的数据库,不提供 WAN 自助登记入口。 diff --git a/docs/client-server-transport.md b/docs/client-server-transport.md new file mode 100644 index 0000000..432637c --- /dev/null +++ b/docs/client-server-transport.md @@ -0,0 +1,89 @@ +# Client 与 Server 的单域名入口 + +## 配置域名与监听地址 + +Server 可通过全局 `--config <路径>` 显式指定 INI 配置;未指定时,开发环境优先读取仓库根目录的 `workspace-access.config`,发布时需要提供外部配置路径,或自行将同名文件放在可执行文件旁。启动时加载,修改后需重启。配置示例: + +```ini +[server] +domain=127.0.0.1 +listen=http://127.0.0.1:5080 +``` + +`domain` 是客户端使用的对外域名,只填主机名,不带协议或端口。Server 以它校验 HTTP `Host`;不匹配时返回 400。`listen` 是 Kestrel 实际绑定的 HTTP 地址,默认仅在本机监听。两者分开配置,例如通过 Caddy 提供 `https://access.example.com` 时,设 `domain=access.example.com`,保留 `listen=http://127.0.0.1:5080`,并让 Caddy 将原始 Host 和 WebSocket Upgrade 转发到该监听地址。配置域名不会自动创建 DNS 记录、签发 TLS 证书或开放端口;这些由部署环境和反向代理负责。目标 `sshd` 始终只在内网可达。 + +## 路由 + +CLI 只接受一个 Server origin,例如 `https://access.example.com`。操作类型由 HTTP 方法和固定路径表示,目标与会话定位 ID 通过 query 传递。账户、认证令牌、SSH 证书和密钥不放进 URL。 + +| 方法与路径 | 用途 | 当前状态 | +| --- | --- | --- | +| `GET /status` | 公开的协议版本、OIDC public client 配置与会话可用状态 | 已实现;`ready` 表示 OIDC 和数据库连接已配置,不保证后端在线 | +| `GET /resources` | 当前身份可新建的目标及持有的存活会话快照 | 已实现,需 Bearer token | +| `GET /identity` | 返回当前经过验证的 `(issuer, subject)`,供本机管理员登记授权 | 已实现,需 Bearer token | +| `POST /session?target={targetId}` | 验证 Bearer token 与授权后,按授权绑定的 Server 私钥或临时 SSH 密码创建工作区 | 已实现;返回工作区 ID,密码不入库 | +| `GET /session?id={sessionId}` | 再次校验身份和目标授权,升级为 WebSocket 附着 | 已实现;`takeover=true` 显式接管,默认已有控制者时返回 409 | +| `DELETE /session?id={sessionId}` | 再次校验身份、设备和目标授权后结束工作区 | 已实现;后端 SSH 连接随之关闭 | + +两个 ID 均使用非空 GUID。query 只负责定位资源,不代表访问权;创建和附着先验证 authentik access token、目标授权和工作区归属。系统账户由 Server 根据数据库授权映射;已绑定 SSH 登录密钥时 Server 从受保护目录加载私钥,未绑定时返回 `400 password_required`,Client 才在 HTTPS 请求体提供一次性 SSH 密码。密钥配置见[SSH Broker](ssh-session.md)。正式 Client 在 `GET /identity`、`GET /resources`、`POST /session`、WebSocket `GET /session` 和 `DELETE /session` 请求头发送 `X-RAC-Device` 安装凭据。除 `/identity` 外,所列请求都必须带有效凭据;另一安装已有活跃连接时返回 409 `device_in_use`,缺少或无效凭据时返回 400 `device_credential_required`。独立 OIDC demo 的 `/identity` 调用仍可不带该头。WebSocket 使用 `Authorization: Bearer` 请求头,输入消息为 `{"type":"input","data":""}`,尺寸消息为 `{"type":"resize","columns":80,"rows":24}`,输出消息为 `{"type":"output","sequence":1,"data":""}`。输出序列号可发现丢失,但没有屏幕快照。后端 SSH 结束时,Server 会在已排队输出之后发送 `{"type":"ended","reason":"ssh_closed"}`,再关闭 WebSocket;显式结束工作区时 `reason` 为 `workspace_closed`。接管造成的观察者连接关闭不发送后端结束事件。若连接意外中断而没有收到 `ended`,Client 将其视为 Server 连接中断,不能误报为 SSH 已正常结束。LAN 管理面承担目标注册、后端凭据配置与授权维护,不挂在这个 WAN 路由组上。协议兼容性由 `/status` 中的 `protocolVersion=3` 表示,当前不在 URL 中加入 `/api/v1`。 + +## 正式 Client 登录与交互 + +`login --server ` 复用已在 demo 验证的浏览器 Authorization Code + PKCE 流程,再请求 Server `/identity`。只有 Server 接受令牌后,CLI 才显示登录成功及 `(issuer, subject)`。`whoami` 保留为同一入口。Client 从 Server `/status` 获取 `[oidc]` 配置,不需要复制 Server 的 INI 或数据库密码。demo 继续用于独立验证身份提供方。 + +在仓库根目录启动 Server,再在另一个终端操作 Client: + +```powershell +dotnet run --project src/server -- --config .\workspace-access.config serve +dotnet run --project src/client -- login --server http://127.0.0.1:5080 +dotnet run --project src/client -- connect --server http://127.0.0.1:5080 +``` + +正式 CLI 还可以在用户级 `client.config` 的 `[client] server` 设置默认入口:Windows `%APPDATA%\Charac\client.config`,Linux/Termux 使用 `$XDG_CONFIG_HOME/charac/client.config` 或 `~/.config/charac/client.config`。配置后直接运行 `connect`、`login`、`status`;命令上的 `--server` 优先,`--config <路径>` 可在命令名前选择另一份配置。此文件只保存入口 origin,不保存认证令牌。 + +`connect` 自行登录并显示资源选择菜单,无需先执行 `login`;`login` 用于验证配置和取得身份键,不建立跨进程登录缓存。浏览器登录后的蓝色回调页、IBM Plex Mono 字体与 demo 共用同一份实现。Server 必须配置 OIDC 和数据库;登录身份仍需由管理员登记目标授权,见[认证与授权](authentication.md)。也可用 `connect --server --target ` 跳过菜单;`--target` 是数据库目标 UUID,不是主机 IP。若该授权已绑定可用的 Server SSH 登录密钥,Client 不会提示 SSH 密码;否则继续使用交互式密码登录。 + +正式 Client 首次运行时在当前用户的本地应用数据目录 `workspace-access/device-key` 生成 32 字节随机安装凭据;后续命令复用它。同一 `(issuer, subject)` 在 Server 上只能由一个安装凭据持有活跃 Client 连接,同一安装可同时打开多个终端。最后一条 WebSocket 断开后,另一设备可登录并连接;已有 SSH 工作区仍保留。管理员的本机 `disconnect` 可以立即切断此身份的所有 Client/WebSocket 并释放占用,见[服务宿主](platforms/service-hosting.md)。这是 Server 进程内的活跃连接仲裁,不限制 authentik 自身的浏览器登录,也不把设备归属写入数据库。安装凭据是可复制的 bearer 秘密,不能证明物理设备;复制到另一机器会被视为同一安装,应像密码一样保护。仅经 HTTPS 发送;开发回环 HTTP 例外。Windows 使用当前用户的应用数据目录权限,Unix 创建文件时限制为 0600。后续若需抵抗凭据复制,应另设计设备密钥的持有证明。 + +连接成功后持续转发终端输入、输出、鼠标和尺寸变化。`Ctrl+]` 解除附着,按键回退模式也接受 `F12`;工作区继续由 Server 持有。Client 在附着前打印工作区 ID,重新进入使用: + +```powershell +dotnet run --project src/client -- connect --server http://127.0.0.1:5080 --id <工作区UUID> +``` + +重新附着会重新登录、重新检查数据库授权,不再创建 SSH 会话或请求 SSH 密码。若仍有控制者,Server 返回 409,当前 Client 不自动接管。远端 `exit`、SSH 断线或 Server 停止会结束后端会话;正式 Client 的本机退出和传输错误不会发送工作区删除请求。令牌只保留在当前命令进程中;离线输出仍受 Server 的有界缓冲限制,屏幕快照和自动重连尚未实现。真实 OIDC → Server → SSH 交互式 `ls` 已在 `.100 → .101` 实测;正常退出及重新附着待复测,Android 限制见[平台说明](platforms/android-client.md)。 +## 登录后的资源发现 + +`GET /resources` 要求经过 Server 验证的 Bearer token,不接受 query 中自报的用户身份。返回 `targets` 和 `sessions` 两个数组;无授权或未登记的身份返回空数组。未认证返回 401;Server 缺少 OIDC 或数据库配置时返回 503。响应设置 `Cache-Control: no-store`。 + +`targets` 的每项为 `{ id, name, account }`。Server 使用一次 EF LINQ 查询,按 `(issuer, subject)`、身份/目标/授权启用状态、授权有效期和已启用主机公钥过滤;复用实际建连的授权条件。不返回内网地址、SSH 端口、主机公钥、CA 或后端凭据。可列出的目标代表有权尝试新建会话,不代表机器已经通过在线探测。 + +`sessions` 的每项为 `{ id, targetId, targetName, account, createdAt, state }`。创建时间使用 UTC,`state` 为 `detached`(没有控制者,可附着)或 `attached`(已有控制者)。只列出当前身份持有、仍有目标访问权且系统账户映射一致的存活工作区;后端结束或 Server 停止后不保留在列表中。创建时间与状态来自进程内 Session Manager,不新增数据库表。 + +数据库授权和各工作区状态构成查询时的快照,不是连接许可。实际创建、附着、结束仍重新查权限;管理员改动账户映射后,旧账户工作区不会列出,直接附着/结束请求也被拒绝。在线撤销和旧工作区的管理员清理机制仍待完善。列表读取不持有全局锁执行数据库或网络操作。 + +不指定 ID 的 `connect --server ` 会在登录后显示菜单:已有会话在前,新建目标在后;输入编号选择、`r` 重新请求列表、`q` 或输入结束退出。占用中的会话显示但不提供编号,CLI 暂不支持显式接管;列表显示后若状态变化,实际附着仍由 Server 拒绝或准许。刷新复用当前进程令牌,不重新打开浏览器。取消和刷新都不会创建或删除工作区。 +## CLI 探测 + +`WorkspaceAccessClient status --server https://access.example.com` 请求 `/status`,检查服务标识和协议版本。`run --server --target ` 从状态响应取得 OIDC issuer、client ID 和 discovery URL,执行浏览器 Authorization Code + PKCE,提示输入 SSH 密码,创建工作区并通过 WebSocket 发送 Linux `ls`;`attach --server --id ` 重新登录并附着已有工作区,也发送 `ls`。这两个命令目前用于一次性验收,不提供持续交互终端。CLI 不持久化令牌和密码。只允许 HTTPS origin;开发时允许 loopback HTTP,例如 `http://127.0.0.1:5080`。不接受 Server origin 中的路径、查询串或用户信息。 + +无需 OIDC 的本机端到端诊断使用显式 `serve --local-probe` 开关。Server 此时额外注册 `/local/session`,从 `[ssh_probe]` 读取 `.101` 等诊断目标,不创建 PostgreSQL 记录,也不改变正式 `/session` 的认证要求。开关只在 `[server] listen` 和 `domain` 都为数值回环地址时生效;请求来源还必须是回环地址。只用于同一台机器上的两个终端,不要通过 Caddy 转发或用于 LAN/WAN。SSH 后端仍以运行时输入的密码登录,并固定校验配置的目标主机公钥。 + +终端一保持 Server 运行,终端二运行 CLI: + +```powershell +dotnet run --no-restore --no-launch-profile --project src/server -- --config .\workspace-access.config serve --local-probe +dotnet run --no-restore --no-launch-profile --project src/client -- probe-session --server http://127.0.0.1:5080 +``` + +第二条命令会提示输入 SSH 密码,经 HTTP 创建托管工作区、WebSocket 发送一次 `ls`、检查输出和退出码,再关闭该诊断工作区。要在同一个工作区持续输入命令,把第二条命令改为 `dotnet run --no-restore --no-launch-profile --project src/client -- shell --server http://127.0.0.1:5080`。在 Windows VT 终端中,`shell` 开启 VT 输出和延迟行尾换行,读取并转发原始输入字节,包括 nvim 请求的鼠标报告、方向键和 Ctrl+C;只有远端 TUI 开启鼠标跟踪时才转发鼠标报告,退出后会过滤滞留报告。连接后会同步终端尺寸,随后持续转发尺寸变化。按 `Ctrl+]` 退出并关闭本次诊断工作区,Server 仍运行。若终端不支持 Windows VT 输入,CLI 回退到按键读取,鼠标操作不可用,此时可用 `F12` 退出。退出时恢复本机控制台模式、关闭终端鼠标报告并清空待处理的本机输入;只有远端仍处于备用屏幕时才退出备用屏幕,避免把光标恢复到旧位置。本地密码提示和断开状态会接在当前终端行后,不额外插入空行;收到 `ended` 后显示工作区关闭状态。远端 shell 自行结束后 Server 可能已经删除工作区,此时 Client 的清理请求收到 404 不会误报为连接失败。附着期间输出队列采用背压并检查序号,若检测到序号缺口,CLI 会结束该诊断工作区并报告错误。nvim/Mason 的 Up/Down 滚动残影已在 2026-10-03 的本机 Debug Client 测试中未复现;断开提示的位置尚待实机复测。Android/Termux 的原始输入模式、断线恢复及正式认证路径的交互验收仍待完成。`ssh probe` 与 `ssh broker-probe` 则是执行完毕即退出的单次诊断命令;它们不会启动常驻 HTTP Server。 + +本机可分别运行: + +```powershell +dotnet run --project src/server +dotnet run --project src/client -- status --server http://127.0.0.1:5080 +dotnet run --project src/client -- run --server http://127.0.0.1:5080 --target <已授权目标的 UUID> +``` + +健康探针 `/health/live` 与 `/health/ready` 保持独立于客户端协议,也遵守相同的 Host 校验。若部署需要转发头,必须在 `[server] trusted_proxy` 中只信任直接相连的代理地址;Server 仅在此前提下读取一跳转发来源。 diff --git a/docs/connection-protocol.md b/docs/connection-protocol.md index 3036373..fd3c5b7 100644 --- a/docs/connection-protocol.md +++ b/docs/connection-protocol.md @@ -1,39 +1,18 @@ # 连接与会话协议 -本文定义传输集成时应遵守的行为契约。当前共享核心实现控制权仲裁,消息编码和端点处于集成阶段。 +Server 按工作区管理一个后端 SSH 会话与一个当前输入控制者。CLI 通过 OIDC 登录和 HTTPS 创建工作区,再通过 WebSocket 附着。现已实现一次性 `ls` 验收路径和基本输入、尺寸、输出消息;完整交互终端、屏幕快照和断线自动重连尚未完成。路径见[单域名入口](client-server-transport.md)。 -## 连接状态 +工作区创建时绑定外部身份、目标机器和系统账户。WebSocket 断开只解除附着和输入控制权;运行中的 Server 继续读取 SSH 输出并按有界策略保存状态。同身份可重连;显式接管后旧连接的输入必须被拒绝。其他身份不能附着已有工作区。显式结束或后端 SSH 退出时释放工作区。 -```text -Authenticated → Authorized → Acquired → Attached - ↑ ↓ - Reconnect ← Detached -``` +`SshWorkspaceManager` 为每个工作区持有 `WorkspaceConnectionGate` 和 SSH 会话;不可伪造的 lease 标识控制权,接管与输入入队在同一临界区排序。独立后台任务持续读取 SSH 输出,最多保留最近 256 KiB、256 个输出块;附着时回放并继续发送。附着客户端的队列有界,满时对 SSH 读取施加背压,避免静默丢失 VT 控制序列;CLI 同时检查输出序号。断线期间的有限历史不是屏幕快照,重新附着后的完整画面恢复尚未实现。客户端 WebSocket 断开只解除附着,不关闭 SSH。 -host 工作区归属随创建建立,随显式结束释放。连接断开只改变附着状态。 - -## 控制权 - -- 首次授权连接取得控制权。 -- 同身份第二连接提交显式 takeover 请求后,原连接失效。 -- 其他身份得到 host 已被占用的响应。 -- 每次控制权获取生成新的连接 ID 和递增 generation。 -- 每个 broker 实例另有独立 epoch;IPC 集成使用 `(epoch, generation, connectionId)` 标识控制权。 -- WebSocket 关闭、心跳超时或授权失效时,按连接标识撤销控制权。 - -`HostConnectionGate` 使用进程内不可伪造的 lease 对象。`TryDispatch` 在同一临界区中完成校验与同步入队,接管与输入接受存在确定顺序。接管完成后旧 lease 的输入、关闭与释放均被拒绝;接管前已接受的输入按队列顺序执行。真正传输集成应把控制消息与输入交由同一有序派发路径处理。 - -## 消息职责 - -| 消息 | 内容与行为 | +| 消息 | 目标行为 | | --- | --- | -| Attach | 目标工作区、协议版本、显式接管意图 | -| Input | 二进制终端输入,绑定已验证连接与控制权 | -| Resize | 有界列数与行数,绑定当前控制权 | -| Output | 二进制终端输出与递增序列号 | -| Snapshot | 屏幕、光标、终端模式、历史边界和输出序列号 | -| Heartbeat | 检测失效连接并触发释放 | -| Detach | 结束传输,保留工作区 | -| Terminate | 授权后结束工作区进程并释放归属 | +| Attach / Takeover | 选择工作区并取得或显式接管控制权 | +| Input / Resize | 经当前 lease 验证后写入 SSH channel,尺寸设定有边界 | +| Output | 从 SSH channel 持续读取并携带序列号 | +| Snapshot | 恢复屏幕、光标与历史边界;具体编码待确定 | +| Heartbeat / Detach | 检测失效连接并解除附着,保持后端 SSH | +| Terminate | 授权后关闭 SSH 会话及工作区 | -终端数据按字节传输,控制消息使用独立类型。输入限制、输出缓冲上限、快照一致性和客户端尺寸变化由协议实现和测试固定。传输入口先完成认证授权,再允许升级及访问工作区。 +Server 重启或后端 SSH 连接中断不能仅靠 WebSocket 重连恢复原终端;若需要跨 Server 生命周期的持久终端,应另行设计目标侧会话设施。 diff --git a/docs/database.md b/docs/database.md new file mode 100644 index 0000000..c23c319 --- /dev/null +++ b/docs/database.md @@ -0,0 +1,112 @@ +# PostgreSQL 管理数据 + +## 技术与范围 + +Server 使用 EF Core 10 和 Npgsql,目标数据库为 PostgreSQL 18。数据层位于 `src/server/Data/`,直接使用具体的 `AccessDbContext`;授权解析位于 `Authentication/SshAccessResolver.cs`。查询、写入与测试使用 LINQ、ChangeTracker 和 SaveChanges;迁移由 EF 工具生成,不拼接 SQL 或使用 FromSqlRaw / ExecuteSqlRaw。EF 参数化不能代替身份认证、授权和字段校验。 + +目前实现管理数据模型、EF 迁移、SSH 登录密钥引用、工作区历史、授权解析、迁移命令与数据库 readiness。OIDC、Session Manager 和受保护的 WAN `/session` 路由已有首版实现;真实私钥 SSH 与正式 Client 的交互式 `ls` 已在 `.100 → .101` 实测;正常退出及重新附着待复测。本机 `database target register` 和 `database grant register` 已可持久登记正式目标、主机公钥、OIDC 身份及授权;`endpoint_regist` 可在本机交互收集端点信息、核对主机指纹并把已有登录私钥复制进服务目录,详见[SSH Broker](ssh-session.md#三参数交互式端点登记);完整的 LAN 网络管理入口与 CA 签发仍待实现。旧 `database enroll-probe`、`database grant-probe` 只面向专用测试库;`database key` 命令管理登录密钥引用和授权绑定。WAN 不接收客户端自报身份来调用授权解析。 + +## 第一版模型 + +所有实体使用 UUID 主键。身份、目标、授权、主机公钥和登录密钥只有启用后才能参与授权;本机密钥登记命令会明确创建启用的密钥记录。 + +| 表(access schema) | 保存内容 | 约束 | +| --- | --- | --- | +| identities | OIDC issuer、subject、启用状态 | issuer + subject 唯一 | +| user_certificate_authorities | 用户证书 CA 名称、公钥、签名密钥引用、启用状态 | 名称唯一;不保存私钥内容 | +| ssh_login_keys | 普通 SSH 登录密钥名称、文件名、启用状态 | 名称与文件名唯一;私钥留在 Server 文件系统 | +| workspace_records | 正式工作区的身份、目标、账户、认证方式与生命周期历史 | 历史不等于可恢复的在线 SSH 连接 | +| targets | 名称、SSH 地址和端口、可选的用户 CA、启用状态 | 名称唯一;可空 CA 外键 | +| host_keys | 已确认的目标主机公钥、启用状态 | 目标 + 公钥唯一;目标外键 | +| grants | 身份、目标、系统账户、可选登录密钥与证书 principal、启用状态、可选 UTC 到期时间 | 身份 + 目标唯一;身份、目标与登录密钥外键 | + +首版明确采用一个外部身份在每个目标上对应一个系统账户。不同目标可映射不同账户,不同身份映射相同账户也不共享工作区归属。将来如需同一身份在一个目标上选择多个账户,需要扩展授权模型与选择参数。 + +私钥文件位于 `[ssh_keys] directory` 指定的受保护目录,数据库只保存密钥名称、普通文件名和启用状态,不保存登录密钥公钥。目标主机公钥仍单独保存在 `host_keys` 中,建连时必须验证。密钥绑定到授权后,Broker 才能为该授权读取文件;数据库记录本身不会生成密钥、修改目标 `authorized_keys` 或使密钥自动获得访问权。`workspace_records` 保留正式工作区的历史,即使 Server 停止也不会自动清理。其 `ClosedAt` 为空只表示未观察到结束,不能作为仍可附着的依据;在线状态仍以进程内 Session Manager 为准。详见[SSH Broker](ssh-session.md)。 + +密码登录不要求 CA;`targets.UserCertificateAuthorityId` 可为空。配置该字段时,它表示 Server 为该目标签发用户登录证书所用的 CA。管理员仍须在 LAN 内配置目标 sshd 信任对应 CA,并建立 principal 与账户的允许关系;数据库关联不会自动修改 sshd。`host_keys` 是 Server 验证 sshd 身份时使用的主机公钥白名单,与用户 CA 分开。首版保存多个精确公钥以支持人工轮换,目标主机证书 CA 的验证策略尚未实现。 + +删除行为为 Restrict,防止误删身份、目标或 CA 时悄悄级联移除授权。SaveChanges 校验必填字段、长度、非空 ID、端口范围和 UTC 到期时间;数据库承担非空列、长度、唯一键和外键约束。实体值与关联是否合法仍须由未来 LAN 管理入口校验,例如主机公钥是否真实、目标地址是否允许、签名密钥引用是否在受信范围内。 + +## 授权检查与签发 + +`SshAccessResolver.ResolveAsync` 仅接受已完成 OIDC 验证的 `ExternalIdentity` 与目标 ID。一次参数化 LINQ 查询同时要求身份、目标、授权启用,授权未到期,且存在启用的目标主机公钥。无匹配时返回空结果;数据库故障抛出错误,调用方必须拒绝继续操作。查询使用 AsNoTracking,不缓存授权,所以后续调用可以看到已提交的撤销。 + +返回值是供 Broker 使用的内部授权快照,包含目标地址、系统账户、可选 principal 与 CA 信息、受信主机公钥及授权截止时间,不对 Client 返回。它既不是签发好的 SSH 证书,也不是可长期重复使用的访问令牌。 + +目标调用时机为创建会话、重新附着、接管,以及建立新的后端 SSH 连接之前。终端每次按键或输出不查询数据库,使用工作区当前 lease 检查控制权。附着与接管还必须检查工作区归属,目标授权通过不代表可以访问其他身份的工作区。 + +授权判断与后续网络连接并非原子事务:查询返回后仍可能发生撤销。签发应紧接授权检查、限制证书 principal 和有效期,且不超过授权截止时间;不可长期保存授权快照后再签发。Session Manager 后续需要消费撤销通知或定期复核,定义最大撤销生效延迟,并撤销现有控制权及按策略关闭连接。SSH 证书到期只限制后续认证,不会自动踢掉已有连接。这些签发与在线撤销行为目前尚未实现。 + +## 配置与迁移 + +开发时优先读取仓库根目录被 Git 忽略的 `workspace-access.config`;可从根目录 `workspace-access.config.example` 复制模板。发布后可用全局 `--config <路径>` 指定受保护的配置文件,或自行将同名文件放在可执行文件旁;显式路径不存在时直接报错。`workspace-access.config` 支持: + +```ini +[database] +host=127.0.0.1 +port=5432 +name=workspace_access +username=workspace_access +password=<数据库密码> +timeout=5 +maximum_pool_size=100 +``` + +`host`、`name`、`username` 是必填项;`port`、`password`、`timeout`(秒)、`maximum_pool_size` 可选。Server 用 `NpgsqlConnectionStringBuilder` 组装连接,密码中的连接字符串分隔符不会被当作其他参数。旧的 `[database] connection_string` 仍可单独使用,但不可与拆分字段混用。可选的环境变量 `WORKSPACE_ACCESS_DATABASE` 优先于文件,填写完整的 Npgsql 连接字符串。包含密码的部署配置应由服务管理器或受权限保护的配置提供,不能提交仓库或出现在命令行参数中。未配置数据库连接时 Server 仍可启动,readiness 报告 `database=not_configured`。 + +Server 为每个请求范围创建独立的 `AccessDbContext`,查询时由 Npgsql 连接池提供连接;一个 `DbContext` 不跨并发请求共享。可用 `maximum_pool_size` 限制并发数据库连接。当前授权查询是短查询,无需在 Server 内另建共享连接或异步命令队列;如果将来需要严格的数据库写入顺序,应在具体业务操作处定义顺序与事务边界。 + +由管理员创建专用数据库及角色。正常运行的 WAN 服务使用最小权限角色;当前授权查询只需管理表读取权限。LAN 管理入口将来需要独立的写权限。迁移使用临时注入的迁移角色连接,具备对应数据库/schema 的建表与修改权限。不要让 WAN 服务常态使用 postgres 超级用户。数据库不保存 CA 私钥、OIDC 令牌或终端输出。 + +开发时先配置连接,再执行: + +```powershell +dotnet run --project src/server -- --config workspace-access.config database migrate +``` + +发布后可运行 `char_rac_server database migrate`。该命令显式调用 EF MigrateAsync,重复运行只应用尚未执行的迁移;如果指定数据库不存在且账号有创建权限,EF 可以创建它。执行前确认连接指向专用数据库并检查迁移。正常服务启动不自动迁移。迁移命令允许单条语句最多执行两分钟,运行时查询默认十秒超时,避免数据库建表耗时与连接授权请求共用短超时。 + +添加后续迁移: + +```powershell +dotnet tool restore +dotnet ef migrations add ChangeName --project src/server --output-dir Data/Migrations -- --config workspace-access.config +``` + +EF 工具与运行时使用相同配置。更改模型后须提交迁移、模型快照和依赖锁文件。迁移创建需要配置数据库连接,但不需要数据库在线。 + +`/health/ready` 的 database 值为 `not_configured`、`unavailable`、`migration_required` 或 `ready`。检查实际连接、待执行迁移和管理表读取权限。即使 database 为 ready,尚未完成的托管后端 SSH 凭据、完整终端客户端和会话端到端验收仍使整体返回 HTTP 503。 + +## 数据维护与保留 + +PostgreSQL 的 `autovacuum` 默认启用,会根据各表的插入、更新和删除量自动执行 `VACUUM` / `ANALYZE`。默认约每分钟检查一次数据库,但这不是每分钟清理一次每张表;未达到触发条件的表不会因此运行 `VACUUM`。正式部署应保持 `autovacuum` 与 `track_counts` 启用,先观察运行数据,再决定是否针对高写入量的表调整阈值。当前没有证据需要另设固定时间的全库 `VACUUM` 任务,也不应把 `VACUUM FULL` 当作周期任务:它会对表取得排他锁。 + +可在 pgAdmin 的 Query Tool 中执行以下**只读**查询,检查当前实例设置及 `access` schema 的维护情况: + +```sql +SHOW autovacuum; +SHOW track_counts; +SHOW autovacuum_naptime; + +SELECT relname, n_live_tup, n_dead_tup, + last_autovacuum, autovacuum_count, + last_autoanalyze, autoanalyze_count +FROM pg_stat_user_tables +WHERE schemaname = 'access' +ORDER BY relname; +``` + +`n_live_tup` 和 `n_dead_tup` 是估算值;刚建好或很少改动的表尚无 `last_autovacuum` 记录是正常现象。普通 `VACUUM` 只回收已删除或更新的旧行及索引项,供后续写入复用,不会删除仍有效的业务记录。数据库连接池与 PostgreSQL 缓存也不需要定时清空。所有实体主键由应用生成 UUIDv7,没有需要回收、重置或复用的自增主键序列。 + +`workspace_records` 是工作区历史,不是数据库垃圾;`identities`、`targets`、`host_keys`、`ssh_login_keys`、`grants` 等是持久管理数据。当前不对它们设置自动删除期限。若将来需要压缩历史,须先确定审计保留期、备份和清理范围,只针对已结束且超过保留期的工作区记录实施单独的限批清理;不能把 `ClosedAt IS NULL` 当成在线判断并直接删除。专用测试库中由本机 Broker 探测生成的记录仍由显式 `database clear-probes` 命令清理,不影响正式记录。 + +PostgreSQL 机制参考:[Routine Vacuuming](https://www.postgresql.org/docs/18/routine-vacuuming.html)、[Vacuuming Configuration](https://www.postgresql.org/docs/18/runtime-config-vacuum.html)、[Statistics Monitoring](https://www.postgresql.org/docs/18/monitoring-stats.html)。 + +## 验收 + +设置 `WORKSPACE_ACCESS_TEST_DATABASE` 为专用 PostgreSQL 18 测试数据库的连接字符串后运行 `./build.ps1 --target Verify --locked-restore`。测试会在指定数据库的 `access` schema 上执行真实 EF 迁移;每个 `DbContext` 通过 Npgsql 连接池获取连接,用例结束时按生成的 ID 删除自身测试数据。不会删除数据库,也不要求测试角色拥有 `CREATEDB`。测试角色需要在该数据库中创建 schema 和表、读写业务表的权限。迁移建立的表与历史记录会保留;测试进程异常终止时也可能留下带随机 ID 的测试记录,因此不要把生产数据库配置为测试目标。未设置此变量时数据库集成测试明确跳过,原有核心测试仍运行。连接上限为 1 的本地测试角色可以顺序运行这些用例;并行运行其他数据库客户端仍可能受该上限影响。 + +测试覆盖迁移重复执行与模型一致性、唯一键与外键、一次授权查询、跨用户/issuer/目标隔离、带 SQL 特殊字符的输入、禁用状态、过期与撤销后的再次授权。数据库测试公钥是占位数据,仅验证存储和授权条件;另有主机公钥格式与精确匹配单元测试,真实 SSH 登录与证书登录仍待验收。 + +参考:[Npgsql EF Core 10](https://www.npgsql.org/efcore/release-notes/10.0.html)、[EF 迁移部署](https://learn.microsoft.com/en-us/ef/core/managing-schemas/migrations/applying)。 diff --git a/docs/development.md b/docs/development.md index a997d22..6395455 100644 --- a/docs/development.md +++ b/docs/development.md @@ -1,54 +1,29 @@ # 开发与 NUKE 构建 -## 工具链 +使用 .NET 10 或更高版本 SDK。global.json 设置最低 SDK 并允许更高版本;项目目标框架为 net10.0,入口使用显式 Program.Main。包版本集中在 Directory.Packages.props,锁文件固定依赖解析。 -安装 `global.json` 指定的 .NET 10 SDK,SDK 补丁选择遵循 `latestPatch`。包版本集中放在 `Directory.Packages.props`,每个项目的 `packages.lock.json` 记录依赖解析结果。 +解决方案 WorkspaceAccess.slnx 包含 src/server/WorkspaceAccessServer.csproj、src/client/WorkspaceAccessClient.csproj、测试项目和 NUKE 项目。各项目的构建输出统一位于根目录 `temp/<项目名>/bin/`,中间文件位于 `temp/<项目名>/obj/`;`temp/` 被 Git 忽略。Windows 用 ./build.ps1,Linux 用 bash ./build.sh。 -`src/WorkspaceAccessHost.csproj` 声明完整 RID 集合,使各平台发布共享稳定的依赖锁。产品代码直接按职责组织在 `src/` 下。解决方案包含产品、测试和 NUKE 三个项目。构建入口关闭 NUKE 遥测,编译和发布使用独立构建进程。 - -Windows 使用 `./build.ps1`,Linux 使用 `bash ./build.sh`。两者都运行 `build/Build.csproj` 中的 NUKE 程序。也可直接执行: - -```text -dotnet run --project build/Build.csproj --configuration Release -- --target Verify -``` - -## 构建目标 - -| 目标 | 行为 | +| NUKE 目标 | 行为 | | --- | --- | -| Restore | 恢复解决方案依赖;`--locked-restore` 启用锁定模式 | -| Compile | Restore 后构建整个解决方案 | -| Test | Compile 后执行核心测试并输出 TRX | -| CheckDocs | 检查标题、相对链接文件、README 可达性及差异说明覆盖 | -| Verify | CheckDocs 与 Test;默认目标 | -| Publish | Verify 后按 `--runtime` 发布单一产品和部署模板 | -| RunHost | Compile 后以 `host` 角色前台运行产品 | -| RunAgent | Compile 后以 `agent` 角色在当前用户身份下运行产品 | +| Restore、Compile、Test | 依次恢复、构建和运行核心测试 | +| CheckDocs、Verify | 检查文档;Verify 组合文档与测试 | +| Publish | Verify 后按 RID 发布 server/ 与 client/ | +| RunServer、RunClient | 前台运行对应程序 | -配置参数为 `--configuration Debug` 或 `Release`。发布 RID 为 `win-x64`、`win-arm64`、`linux-x64`、`linux-arm64`。 +Server 与 Client 共同支持 win-x64、win-arm64、linux-x64、linux-arm64;Client 另支持 linux-bionic-arm64(Android ARM64/Termux,依赖设备上的 .NET 10 运行时)。常用命令: ```powershell ./build.ps1 --target Verify --locked-restore ./build.ps1 --target Publish --runtime win-x64 -./build.ps1 --target Publish --runtime linux-x64 +./build.ps1 --target Publish --runtime linux-bionic-arm64 +dotnet run --project src/server +dotnet run --project src/client -- --help +dotnet run --project src/client -- status --server http://127.0.0.1:5080 ``` -## 开发检查 +Server 支持全局 `--config <路径>` 指定 INI 文件,服务启动、`database migrate`、`ssh probe` 和 `ssh broker-probe` 均使用它;相对路径在开发仓库中优先按仓库根目录解析,也可使用绝对路径。未指定时,开发环境优先读取仓库根目录被 Git 忽略的 `workspace-access.config`(模板为 `workspace-access.config.example`),发布后需用 `--config` 指向外部文件,或自行把同名文件放在可执行文件旁。配置提供 `[server] domain` 和 `listen`,示例监听 http://127.0.0.1:5080;健康端点为 /health/live 与 /health/ready,Client 状态探测为 /status。单域名路由见[通信入口](client-server-transport.md)。就绪端点在 SSH 和认证集成前返回 503。变更依赖时更新集中版本与锁文件,交付前执行 Verify。平台行为验收见[验证与验收](testing.md)与[Android CLI](platforms/android-client.md)。 -1. 修改实现及对应设计文档。 -2. 为身份边界、并发控制和资源生命周期增加测试。 -3. 执行 Verify,检查 `artifacts/test-results/`。 -4. 涉及原生行为时,在目标系统执行[平台验收](testing.md)。 +示例:`dotnet run --project src/server -- --config workspace-access.config ssh broker-probe`;此诊断现通过 Session Manager 发送 Linux `ls` 并显示输出。EF 工具可使用 `dotnet ef database update --project src/server -- --config workspace-access.config`。`--config` 不存在时会报错,不会回退到默认文件。完整 OIDC + WebSocket 的 CLI 命令见[通信入口](client-server-transport.md)。 -依赖升级时先修改集中版本,再执行 Restore,检查锁文件变化并执行 Verify。CI 先以锁定模式恢复构建程序,再通过 NUKE 验证和发布。 - -## 本地运行 - -Host 默认地址为 `http://127.0.0.1:5080`,命令行 `--urls` 或环境变量 `ASPNETCORE_URLS` 可覆盖地址。直接运行示例: - -```text -dotnet run --project src -- host --urls http://127.0.0.1:5081 -dotnet run --project src -- agent -``` - -健康检查分别为 `/health/live` 和 `/health/ready`。当前行为见[交付状态](implementation-status.md)。 +数据库模型使用 EF Core 和 Npgsql。在根目录配置 `[database]` 的 `host`、`name`、`username` 等字段后运行 `dotnet run --project src/server -- database migrate`;正常启动不自动迁移。新增迁移与真实 PostgreSQL 集成测试见[数据库开发](database.md)。本机 `ssh probe` 命令可从计划部署 Server 的机器验证到目标 sshd 的主机密钥、密码认证和 PTY Shell;步骤见[SSH Broker](ssh-session.md)。 diff --git a/docs/implementation-status.md b/docs/implementation-status.md index ae2a715..c4a0024 100644 --- a/docs/implementation-status.md +++ b/docs/implementation-status.md @@ -1,31 +1,28 @@ # 交付状态 -## 框架已实现 +## 已实现 -| 模块 | 可验证内容 | +| 模块 | 当前能力 | | --- | --- | -| 解决方案 | 单一 .NET 10 产品项目、测试项目、NUKE 项目、集中包版本与依赖锁文件 | -| NUKE | Restore、Compile、Test、CheckDocs、Verify、Publish、RunHost、RunAgent | -| Host | `host` 角色、ASP.NET Core 入口、Windows Service / systemd 生命周期集成、loopback 健康端点 | -| Agent | `agent` 角色、目标用户进程入口、日志、宿主退出响应 | -| 平台层 | `Platforms/Windows` 与 `Platforms/Linux` 目录及平台描述 | -| 核心契约 | 外部身份、终端尺寸与 PTY 会话接口 | -| 连接仲裁 | host 独占、显式接管、断线保留归属、过期 lease 拒绝与并发控制 | -| 开发治理 | 平台差异文档、链接和导航校验、双平台 CI、服务部署模板 | +| 解决方案 | .NET 10 Server、CLI Client、测试及 NUKE 项目;集中包版本与锁文件;构建产物统一放在 `temp/<项目名>/` | +| Server | 独立入口、Windows Service / systemd 集成、INI 配置、同账户本机管道 `status`、`sessions`、日志配置 `reload` 和默认关闭的 `disconnect`、OIDC JWT Bearer 验证、单身份单活跃 Client 设备仲裁、存活与就绪端点;受保护的资源列表、工作区创建、WebSocket 附着和结束路由;显式回环诊断入口 | +| 数据层 | EF Core 10 / Npgsql / PostgreSQL 18;身份、目标、主机公钥、普通 SSH 登录密钥引用、用户 CA、授权与工作区历史模型;迁移命令、本机目标/主机公钥/授权登记、`endpoint_regist` 三参数交互登记及私钥导入、单次查询授权解析及数据库 readiness,见[数据库](database.md) | +| Client | CLI 用户级 INI 默认 Server 入口及命令覆盖、状态探测、`login` 浏览器 OIDC PKCE 登录及 Server 身份验证、`connect` 登录后选择已有会话/新建目标,支持刷新列表及直接指定 ID;安装级设备凭据持久保存并随正式请求发送;正式交互创建/重新附着;一次性 `ls` 诊断保留;无 OIDC 的回环 `probe-session` 与交互式 `shell` 诊断命令;Android ARM64/Termux 的 framework-dependent 发布目标、`com.nitload.charac` 调试 APK 启动器与 Termux 环境检查,设备运行待验收 | +| 连接仲裁 | WorkspaceConnectionGate 管理单个工作区的身份归属、控制权接管、断线与过期 lease 拒绝;已接入 WebSocket 输入派发 | +| SSH Broker 与会话 | SSH.NET 密码及登录密钥建连、授权账户与目标解析、主机公钥固定校验、PTY Shell、独立工作区控制权、有界输出与断线保留;本机 `broker-probe` 可发送 Linux `ls`,见[SSH 会话设计](ssh-session.md) | +| 构建与打包 | Restore、Compile、Test、CheckDocs、Verify、Publish、RunServer、RunClient;Windows x64 Client MSI、Linux x64 Server/Client RPM、Termux aarch64 Client DEB 和 Android 调试 APK 已生成,仍需目标设备安装与运行验收;Client Windows x64 Native AOT 试编译成功,尚未作为默认包 | -## 集成工作 +旧 Host/Agent、PowerShell 标准流后端及自行管理 PTY 的代码已移除。Server 的受保护调用入口和一次性 CLI 验收路径已接通;回环无 OIDC 的 `probe-session` 已在 `.100 → .101` 实测通过,`shell` 的持续键盘输入和 VT 画面已在 VS Code 终端试用。Windows VT 鼠标转发已接入但尚待复测;2026-10-05 托管 SSH 登录密钥已通过本机 `key-probe` 完成 `.100 → .101` 数据库授权、主机公钥、PTY 和 `ls` 实测;登录密钥登记只保存受保护私钥的文件引用,不要求 `.pub` 副本,目标主机公钥固定校验仍保留;正式 Client 已实测通过 authentik 登录、资源选择及 `.101` 交互式 `ls`;SSH 正常退出时的 WebSocket 关闭修复待复测。`demo/oidc-cli` 已在真实 authentik public client 下完成浏览器 PKCE 登录与令牌交换;2026-10-04 正式 Client 的 `login` 也已实测通过 Server 令牌验证;资源选择到 SSH 交互主链路已在 `.100 → .101` 实测,见[通信入口](client-server-transport.md)。 -| 阶段 | 实现内容 | 验收入口 | -| --- | --- | --- | -| 用户代理 | Windows 登录启动、命名管道;Linux 用户启动组件与 socket | 系统账户归属及 IPC 对端校验 | -| 终端后端 | ConPTY、POSIX PTY、输入输出与进程清理 | 交互 shell、尺寸变更、退出及断线运行 | -| 认证授权 | authentik OIDC、账户映射、host 授权及撤销 | 两个外部身份和两个系统账户的访问测试 | -| 传输接入 | WSS、显式接管、心跳、有序输入 | 旧连接输入拒绝与网络恢复 | -| 终端状态 | 屏幕状态、输出历史、快照同步 | 全屏编辑器重连后的画面一致性 | -| 存储验收 | 目标 VirtioFS 版本与挂载配置 | 权限、变更通知、锁与保存行为 | +## 待实现 -## 健康状态 +| 组件 | 验收条件 | +| --- | --- | +| 认证授权验收 | 真实 authentik public client 下的 PKCE 登录、令牌验证、数据库身份映射及交互命令已实测;仍需跨设备和撤销验收 | +| 后端 SSH 凭据验收 | Server 的密钥登记、授权绑定和公钥认证已在 `.100 → .101` 实机通过本机诊断;正式 OIDC Client 已连接 `.101` 并执行 `ls`;正常退出和重新附着待复测。SSH CA 为可选方案 | +| Session Manager 完善 | 在线授权撤销、后端异常与慢客户端的完整验收、屏幕快照 | +| WebSocket 协议完善 | 心跳、丢失输出恢复和终端状态快照 | +| 客户端终端 | 正式 `connect` 的持续交互及 `ls` 已实测;尺寸变化、正常退出及重新附着待复测;工作区选择菜单已实现,自动重连待实现 | +| 目标平台验收 | Windows/Linux sshd、用户环境、终端应用和 VirtioFS | -`/health/live` 表示 HTTP 宿主存活。`/health/ready` 在框架阶段返回 HTTP 503,列出 OIDC、代理 IPC、PTY 和 WebSocket 集成项。就绪检查在接入真实组件后按实际依赖状态计算。 - -构建和发布证明代码与依赖可生成目标平台产物。操作系统用户上下文、服务安装、PTY 和 VirtioFS 行为通过目标系统上的集成验收确认。 +/health/live 表示 HTTP 进程存活。/health/ready 当前仍返回 HTTP 503,实际检查数据库连接、迁移与读取权限,并列出尚未完成的 ssh-backend-credential、terminal-client 和 session-e2e-validation;缺少 OIDC 配置或数据库未就绪时还会列出相应项。Server 进程或后端 SSH 连接终止后,原终端是否可恢复取决于另行部署的目标端持久化设施;当前设计只保证客户端断线期间由运行中的 Server 持有连接。 diff --git a/docs/overview.md b/docs/overview.md index 09467ed..ca2e6a4 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -2,41 +2,39 @@ ## 产品目标 -Workspace Access Host 提供持续存在的字符终端工作区。用户完成 authentik OIDC 认证后,以已授权的系统用户身份访问 shell 与 TUI 程序。Windows 和 Linux 共享连接、授权和工作区模型,通过平台适配实现系统服务、用户执行上下文与伪终端。 +Workspace Access Server 管理 Windows 与 Linux 上的远程 SSH 工作区。外部用户经 authentik OIDC 登录;Server 按经过验证的外部身份授权目标机器和系统账户,连接只在内网可达的 OpenSSH Server。对外接口为 HTTPS/WebSocket,客户端不直接接触目标 `sshd`。 -产品使用单一 C# 项目,内部按目录组织架构。发布程序通过 `host` 和 `agent` 参数分别运行系统服务与用户代理角色,共享一套部署产物。 +Windows OpenSSH 提供 ConPTY 与用户 Shell,Linux OpenSSH 提供 POSIX PTY 与用户 Shell。Server 负责 SSH 信任、连接控制、工作区归属与输出保持,不自行实现平台 PTY 或用户代理。 -## 功能 +## 功能与边界 -| 功能 | 当前设计 | +| 功能 | 目标行为 | | --- | --- | -| 身份来源 | authentik 管理外部身份,操作系统管理系统账户 | -| 身份关联 | 以 `(issuer, subject)` 标识外部身份,管理员配置系统账户映射与 host 访问规则 | -| 远程连接 | 客户端经 WSS 连接 broker,控制与终端数据按协议传输 | -| 连接独占 | 每个 host 一个有效远程连接;同身份显式接管时撤销旧控制权 | -| 工作区归属 | 归属独立于连接存在;断线后原身份可重新附着 | -| 终端持久性 | 用户代理持有 PTY 与进程,连接断开期间继续运行和读取输出 | -| 画面恢复 | 按终端状态快照与有序输出恢复光标、屏幕及滚动历史 | -| 输入控制 | 输入、终端缩放及会话变更均校验当前连接控制权 | -| 文件访问 | 遵循执行账户及实际存储后端权限;并行编辑使用独立工作目录 | -| 数据保存 | 应用负责文件保存,代理保存运行期间的终端状态;系统启动后建立新的运行会话 | -| 审计 | 记录认证结果、映射、连接获取、接管和会话结束等事件 | +| 身份 | authentik 负责 OIDC;操作系统负责系统账户与文件权限 | +| 映射 | 以 `(issuer, subject)` 标识外部身份,管理员授权目标机器和系统账户 | +| SSH 信任 | Server 管理后端 SSH 凭据并校验目标主机密钥;SSH CA 是可选实现方式 | +| 外部传输 | CLI 经 HTTPS 登录、经 WebSocket 附着工作区 | +| Android 客户端 | ARM64/Termux CLI 发布;设备运行和交互能力待验收,见[Android CLI](platforms/android-client.md) | +| 工作区 | Session Manager 按工作区持有后端 SSH 连接,可管理多个工作区 | +| 控制权 | 每个工作区只有一个有效远程控制者;同身份可显式接管 | +| 断线 | 外部 WebSocket 断开只解除附着;Server 继续读取后端输出 | +| 恢复 | 同身份重新授权后附着,按终端状态与有序输出恢复画面 | +| 生命周期 | 首期持久性以 Server 进程和后端 SSH 连接存活为前提 | +| 审计 | 记录认证、映射、SSH 凭据、接管、连接及工作区结束事件 | + +后端 SSH 凭据只解决 Server 到 sshd 的认证,不负责工作区持久性。Server 重启或后端 SSH 断线后的原会话恢复不属于首期保证。 ## 平台差异 | 项目 | Windows | Linux | 详细说明 | | --- | --- | --- | --- | -| 服务宿主与 IPC | Windows Service、命名管道 | systemd、Unix domain socket | [服务宿主](platforms/service-hosting.md) | -| 用户环境来源 | 目标用户已登录时启动用户代理,关联 SID | 为现有系统账户建立 UID/GID 用户上下文 | [用户环境](platforms/user-environment.md) | -| 伪终端 | ConPTY 与 Windows 进程 API | POSIX PTY、控制终端与进程组 | [终端后端](platforms/terminal-backend.md) | -| 权限与进程边界 | Windows 访问令牌、对象 ACL、Job Object | UID/GID、文件权限、cgroup 和进程组 | [权限与进程](platforms/process-security.md) | -| 用户会话生命周期 | 跟随用户登录会话,锁屏期间保持工作区 | 跟随用户会话宿主与服务策略运行 | [生命周期](platforms/session-lifecycle.md) | -| 文件系统与共享目录 | NTFS 或 VirtioFS;验证 SID 与宿主权限映射 | 原生文件系统或挂载目录;验证 UID/GID 与锁行为 | [存储访问](platforms/storage.md) | -| 系统管理权限 | Windows 用户权限分配和提升策略 | sudo、polkit 与服务权限策略 | [系统管理](platforms/system-control.md) | -| 发布与注册 | Windows RID、SCM 注册和用户登录启动 | Linux RID、systemd system/user unit | [发布部署](platforms/deployment.md) | +| 服务宿主与 SSH | Windows Service、Windows OpenSSH | systemd、OpenSSH | [服务宿主](platforms/service-hosting.md) | +| 用户环境 | Windows 账户与 SSH 登录令牌 | UID/GID 与 SSH 登录环境 | [用户环境](platforms/user-environment.md) | +| 终端后端 | sshd 使用 ConPTY | sshd 使用 POSIX PTY | [终端后端](platforms/terminal-backend.md) | +| 进程与权限 | Windows 访问令牌、ACL | UID/GID、文件权限 | [权限与进程](platforms/process-security.md) | +| SSH 会话生命周期 | Windows sshd 与服务策略 | Linux sshd 与服务策略 | [生命周期](platforms/session-lifecycle.md) | +| 文件系统 | NTFS 或 VirtioFS | 原生文件系统或挂载目录 | [存储访问](platforms/storage.md) | +| 系统管理 | Windows 用户权限与提升策略 | sudo、polkit | [系统管理](platforms/system-control.md) | +| 发布部署 | Windows RID、SCM、OpenSSH 配置 | Linux RID、systemd、OpenSSH 配置 | [发布部署](platforms/deployment.md) | -## 使用条件 - -Windows 标准运行方式是已登录用户代理。宿主进入等待状态后,目标用户登录即可建立代理上下文。部署可以选用专门的本地账户。Linux 的用户上下文由受控启动组件创建。两个平台的首期连接策略均为 host 级独占。 - -当前代码覆盖范围见[交付状态](implementation-status.md),验收项见[验证文档](testing.md)。 +当前代码覆盖范围见[交付状态](implementation-status.md),验收见[验证文档](testing.md)。 diff --git a/docs/platforms/android-client.md b/docs/platforms/android-client.md new file mode 100644 index 0000000..84f2a59 --- /dev/null +++ b/docs/platforms/android-client.md @@ -0,0 +1,38 @@ +# Android CLI 客户端 + +Android 首期支持 ARM64 设备上的 Termux 终端,CLI 使用 `linux-bionic-arm64` RID。另有独立的 .NET Android 启动器项目 `src/android/CharacAndroid.csproj`,包 ID 为 `com.nitload.charac`;Server 仍只部署在 Windows 或 Linux。 + +执行 `./build.ps1 --target Publish --runtime linux-bionic-arm64 --locked-restore`,或在 Linux 上使用 `bash ./build.sh`。随后运行 `./packaging/package.ps1 -Target ClientTermuxDeb`,生成 `artifacts/packages/charac_0.1.0-1_aarch64.deb`。该包面向标准包名 `com.termux` 的 apt 版 aarch64 Termux,将 Client 安装到 `$PREFIX/opt/charac`,将 `charac` 入口装到 `$PREFIX/bin`,并依赖 Termux 的 `dotnet-runtime-10.0`。在 Termux 中复制 DEB 后运行 `apt install ./charac_0.1.0-1_aarch64.deb`;当前尚未发布到 Termux 仓库,因此 `pkg install charac` 还不能从公共仓库下载它。APK 的环境检查会识别包提供的固定入口。 + +如果暂时不使用 DEB,也可以复制 `artifacts/publish/linux-bionic-arm64/client/` 到 Termux 私有目录,例如 `$HOME/.local/opt/charac`。这是 framework-dependent 发布,需要自行安装 .NET 10 或更高兼容运行时。为 APK 提供固定入口 `$PREFIX/bin/charac`,其内容用 Termux 的 `sh` 调用 `dotnet $HOME/.local/opt/charac/charac.dll` 并原样传递参数;入口必须可执行。若应用宿主无法定位运行时,可在 Termux 中运行 `dotnet $HOME/.local/opt/charac/charac.dll --help` 检查。 + +手动复制方案在 Termux 中完成后,创建入口: + +```sh +cat > "$PREFIX/bin/charac" <<'SH' +#!/data/data/com.termux/files/usr/bin/sh +exec dotnet "$HOME/.local/opt/charac/charac.dll" "$@" +SH +chmod 700 "$PREFIX/bin/charac" +``` + +两种安装方式都要设置 Client 的默认入口: + +```sh +mkdir -p "$HOME/.config/charac" +printf '[client]\nserver=https://access.example.com\n' > "$HOME/.config/charac/client.config" +``` + +将 `access.example.com` 改为实际 RAC HTTPS 入口。当前代码含 `login` OIDC PKCE 浏览器登录、本机 loopback 回调、`connect` 正式 WebSocket 交互及一次性 `ls` 诊断,但尚未在 Android/Termux 实机验证浏览器回调或交互。终端原始模式、键盘映射、窗口尺寸同步与 WebSocket 自动重连仍待实现。跨平台构建和发布不等于设备运行验收;验收须在真实 ARM64 Android/Termux 环境检查启动、认证回调、前后台切换和网络恢复。 + +参考:[.NET RID 目录](https://learn.microsoft.com/en-us/dotnet/core/rid-catalog)、[Termux .NET 包](https://github.com/termux/termux-packages/tree/master/packages/dotnet10.0)。 + +## One UI APK 与 Termux + +APK 包名使用 `com.nitload.charac`,厂商标识为 `nitload`,要求 Android 12 或更高版本。启动器检测 `com.termux` 与自身的 `com.termux.permission.RUN_COMMAND` 授权;用户点击环境检查后,通过 Termux 的 `RUN_COMMAND` 在后台检查 `$PREFIX/bin/charac` 与 `dotnet`,成功才允许启动固定命令 `charac connect`。Termux 里的 Client 自动读取 `~/.config/charac/client.config`。APK 不保存 SSH 私钥、OIDC 令牌或 Termux 的设备凭据。 + +Android 应用不能直接读取、改写 Termux 私有目录,也不能替用户静默授予跨应用执行权限。用户仍需在 Android 设置中授权 charac 的 `RUN_COMMAND` 权限,并在 Termux 的 `~/.termux/termux.properties` 设置 `allow-external-apps=true`;然后安装上面的 DEB(自动安装 .NET 运行时依赖)或按手动方案复制 CLI,并设置用户级配置。启动器有权限设置入口;检测不到 Termux 时显示安装提示。One UI 的后台启动限制可能要求在通知栏点开 Termux,或授予 Termux“悬浮窗”权限。 + +在安装了 .NET Android workload 和 Android SDK 的构建机上,运行 `dotnet build src/android/CharacAndroid.csproj -c Debug -p:NuGetAudit=false` 可生成调试签名 APK;再运行 `./packaging/package.ps1 -Target ClientApkDebug` 将其收集到 `artifacts/packages/charac-client_0.1.0_android-debug.apk`;调试签名只用于设备验证,正式升级发布需要 nitload 保管固定签名密钥。Android/Termux 实机的权限回调、CLI 启动、浏览器登录与键盘交互尚未验收。 + +依据:[Termux RUN_COMMAND 官方说明](https://github.com/termux/termux-app/wiki/RUN_COMMAND-Intent)。 diff --git a/docs/platforms/deployment.md b/docs/platforms/deployment.md index 4b18497..b7417ef 100644 --- a/docs/platforms/deployment.md +++ b/docs/platforms/deployment.md @@ -1,17 +1,39 @@ -# 平台发布与部署 +# 平台部署 -| 项目 | Windows | Linux | -| --- | --- | --- | -| 发布 RID | `win-x64`、`win-arm64` | `linux-x64`、`linux-arm64` | -| 产品入口 | `WorkspaceAccessHost.exe host` / `agent` | `WorkspaceAccessHost host` / `agent` | -| 服务管理 | SCM 注册、启动类型和账户配置 | systemd unit、服务账户和目录权限 | -| 用户代理启动 | 用户登录启动任务或用户启动项 | 用户上下文启动组件或 user unit | -| 本机通信路径 | 命名管道 | `/run` 下受控 socket 路径 | +当前提供 Windows x64 Client MSI、Fedora/RHEL 系 Linux x64 Server RPM 和 Client RPM,以及 Termux aarch64 Client DEB。RPM/MSI 由 .NET 10 发布目录生成;`packaging/package.ps1` 只负责封装,发布目录必须先经过 NUKE `Publish`(含 `Verify`)。Android/One UI 的 Termux 启动器 APK 已可生成调试包,需与 Termux DEB 配合,见 [Android 客户端](android-client.md)。 -NUKE `Publish` 先执行 Verify,再发布单一产品的 self-contained 产物,并复制平台部署模板。输出位于 `artifacts/publish//`,根目录包含可执行文件、配置与运行时依赖,`deploy/` 包含部署模板。Host 和 Agent 使用同一可执行文件,通过启动参数选择角色,整套产物统一部署和更新。 +在仓库根目录执行: -服务配置使用发布目录中的 `appsettings.json` 和 .NET 环境变量覆盖。初始 HTTP 端点位于 loopback。远程传输集成时由受控 TLS 入口提供 WSS,并配置 host 白名单、认证及 Origin 策略。 +```powershell +$env:NuGetAudit='false' +.\build.ps1 --target Publish --runtime linux-x64 --locked-restore +.\build.ps1 --target Publish --runtime win-x64 --locked-restore +.\build.ps1 --target Publish --runtime linux-bionic-arm64 --locked-restore +.\packaging\package.ps1 -Target ServerRpm +.\packaging\package.ps1 -Target ClientRpm +.\packaging\package.ps1 -Target ClientMsi +.\packaging\package.ps1 -Target ClientTermuxDeb +``` -x64 的 Windows 与 Linux 验证流程由 CI 分别执行。ARM64 产物通过指定 RID 发布,并在对应硬件或虚拟机完成运行验收。 +封装需要 [nFPM](https://nfpm.goreleaser.com/) 2.47.0 和 [WiX Toolset](https://wixtoolset.org/) 6.0.2。脚本默认从 `temp/tools/nfpm/nfpm.exe`、`temp/tools/wix.exe` 寻找,也可传 `-NfpmPath`、`-WixPath`。输出在 `artifacts/packages/`;这两个工具和发布产物均不提交仓库。Fedora/RHEL 的 ARM64 RPM 尚未配置。 -部署步骤见 [Windows](../../deploy/windows/README.md) 与 [Linux](../../deploy/linux/README.md)。服务安装由部署人员执行。 +## Server RPM 首次部署前提 + +这套部署复用现有 authentik、PostgreSQL、Caddy 和内网 sshd,不需要在 Server RPM 内再部署一套身份平台。安装包只装程序、systemd 单元和非 root 服务账户,不会自动填入生产配置、创建数据库、配置反向代理或向目标 sshd 登记公钥。首次上线按下列顺序准备: + +1. 确认 Server 主机能访问 PostgreSQL、authentik 的 OIDC discovery/JWKS,以及内网各目标的 SSH 端口;公网只暴露 Caddy 的 HTTPS 入口,不暴露 PostgreSQL 和目标 SSH。 +2. 在 PostgreSQL 创建专用数据库和受限角色。安装 RPM 后将配置写入 `/etc/charac/workspace-access.config`,填 `[database]`、`[oidc]`、`[server]` 和 `[ssh_keys]`;文件只允许 `charac` 服务账户读取。用同一配置执行 `char_rac_server --config /etc/charac/workspace-access.config database migrate`,然后核对迁移结果。服务正常启动不会自动迁移。 +3. 在 authentik 为 CLI 准备 public OIDC Provider,启用 PKCE 和本机动态 loopback 回调;Server 配置正确的 issuer、client ID 和 discovery URL。详情见[认证](../authentication.md)。 +4. 创建服务账户持有的 `/var/lib/charac/keys`(目录 `0700`,私钥 `0600`),从可信渠道核对目标 sshd 主机公钥。使用本机 `endpoint_regist` 导入 Server 登录私钥、登记目标,并授权 OIDC subject 到目标 OS 账户;目标账户的 `authorized_keys` 也须信任相应登录公钥。详情见[SSH 登记](../ssh-session.md#三参数交互式端点登记)。 +5. 配置 Caddy 的域名、TLS 和到 Server 监听地址的反向代理;需要读取真实客户端 IP 时再设置 `[server] trusted_proxy`。最后启动 `charac-server.service`,检查 `systemctl status` 与 `journalctl -u charac-server`,再从 Client 完成真实登录和 SSH 连接。 + +当前 `/health/ready` 还包含未完成能力标记,可能返回 503;不要将它作为首次部署成功的唯一判据。运行时可核对进程状态、数据库迁移、OIDC 登录和实际工作区连接。Server RPM 不启动 PostgreSQL 容器;数据库可以是独立容器或现有 PostgreSQL 实例。 + +Server RPM 安装自包含程序到 `/opt/charac/server`,安装 `charac-server.service` 和专用 `charac` 系统账户。包不会携带运行配置、数据库密码或 SSH 私钥,也不会在安装时自动启动服务。部署机上准备 `/etc/charac/workspace-access.config`(只允许服务账户读取),在 `[ssh_keys]` 指定 `/var/lib/charac/keys`,该目录及私钥由 `charac` 拥有且不允许其他用户访问。配置样例安装于 `/usr/share/doc/charac-server/workspace-access.config.example`。准备 PostgreSQL 数据库并执行迁移后,再用 `systemctl enable --now charac-server` 启动。服务监听地址和 Caddy 等反向代理按实际部署配置;Server 只需能访问内网 SSH 目标。详情见 [Linux 部署](../../deploy/linux/README.md)。 + + +Server 运行时只需 `--config /etc/charac/workspace-access.config`。这一个 INI 保存监听地址、OIDC 信息、PostgreSQL 连接参数和 `[ssh_keys] directory`;它不负责启动 PostgreSQL 容器。PostgreSQL 容器及其数据卷应由独立的数据库部署管理,RAC 只使用受限数据库账户连接。`charac` 是非 root 的系统服务账户,`StateDirectory=charac` 负责 `/var/lib/charac`,`/var/lib/charac/keys` 建议设为该账户所有、目录 `0700`、私钥 `0600`。当前没有磁盘会话缓存或单独日志文件:工作区状态留在 Server 内存与数据库历史中,标准日志进入 systemd journal,可用 `journalctl -u charac-server` 查看。无需额外配置 `CacheDirectory` 或 `LogsDirectory`;将来确有持久快照或文件日志时再增加。服务通过高端口监听,由 Caddy 承接 443,无需为网络入口提升 Server 到 root。 +Client RPM 安装自包含程序到 `/opt/charac/client`,并提供 `/usr/bin/charac` 命令。Windows MSI 安装到 `Program Files\nitload\Charac Client`,把安装目录加入系统 `PATH`;新终端可运行 `charac.exe --help`。MSI 内嵌所需文件,不依赖独立 CAB。两个 Client 包均不预置登录令牌或目标配置。 + +RPM 尚需在目标 Fedora/RHEL 机器进行实际安装、升级、卸载和 systemd 验收;Termux DEB 尚需在真实 Android/Termux 设备上安装、启动和登录验收;MSI 尚需在正常启用 Windows Installer 服务的 Windows 机器进行安装、升级、卸载验收。当前构建环境完成了 RPM 生成及文件头检查、Termux DEB 控制字段和文件路径检查、MSI 生成及文件表反编译检查;受限环境里的 ICE 校验无法连接 Windows Installer 服务。 +Client 可在用户级 INI 的 `[client] server` 写入默认 HTTPS origin,样例见仓库根目录 `client.config.example`;Client RPM 另安装到 `/usr/share/doc/charac-client/client.config.example`,MSI 则安装到程序目录。Windows 默认读取 `%APPDATA%\Charac\client.config`;Linux/Termux 默认读取 `$XDG_CONFIG_HOME/charac/client.config`,未设置时读取 `~/.config/charac/client.config`。之后直接执行 `charac connect`(Windows 为 `charac.exe connect`);`--server ` 可临时覆盖,`--config <路径>` 可在命令前选用其他配置。该文件只存入口地址,不存 OIDC 令牌;旧版 `%APPDATA%\CharRAC`、`~/.config/char-rac` 配置在新位置不存在时仍会自动读取。Client 安装级设备凭据仍独立存于用户本地应用数据目录,不能复制到多台设备共用。 diff --git a/docs/platforms/process-security.md b/docs/platforms/process-security.md index 51d14d0..e00a26a 100644 --- a/docs/platforms/process-security.md +++ b/docs/platforms/process-security.md @@ -1,16 +1,9 @@ -# 进程权限与隔离 +# 进程与安全边界 -| 层次 | Windows | Linux | +| 项目 | Windows | Linux | | --- | --- | --- | -| 账户权限 | 用户访问令牌、SID 与文件 ACL | UID、GID、附加组与文件权限 | -| 代理 IPC | 命名管道 ACL 与对端身份校验 | socket 路径权限与 peer credentials | -| 进程归属 | Job Object 与进程句柄 | 进程组与 systemd/cgroup | -| 系统管理 | 令牌权限及提升策略 | capabilities、sudo、polkit | +| 目标进程权限 | sshd 按目标账户令牌创建 | sshd/PAM 按目标 UID/GID 创建 | +| Server 权限 | 独立服务账户,保护 CA 凭据 | 独立服务账户及受限文件权限 | +| SSH 配置 | Windows OpenSSH 服务配置 | Linux sshd 与 PAM 配置 | -网络宿主以服务账户执行网络与授权逻辑;用户代理以目标账户执行终端程序。身份切换或代理创建接口接受经过验证的账户映射和限定启动参数。 - -工作区清理追踪完整进程树。显式结束先执行有序退出,再按策略终止剩余进程,并释放 PTY 及本机通信资源。 - -完全复用用户环境时,终端具备该用户相应的系统访问能力。部署权限策略决定进程提升、系统管理和共享目录访问范围。文件权限在实际存储后端验收,见[存储说明](storage.md)。 - -验收包括不同账户的代理访问、伪造映射、越权工作目录、子进程清理,以及普通账户的管理权限边界。 +Server 必须先验证 OIDC、授权、账户映射和目标主机密钥,再建立 SSH 连接。目标 sshd 的账户权限限制文件与系统操作;不能把 Server 服务账户权限传给终端用户。证书应短期有效且限定 principal。关闭工作区时先尝试正常关闭 SSH channel,再按明确策略处理残留目标进程;目标进程清理能力须在实际 sshd 环境验收。 \ No newline at end of file diff --git a/docs/platforms/service-hosting.md b/docs/platforms/service-hosting.md index da80022..b03cfe6 100644 --- a/docs/platforms/service-hosting.md +++ b/docs/platforms/service-hosting.md @@ -1,16 +1,17 @@ -# 服务宿主与本机通信 +# 服务宿主 -| 平台 | 系统入口 | 用户代理通信 | +| 项目 | Windows | Linux | | --- | --- | --- | -| Windows | Windows Service Control Manager;`AddWindowsService` | 带 SID ACL 的命名管道 | -| Linux | systemd system unit;`AddSystemd` | 带文件权限和对端凭据检查的 Unix domain socket | +| Server | Windows Service | systemd service | +| SSH 服务 | OpenSSH sshd 系统服务 | OpenSSH sshd 系统服务 | +| 日志 | Windows 事件与应用日志 | journal 与应用日志 | -Host 负责远程连接与控制权仲裁,用户代理持有工作区。服务账户具备配置读取与本机通信所需的权限。IPC 连接建立后验证协议版本、对端系统身份和目标账户。 +Server 作为独立服务运行,管理认证、SSH Broker 和工作区。目标 sshd 独立运行且只在受控内网接受来自 Server 的连接。Server 的存活端点与业务就绪端点分开;后者须以 OIDC、CA、SSH、Session Manager 和 WebSocket 的实际状态为准。当前实现服务入口、健康端点与客户端状态探测;公网域名由 `workspace-access.config` 的 `[server] domain` 指定,并由可信反向代理转发,见[通信入口](../client-server-transport.md)。部署见[平台部署](deployment.md)。 -Windows 代理在目标用户登录时启动。Linux 用户代理由用户上下文启动组件管理。本机通信路径按 host 与系统用户标识确定,进程启动后执行注册握手。 +## 本机管理命令 -当前 Host 已集成系统服务生命周期与健康检查。IPC 注册属于后续集成项。部署入口见 [Windows](../../deploy/windows/README.md) 与 [Linux](../../deploy/linux/README.md)。 +运行中的 Server 在本机建立与配置文件绝对路径绑定的命名管道。新启动的 `char_rac_server --config <同一路径> status`、`sessions` 或 `reload` 作为管理客户端连接现有进程,不启动第二份 HTTP 服务,也不向运行中的进程注入代码。`status` 返回进程 ID 和当前 SSH 工作区数量;`sessions` 列出工作区 ID、OIDC issuer/subject、目标、系统账户、附着状态、时间以及当前 WebSocket 的连接来源;`reload` 重新读取 INI,仅在线应用 `[Logging]` 设置。修改监听地址、OIDC、数据库、SSH 目标或其他设置时,命令明确拒绝并提示重启,原配置和工作区继续运行。配置文件路径必须与服务启动时解析出的路径相同。 -验收:服务随系统启动;服务停止完成资源清理;代理识别正确服务身份;其他账户访问 IPC 时按规则返回拒绝结果。 +管道使用 `PipeOptions.CurrentUserOnly`,因此管理命令必须以与服务相同的系统账户执行。配置路径的哈希只用于定位管道,不是认证凭据。Linux 的命名管道底层使用 Unix 域套接字;部署模板保留 `UMask=0077`,并且不隔离 `/tmp`,以便该账户在另一个终端连接。Windows 服务使用独立账户时,管理命令也须在该账户上下文中运行。此管理通道不在 HTTP/WebSocket 或 Caddy 入口上暴露。必须限制服务账户的交互登录;同账户运行的进程可以调用该管道。连接来源默认取 Server 直接看到的网络对端;经 Caddy 代理时默认显示代理地址。若需要显示 Caddy 上游客户端地址,在 `[server] trusted_proxy=` 中显式信任这一跳,Server 才读取 `X-Forwarded-For` 的最后一跳。其他来源传入的转发头不被信任。完整配置热替换需要逐组件设计切换与回滚,目前不支持。 -参考:[Windows Service](https://learn.microsoft.com/en-us/dotnet/core/extensions/windows-service)、[systemd 集成](https://devblogs.microsoft.com/dotnet/net-core-and-systemd/)。 +切断用户连接需在 INI 中设置 `[management] allow_disconnect=true` 并重启 Server,默认关闭。管理员在服务账户上下文中运行 `char_rac_server --config <同一路径> disconnect --subject --confirm`,Server 按配置的 issuer 和给定 subject 精确匹配,断开该身份当前附着的所有 Client/WebSocket,令旧连接的输入租约立即失效并记录日志。该操作同时释放当前 Client 设备的占用;SSH 工作区继续运行,用户持有效令牌且仍获授权时可以从任一设备重新连接。这是一次性断开操作,不改变授权;持续禁止访问须另行撤销数据库授权。命令需要显式 subject 和 `--confirm`,Server 还会检查启用开关;这些参数用于减少误操作,真正的本机权限边界是管道所属系统账户。`allow_disconnect` 不支持热重载。 diff --git a/docs/platforms/session-lifecycle.md b/docs/platforms/session-lifecycle.md index 5b64b0b..abc8ea5 100644 --- a/docs/platforms/session-lifecycle.md +++ b/docs/platforms/session-lifecycle.md @@ -1,16 +1,16 @@ # 会话生命周期 -| 事件 | Windows | Linux | +| 阶段 | Windows | Linux | | --- | --- | --- | -| 创建工作区 | 已登录用户代理建立 ConPTY | 用户上下文中的代理建立 PTY | -| 网络断开 | 代理继续持有终端和进程 | 代理继续持有终端和进程 | -| 同身份重新连接 | 重新授权并附着原工作区 | 重新授权并附着原工作区 | -| 锁屏或关闭桌面连接 | 用户登录会话仍存在时保持工作区 | 按用户代理所属服务生命周期保持工作区 | -| 用户注销 | 用户代理及进程受 Windows 登录会话清理影响 | 按 user service、会话清理及 linger 配置执行 | -| 系统启动 | 等待目标用户代理注册后建立新工作区 | 服务准备用户上下文后建立新工作区 | +| 创建 | Server 通过 SSH 请求目标 OpenSSH 创建 ConPTY Shell | Server 通过 SSH 请求目标 OpenSSH 创建 POSIX PTY Shell | +| 断开客户端 | Server 保持 SSH channel 并持续读取输出 | 同左 | +| 后端中断 | 原 SSH channel 消失 | 同左 | -持久性范围为系统本次运行期间、用户代理存活时的工作区。客户端断线仅解除连接;工作区终止是独立的授权操作。 +当前 `SshWorkspaceManager` 按工作区分别保存身份归属、控制权、SSH channel 和最近 256 KiB 输出。一个工作区同时只有一个输入控制者;Server 可管理多个工作区。客户端断线不终止 SSH;显式结束、后端 SSH EOF 或 Server 停止会释放资源。后端结束时,Server 会先排空该观察者队列,再通过 WebSocket 发送 `ended` 事件并关闭连接;接管造成的旧观察者断开不会声称 SSH 已结束。撤销授权会阻止新的附着与结束请求,但尚不会自动终止已经附着的连接。Server 重启无法凭 SSH CA 恢复已经断开的交互终端,如需跨重启持久化,应设计目标侧会话管理。协议见[连接与会话](../connection-protocol.md)。 -连接归属在断线后保留。同一身份重连获取新控制权,接管撤销旧连接。会话管理器完成进程清理后释放工作区归属。broker 重启后的代理重新登记属于协议集成验收范围。 +正式工作区创建时,Server 将身份键、目标、系统账户、认证方式与创建时间写入 PostgreSQL `access.workspace_records`。正常关闭会补写结束时间和原因,Server 正常停止记录 `server_stopped`;崩溃可能留下未补写的历史行。历史行始终保留,但它不是持久化的 SSH channel,也不会在重启后出现在可附着资源列表。测试用例只清理各自创建的临时数据,Server 停止不清理已登记身份、密钥或历史记录。 -验收分别执行网络断开、浏览器关闭、锁屏、用户注销、代理退出和显式终止,记录工作区状态与进程结果。系统重启后验证新环境的建立条件。 +同一 OIDC 身份的活跃 Client 连接由 Server 按安装凭据仲裁:一台 Client 可同时附着多个工作区,另一台 Client 在前者仍有活跃连接时被拒绝。所有 WebSocket 断开后,设备占用释放,但 SSH 工作区继续运行。管理端 `disconnect` 仅中断该身份当前的 Client/WebSocket 并释放占用;它不删除工作区或修改目标授权。 + + +资源列表仅暴露当前身份仍有权访问且账户映射一致的存活工作区,并携带创建时间及 `attached`/`detached` 状态。列表是快照,不预占控制权;查询后退出、接管、授权撤销等变化以实际附着请求的结果为准。Server 不为资源列表探测或新建 SSH 连接。 diff --git a/docs/platforms/storage.md b/docs/platforms/storage.md index 0800d31..a85520b 100644 --- a/docs/platforms/storage.md +++ b/docs/platforms/storage.md @@ -1,30 +1,5 @@ -# 文件存储与 VirtioFS +# 文件存储 -| 项目 | Windows | Linux | -| --- | --- | --- | -| 本地文件 | NTFS 等本地卷,使用 Windows 文件 API 与权限 | 原生文件系统,使用 POSIX 文件 API 与权限 | -| 宿主共享目录 | Windows VirtioFS 驱动与 WinFsp | VirtioFS 客户端与宿主目录 | -| 身份映射 | 验证 SID、挂载配置和宿主 UID/GID 的关系 | 验证 UID/GID、附加组及宿主权限 | -| 变更与锁 | 验证 Windows 通知、共享模式和锁的传播 | 验证文件通知、锁及跨挂载访问行为 | +目标用户的文件访问由目标操作系统账户、文件系统权限及挂载配置决定。Server 通过 SSH 提供终端连接,不代理文件系统操作。若目标工作区使用 VirtioFS,须在所选驱动、挂载与宿主配置上验证读写权限、文件锁、变更通知、原子保存及重命名。 -## 路径安排 - -服务配置、运行元数据及用户配置优先放在操作系统本地卷。项目文件可放在 VirtioFS 工作目录。挂载可用性和目标用户访问检查在创建工作区前执行。 - -并行编辑为本地操作与远程操作分配独立工作目录。代码项目可以采用独立 clone 或 Git worktree,通过版本控制整合修改。远程连接独占管理客户端控制权,文件写入由应用与存储权限决定。 - -## 兼容性记录 - -每个部署记录 PVE、virtiofsd、Windows virtio-win / WinFsp 版本、宿主文件系统、导出路径和挂载选项。 - -| 验证项 | 场景 | -| --- | --- | -| 可见性 | 目标用户代理上下文访问预期盘符或挂载点 | -| 权限 | 两个系统账户分别读取、创建、修改和删除文件 | -| 外部变更 | Windows 本地程序、宿主和其他 guest 修改文件后,编辑器观察变化 | -| 锁 | 不同进程及不同访问入口的独占锁和共享模式 | -| 保存 | 临时文件写入后重命名替换、刷新、重复保存 | -| 路径 | Unicode、大小写、长路径与符号链接 | -| 开发工具 | Git 操作、构建输出、文件监听和高频小文件访问 | - -参考:[Windows VirtioFS](https://github.com/virtio-win/kvm-guest-drivers-windows/wiki/Virtiofs:-Shared-file-system)、[Git worktree](https://git-scm.com/docs/git-worktree)。 +Windows 需记录 VirtioFS 驱动和 WinFsp 版本,并验证 ACL 与账户 SID 的实际行为。Linux 需验证 UID/GID 映射、权限位、缓存和挂载选项。终端连接成功不等于存储行为已经验收。 \ No newline at end of file diff --git a/docs/platforms/system-control.md b/docs/platforms/system-control.md index fc6f51c..2285a2d 100644 --- a/docs/platforms/system-control.md +++ b/docs/platforms/system-control.md @@ -1,17 +1,5 @@ -# 系统管理权限 +# 系统管理操作 -| 能力 | Windows | Linux | -| --- | --- | --- | -| 本机关机和重启 | 系统用户权限分配,包含 `SeShutdownPrivilege` | 登录会话授权、polkit、sudo 或 root 权限 | -| 网络发起系统关机 | 对应远程管理权限及服务策略 | 对应远程管理服务和账户策略 | -| 提升执行权限 | Windows 用户令牌与提升机制 | sudo、capabilities 及服务管理接口 | +工作区内命令以目标 SSH 系统账户运行。Windows 管理权限由目标账户令牌及 UAC/系统策略决定;Linux 管理权限由目标账户、sudo/polkit 等策略决定。Server 不向工作区授予额外系统权限,也不把 SSH CA 凭据暴露给客户端。 -终端使用目标系统账户的权限。部署阶段明确该账户的系统管理能力,并使用操作系统策略控制可执行的管理操作。通过远程终端运行的程序属于目标机器上的本地进程。 - -复用登录用户时,账户级权限策略也作用于该用户的其他程序。需要不同权限边界的部署使用专用执行账户或经过验证的受限上下文。 - -管理入口与普通终端输入分别授权。系统管理操作记录请求身份、目标、时间和执行结果。系统启动后的工作区状态由[用户环境](user-environment.md)和[生命周期](session-lifecycle.md)定义。 - -验收使用系统权限查询和具有代表性的系统管理 API,确认工作区与管理入口符合配置。PTY 输入作为终端字节流处理。 - -参考:[Windows 关机 API 权限](https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-exitwindowsex)。 +验收应分别使用普通账户和允许执行管理操作的账户,确认权限边界与审计行为。授权映射必须明确限制可登录的目标和系统账户。 \ No newline at end of file diff --git a/docs/platforms/terminal-backend.md b/docs/platforms/terminal-backend.md index 090b111..72017b5 100644 --- a/docs/platforms/terminal-backend.md +++ b/docs/platforms/terminal-backend.md @@ -2,15 +2,8 @@ | 项目 | Windows | Linux | | --- | --- | --- | -| 伪终端 | ConPTY | POSIX PTY master/slave | -| 进程连接 | 伪控制台通过扩展启动属性关联用户进程 | session、控制终端、标准流和前台进程组 | -| 尺寸变更 | `ResizePseudoConsole` | 窗口尺寸 ioctl 与终端信号行为 | -| 生命周期 | 用户进程、管道和伪控制台句柄 | 子进程、进程组和 PTY 文件描述符 | +| 伪终端 | 目标 OpenSSH 使用 ConPTY | 目标 OpenSSH 使用 POSIX PTY | +| Shell | 可将目标用户默认 Shell 设为 PowerShell 7 | 使用目标账户允许的 Shell | +| 尺寸 | SSH window-change 请求 | SSH window-change 请求 | -共享接口为 `IPtySessionFactory` 与 `IPtySession`。终端输入输出按字节传递;用户代理负责持续读出、缓冲边界及终端状态。平台实现明确句柄所有权、取消和资源释放顺序。 - -Windows 后端在用户代理上下文中创建 shell。Linux 后端使用适合托管运行时的原生启动边界完成进程和终端设置,子进程进入目标程序前完成系统调用配置。 - -验收覆盖交互 shell、UTF-8、方向键、Ctrl+C、全屏编辑器、尺寸变更、大量输出、子进程退出及显式终止。断线后的输出持续被消费,重连恢复见[连接协议](../connection-protocol.md)。 - -参考:[ConPTY 会话](https://learn.microsoft.com/en-us/windows/console/creating-a-pseudoconsole-session)、[Linux PTY API](https://man7.org/linux/man-pages/man3/openpty.3.html)。 +Server 只持有 SSH shell channel,转发输入、输出和尺寸变化,并管理客户端附着。伪终端创建、用户 Shell 启动与本地进程资源由目标 sshd 负责。需通过真实目标验收 PowerShell 7、交互提示、全屏 TUI、颜色、控制键和尺寸变化。Server 已实现内部密码 SSH Broker、PTY Shell、受保护的 WAN 会话入口与有界输出保持;真实目标上的 `ls` 和完整交互仍待验收,见[SSH 会话设计](../ssh-session.md)。 diff --git a/docs/platforms/user-environment.md b/docs/platforms/user-environment.md index 897f743..7d5e6eb 100644 --- a/docs/platforms/user-environment.md +++ b/docs/platforms/user-environment.md @@ -1,20 +1,9 @@ -# 用户执行环境 +# 用户环境 -| 平台 | 环境建立 | 身份标识 | 就绪条件 | -| --- | --- | --- | --- | -| Windows | 复用目标用户登录后的代理上下文 | Windows SID | 指定用户的代理已注册 | -| Linux | 为现有系统账户设置 UID、GID、附加组及用户会话 | UID 与组信息 | 账户解析、权限设置及代理启动完成 | +| 项目 | Windows | Linux | +| --- | --- | --- | +| 账户标识 | SID | UID、GID 与附加组 | +| 登录环境 | OpenSSH 建立目标用户环境,默认 Shell 可配置 | sshd/PAM 建立目标用户环境 | +| 主目录 | 目标账户配置决定 | 目标账户与 PAM 配置决定 | -## Windows - -标准部署使用目标用户登录后启动的 Agent。它继承用户权限和用户配置,终端启动时设置工作目录、环境变量和 shell 参数。专用本地账户可以作为远程工作区账户。 - -Host 根据外部身份映射匹配该用户的 SID。目标用户代理尚未注册时,工作区状态为 `WaitingForUserSession`。锁屏状态下保留用户会话;用户注销后的行为见[生命周期](session-lifecycle.md)。 - -## Linux - -系统账户由操作系统维护。启动组件按授权映射查询账户,初始化附加组、主组、UID、HOME、工作目录和登录环境;部署所需的 PAM account/session 处理归入该组件。用户代理以目标账户身份持有 PTY。 - -## 验收 - -检查终端中的系统身份、主目录、配置目录、环境变量和文件访问结果。Windows 验证登录前的等待状态与登录后的代理注册;Linux 验证无人交互登录时的用户上下文建立。认证归属与系统账户映射见[认证文档](../authentication.md)。 +Server 将经 authentik 认证的身份映射到授权目标与系统账户,再通过 SSH 证书登录。工作区进程由目标 sshd 按系统账户启动;Server 不启动用户 Agent,也不直接切换用户身份。需在目标系统验收环境变量、主目录、工作目录、组权限与登录策略。认证设计见[身份认证](../authentication.md)。 \ No newline at end of file diff --git a/docs/ssh-session.md b/docs/ssh-session.md new file mode 100644 index 0000000..97ad83b --- /dev/null +++ b/docs/ssh-session.md @@ -0,0 +1,120 @@ +# Server 中的 SSH Broker + +## 已实现的路径 + +Server 的 `SshBroker.OpenWithPasswordAsync` 先从 PostgreSQL 解析已启用的身份、目标、账户授权和目标主机公钥,再用 SSH.NET 验证目标主机身份、以密码登录并打开 PTY Shell。地址、端口、主机公钥和允许登录的账户来自数据库,不能由公网请求替换。`SshWorkspaceManager` 按工作区持有连接、控制权和有界输出;Server 停止时关闭连接。目标 `sshd` 负责用户 Shell 和 Windows ConPTY/Linux PTY。 + +密码登录不要求用户 SSH 密钥对,也不要求 SSH CA。`targets.UserCertificateAuthorityId` 与 `grants.CertificatePrincipal` 可为空。主机公钥是目标 sshd 的身份凭据,与用户登录密钥不同。Broker 会精确匹配事先登记的主机公钥;错误或缺失时拒绝连接。密码仅用于本次建连,不写入数据库、配置或日志。OIDC、Session Manager、持续读取输出和 WebSocket 附着已有首版实现;真实 authentik 的登录和令牌验证已单独验收,正式身份到密钥工作区的交互式 `ls` 已在 `.100 → .101` 实测;正常退出及重新附着待复测。证书签发、屏幕快照和自动重连尚未实现。 + +## Server 托管的登录密钥 + +Server 也支持以 SSH 登录私钥创建正式工作区。数据库 `access.ssh_login_keys` 保存名称、私钥文件名和启用状态,`access.grants.SshLoginKeyId` 将一条授权绑定到密钥;私钥本身只在 Server 的受保护目录中。它不是目标的 SSH host key,也不是 SSH CA 签名密钥。Server 每次建连重新检查 OIDC 身份、目标授权、账户映射与已登记的目标主机公钥,然后加载对应私钥,进行 SSH 公钥认证;无需单独登记登录密钥的 `.pub` 文件或在数据库保存其公钥。未绑定密钥的授权保留密码登录路径。 + +先在 Server 使用的 INI 配置中设置绝对路径: + +```ini +[ssh_keys] +directory=/var/lib/workspace-access/keys +``` + +Windows 测试时改为实际的 Windows 绝对目录,例如 `directory=C:\Users\<用户>\AppData\Local\WorkspaceAccess\keys`;不要在 Windows 路径后拼接上面的 Linux 示例路径。私钥应位于支持 NTFS ACL 的目录,避免保存在无法可靠设置所有权和 ACL 的 VirtIO-FS 共享盘。将已有私钥以普通文件名放入该目录,例如 `fedora_101`。Server 无需该登录密钥的 `.pub` 文件。目标 sshd 仍须在对应账户的 `authorized_keys` 中信任与此私钥匹配的公钥;不能用任意新生成的公钥代替。命令中的文件名必须与磁盘上完全一致。Linux 私钥须只允许服务账户访问(例如 `0600`),目录不得允许其他用户写入;Windows 的目录和私钥文件须由服务账户、SYSTEM 或 Administrators 拥有,允许访问的账户也只能是这三者;Server 读取时会强制检查。`database key register` 会读取并验证私钥格式,只将名称、文件名和启用状态保存到数据库。当前只支持服务无需交互即可打开的私钥;带口令私钥尚需独立的解锁凭据渠道。路径只来自 Server 配置与本机管理员登记,不接受 HTTP 客户端提交。更换目录或密钥文件无需迁移数据库,但必须保持已登记的文件名可用;修改 `[ssh_keys]` 配置需重启 Server。 + +完成 `database migrate` 后,以同一配置运行以下本机命令(`` 是授权记录 ID,`broker-probe` 或 `grant-probe` 会打印): + +```text +char_rac_server --config <配置路径> database key register fedora-101 fedora_101 +char_rac_server --config <配置路径> database key assign fedora-101 +char_rac_server --config <配置路径> database key list +``` + +`database key unassign ` 可撤销绑定并恢复该授权的密码建连方式。登记与绑定是持久数据,不会因 Server 停止而删除。注册操作在名称和文件名不变时可重复运行;轮换密钥时先以新名称登记并在目标 sshd 授权新公钥,再重新绑定授权,验证成功后移除旧公钥。正式 Client 对密钥授权不提示 SSH 密码;未绑定密钥时由 Server 返回 `password_required`,Client 才提示输入密码。当前没有自动从文件系统扫描或自动授权目标 sshd 的行为。 + +若测试库已清空,可用 `[ssh_probe]` 的地址、端口、账户和主机公钥,加上 `[oidc]` 的 issuer 登记完整的 `.101` 测试目标。以下命令只在本机执行,其中 `enroll-probe` 和 `key-probe` 只允许名称含 `test` 的数据库: + +```text +char_rac_server --config <配置路径> database enroll-probe fedora-101 fedora-101 +char_rac_server --config <配置路径> database key grants +char_rac_server --config <配置路径> ssh key-probe +``` + +`enroll-probe` 会创建或核对目标、主机公钥、身份、授权和密钥绑定,重复运行不会新增记录;有同名但内容不同的记录时拒绝覆盖。`key-probe` 经数据库授权和 SSH Broker,用登记的私钥打开 PTY 并执行 `ls`。它是本机诊断命令,使用指定的 subject 模拟已验证身份,**不验证 authentik 令牌**;完整认证仍应使用正式 Client `connect` 测试。2026-10-05 已在 `.100 → .101` 用真实密钥验证 `key-probe`,获得远端 `ls` 输出。 + +`access.workspace_records` 持久保存正式创建的工作区 ID、身份键、目标、系统账户、认证方式、创建时间与已观察到的结束时间/原因。它是历史记录,`GET /resources` 的可附着列表仍只来自 Server 进程内的存活 SSH 连接。Server 正常停止时记录 `server_stopped`;崩溃时 `ClosedAt` 可能为空,这不代表原 SSH 终端仍能恢复。现有 `database clear-probes` 是显式管理员操作,只清理专用测试库中的探测记录;Server 停止不执行数据库清理。 + +## 三参数交互式端点登记 + +管理员可以在 Server 主机的本机终端运行以下命令。`<名称>` 只是展示和数据库检索用的标签;`` 是目标主机名或 IP;`<私钥路径>` 是 **Server 本机** 可读文件的路径,不是 Client 路径,也不是目标 sshd 的路径。命令只有三个位置参数: + +```powershell +char_rac_server endpoint_regist vm-102 10.10.0.102 "C:\path\to\id_ed25519" +``` + +开发构建产物在 `temp/WorkspaceAccessServer/bin/Release/net10.0/`;Windows PowerShell 可在该目录运行 `.\char_rac_server.exe`,或将所在目录加入 `PATH` 后使用上面的命令。未指定 `--config` 时会查找仓库根目录、当前目录或程序目录中的 `workspace-access.config`;部署到其他位置时请加 `--config <绝对路径>`。 + +命令随后交互询问端口(默认 22)、可信的 **目标主机公钥** 文件路径,并显示其 SHA-256 指纹。管理员须与目标控制台或其他可信渠道的指纹比较,再粘贴相同指纹确认。不能把首次从待连接网络获取的 `ssh-keyscan` 结果本身视为可信。随后可输入 OIDC subject 和目标 OS 账户,直接为该身份创建授权;subject 留空只登记目标和登录密钥,之后可用 `database grant register <名称> <账户> <名称>` 授权。已登记过相同内容可重复运行;不同地址、公钥或私钥会被拒绝覆盖。 + +Server 校验私钥格式后,将其**复制**到 `[ssh_keys] directory` 的受保护目录,使用与端点名称关联的普通文件名,核对复制品的权限和可加载性;数据库仅保存文件名。源文件不会自动删除,管理员需自行管理其权限和后续清理。目标 sshd 的对应 OS 账户仍须在 `authorized_keys` 中信任这把私钥对应的登录公钥。命令不会上传私钥到目标机器,也不会修改 `authorized_keys`。Server 必须有权读取源文件并写入受保护密钥目录;Linux 导入文件以 `0600` 创建,Windows 继承已核验的受限目录 ACL。若数据库提交失败,已导入的未引用文件可能留在目录中,重试前应核对登记结果。 + +此入口没有内置 `scp`:本机文件导入不需要网络传输,而且从目标机器拉取私钥不符合 Server 持有私钥、目标 sshd 持有对应登录公钥的分工。主机公钥可以通过现有的可信管理通道复制到 Server,但仍须独立核对指纹。此命令只在 Server 本机运行,不暴露为 WAN API。 + +## 本机登记正式 SSH 目标与授权 + +Server 常驻运行不需要 `[ssh_probe]`。管理员可在 Server 主机上使用与服务相同的 `--config` 和数据库,分别登记目标、目标主机公钥与 OIDC 授权;这些命令不通过 WAN API。先从目标机器控制台等可信渠道复制目标 sshd 的 `ssh_host_ed25519_key.pub`,确认指纹后保存为 Server 本机文件。它是**目标主机公钥**,不是 Server 登录私钥的 `.pub` 文件;不要把未经核对的 `ssh-keyscan` 结果直接登记为信任锚。 + +```powershell +dotnet run --project src/client -- login --server http://127.0.0.1:5080 +dotnet run --project src/server -- --config .\workspace-access.config database target register vm-102 10.10.0.102 22 .\trusted\vm-102-ssh_host_ed25519_key.pub +dotnet run --project src/server -- --config .\workspace-access.config database key register vm-102-login vm_102_login +dotnet run --project src/server -- --config .\workspace-access.config database grant register vm-102 <目标OS账户> vm-102-login +dotnet run --project src/server -- --config .\workspace-access.config database target list +dotnet run --project src/server -- --config .\workspace-access.config database grant list +``` + +`database target register` 从公钥文件读取恰好一行 SSH 主机公钥,校验格式,持久保存目标的名称、地址、端口和信任锚,并打印目标 UUID 和指纹。`database grant register` 使用 `[oidc] issuer` 与传入的 subject 创建或复用身份,再为目标绑定 OS 账户及可选的登录密钥名称。省略末尾密钥名称时使用交互式 SSH 密码路径;指定密钥时会检查其已登记、已启用且私钥文件可由当前服务账户读取。目标 sshd 仍须允许该账户使用对应公钥。两条登记命令重复执行相同内容不创建副本,遇到同名但不同内容或被禁用的记录会拒绝覆盖;变更、轮换与撤销应由单独的管理操作处理。登记完成后,运行中的 Server 下次查询数据库即可看到新授权,无需因数据变化重启。 + +`[ssh_probe]` 只供 `ssh probe`、`ssh broker-probe`、`serve --local-probe`、`database enroll-probe` 和 `database grant-probe` 等旧诊断命令使用;正式 `serve`、`connect`、`database target/grant/key` 与 `ssh key-probe` 不依赖它,可以从配置中删除。 +## 本机配置与诊断 + +两个诊断命令支持全局 `--config <路径>`,可直接指向编辑好的 INI 文件,无需复制到输出目录;开发时不指定则读取仓库根目录的 `workspace-access.config`。该文件被 Git 忽略,可参考 `workspace-access.config.example`;发布时可用 `--config` 指向独立的受保护配置,或自行将同名文件放在可执行文件旁。`[database]` 是 Server 正常运行使用的 PostgreSQL 连接参数;`[ssh_probe]` 仅提供本机诊断目标参数,不会替代数据库中的正式目标登记。SSH 密码在运行时隐藏输入,不写入配置。 + +```ini +[database] +host=127.0.0.1 +port=5432 +name=ni_access_test_db +username=ni_access_test +password=<测试库密码> +timeout=5 +maximum_pool_size=100 + +[ssh_probe] +address=10.10.0.101 +port=22 +account=youmuliu +host_public_key="ssh-ed25519 AAAA..." +``` + +先通过 `.101` 控制台或可信管理通道获取完整的 `ssh_host_ed25519_key.pub` 内容,填入 `host_public_key`。复制以 `ssh-ed25519` 开头的一行,配置中可以保留包裹整行的普通 ASCII 双引号;不要用排版用的弯引号。诊断命令会在要求输入 SSH 密码前检查公钥格式。不要只凭待测网络上的 `ssh-keyscan` 输出决定信任。 + +在 `.100` 的仓库目录运行 `dotnet run --no-launch-profile --project src/server -- --config workspace-access.config ssh probe`,可直接验证 SSH 传输、主机公钥、密码认证和 PTY Shell。2026-10-02 已在 `.100` 上用显式参数形式成功探测 `.101`。 + +`ssh probe` 和下述 `ssh broker-probe` 都是执行一次便退出的命令,不会启动常驻服务。要让 CLI 通过 Server 的 HTTP/WebSocket 实测一次 `ls`,使用[回环端到端诊断](client-server-transport.md#cli-探测)的 `serve --local-probe` 与 `probe-session`;它不依赖 OIDC,也不向 PostgreSQL 写入测试记录。 + +运行 `dotnet run --no-launch-profile --project src/server -- --config workspace-access.config ssh broker-probe`,会进一步测试数据库授权解析和真实 Broker。此命令只允许连接库名含 `test` 的专用 PostgreSQL 测试库;它会保存启用的测试身份、目标、主机公钥和授权,打印四条记录的 ID 后再要求输入 SSH 密码。随后通过 `SshWorkspaceManager` 向 Linux Shell 发送 `ls`,读取返回列表并检查退出码为 0;即使 SSH 连接失败,诊断记录也会保留。每次运行新增一组记录,可用 `database list-probes` 查看,用 `database clear-probes` 清理专用测试库中的诊断记录;这两个命令保留 EF 迁移与非诊断数据。测试库须先应用 EF 迁移。成功输出 `Database authorization, host key, password, managed session and Linux ls verified.`。2026-10-02 已在 `.100` 上使用此命令连接 `.101`,读回目录列表并验证 `ls` 退出码为 0。虚拟身份只用于本机测试,不代表 OIDC 已通过,也不对公网开放。两个命令仍支持显式传入地址、端口、账户、公钥的旧形式。 + +要继续验收 OIDC + WebSocket,先配置[authentik 登记](authentication.md),再在一个终端启动 Server。在另一个终端依次运行以下命令;`` 用 `broker-probe` 打印的值,`` 与 `` 用 `whoami` 打印的值。`grant-probe` 是本机管理命令,只能对专用测试库中的诊断目标授权: + +```powershell +dotnet run --project src/server -- --config workspace-access.config +dotnet run --project src/client -- whoami --server http://127.0.0.1:5080 +dotnet run --project src/server -- --config workspace-access.config database grant-probe +dotnet run --project src/client -- run --server http://127.0.0.1:5080 --target +``` + +最后一条命令会重新通过 authentik 登录、交互输入 SSH 密码、创建工作区、通过 WebSocket 发送 `ls` 并显示输出及工作区 ID。使用 `attach --server http://127.0.0.1:5080 --id ` 可再次登录并附着同一工作区,继续发送 `ls`。目录列表应与在 `.101` 上用对应系统账户直接运行 `ls` 的结果对照;CLI 同时检查 Shell 返回的退出码为 0。退出 CLI 后工作区仍在 Server 内,直到后端 Shell 结束、显式 DELETE 或 Server 停止。 + +## 多目标与生命周期 + +开发机当前为 `.100`,已测试 `.100 → .101` 的直接密码探测。最终计划把 Server 部署在 `.103`,由它连接 `.100`—`.103` 四个独立 SSH 目标;部署后需重新验收 `.103` 到各目标的连接。每个目标分别管理地址、端口、主机公钥和授权账户,Server 到其自身 sshd 的连接也遵循相同规则。 + +客户端断线不会立即关闭后端 SSH;运行中的 Session Manager 持续读取并有界保存输出。远端 Shell 退出或 SSH 传输断开时,原交互会话不能仅靠 CA 或数据库恢复。`broker-probe` 测试托管工作区的一次 `ls` 输入和输出,持续交互、接管、撤销及重连仍需单独验收。 diff --git a/docs/testing.md b/docs/testing.md index 284842f..6d553b5 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -1,29 +1,23 @@ # 验证与验收 -## 自动验证 +NUKE Verify 构建解决方案、检查文档并运行工作区连接仲裁测试。配置 WORKSPACE_ACCESS_TEST_DATABASE 后还会在 PostgreSQL 18 专用测试库中执行迁移、唯一键/外键、授权隔离、过期与撤销等集成测试;用例通过 Npgsql 连接池访问数据库并在结束时清理自身测试数据,未配置时明确跳过。配置方式见[数据库验收](database.md)。核心测试覆盖同身份接管、旧 lease 拒绝、跨 issuer 身份边界、断线保留归属、释放及并发获取;新增 Client 回环 WebSocket 集成测试检查正式端点的 Bearer 请求头、仅含工作区 ID 的 query、输出接收与解除附着,以及无令牌和非回环明文端点的拒绝;测试不模拟 authentik 签名校验,也不连接真实 sshd。本机 `ssh probe` 与 `ssh broker-probe` 的手动操作见[SSH Broker](ssh-session.md)。已在开发机 `.100` 完成到 `.101` 的直接密码探测及 Session Manager `ls` 探测;demo 已通过真实 authentik public client 的 PKCE 令牌交换;正式 Client `login`/`connect` 的 Server 验签、授权与 SSH 交互整链路,以及未来 `.103` 部署路径待验收。CI 在 Windows 与 Linux x64 上运行 Verify 和 Publish,并在 Linux runner 上交叉发布 Android ARM64/Termux Client;设备运行仍需实机验收。另有 PostgreSQL 18 容器任务启用数据库集成测试。没有 `[oidc]` 配置时工作区路径返回 503;配置占位 OIDC 后,未认证的创建与附着请求在本机返回 401。完整命令见[单域名入口](client-server-transport.md)。 -NUKE Verify 执行文档检查、解决方案构建和核心测试。测试包括同身份显式接管、旧 lease 输入与断开拒绝、跨 issuer 身份边界、断线后归属保留、工作区释放以及并发连接唯一获权。 - -CI 在 Windows 与 Linux x64 上各自执行 Verify 和 Publish,保存发布产物与 TRX。发布成功作为目标 RID 构建验证,运行能力通过以下环境验收确认。 - -## 集成验收矩阵 +SSH 接入后,需要在真实目标系统验收: | 领域 | Windows | Linux | | --- | --- | --- | -| 服务 | SCM 启动、停止、服务账户配置 | systemd 启动、停止、权限与日志 | -| 用户身份 | 登录代理 SID、主目录、锁屏与注销 | UID/GID/附加组、HOME、无人登录时启动 | -| 终端 | ConPTY、PowerShell、全屏 TUI、缩放 | PTY、shell、控制终端、信号与缩放 | -| 进程清理 | 子进程树和句柄释放 | 进程组/cgroup 与文件描述符释放 | -| 文件 | 实际 VirtioFS 驱动与 WinFsp 配置 | 实际挂载配置与宿主 UID/GID | +| 服务 | Server SCM 生命周期和 sshd 配置 | Server systemd 生命周期和 sshd 配置 | +| 身份 | SSH 证书映射到允许的系统账户 | SSH principal 与 UID/GID 映射 | +| 终端 | OpenSSH/ConPTY、PowerShell 7、TUI、尺寸变化 | OpenSSH/POSIX PTY、shell、TUI、尺寸变化 | +| 会话 | WebSocket 断线期间继续输出、重连、接管与清理 | 同左 | +| 文件 | 实际 VirtioFS 驱动和权限 | 实际挂载、UID/GID 与权限 | -## 跨平台场景 +Android 客户端需在真实 Termux 环境检查 .NET 运行时、入口启动、OIDC 回调、键盘与终端原始模式、尺寸变化及网络恢复,见[Android CLI](platforms/android-client.md)。 -1. 两个客户端同时连接,确认 host 只有一个有效控制者。 -2. 同身份显式接管,旧客户端继续发送输入,确认过期输入被拒绝。 -3. 断线后另一身份尝试进入,确认工作区归属保持。 -4. 断线期间进程继续产生输出,重连后恢复终端状态。 -5. 认证过期或授权撤销后,确认传输关闭和控制权撤销。 -6. 全屏编辑器重连,确认光标、颜色、备用屏幕和尺寸一致。 -7. 显式终止清理完整进程树后,确认新的身份可以创建工作区。 +还需检查目标主机密钥错误、证书过期、权限撤销、Server 重启和后端 SSH 中断时的行为。每次验收记录系统版本、配置、步骤和实际结果;存储细节见[VirtioFS 验收](platforms/storage.md)。 +� -存储场景细化见[VirtioFS 验收](platforms/storage.md)。每次环境验收记录系统版本、运行用户、依赖版本、配置、执行步骤与实际结果。 + +资源发现测试覆盖匿名请求在查询数据库前被拒绝、跨 subject/issuer 的会话隔离、已撤销目标与账户映射变更过滤、响应字段不含后端凭据,以及 CLI 返回旧会话/新建目标的选择、占用状态、空列表、刷新、退出和终端控制字符过滤。PostgreSQL 集成测试还检查目标列表仅执行一次查询,并在启用状态、有效期或授权变更后立即反映结果;未配置测试库时显式跳过。真实浏览器登录到菜单、选择 .101 交互及解除附着后重新选择的流程需要实机验收。 + +本机管理通道测试覆盖 `status`、`sessions`、日志 `reload`、过长指令拒绝、未启用时拒绝 `disconnect`、显式启用后的 subject 校验,以及断开控制者后旧输入租约失效且工作区归属保留。设备仲裁测试覆盖同一安装多连接、另一安装被拒绝、断开后的换机、旧租约不能释放新设备占用,以及客户端凭据的并发首次创建。来源地址测试验证只有 `[server] trusted_proxy` 匹配直接对端时才接纳一跳 `X-Forwarded-For`;真实 Caddy 部署、异机 Client 和远端断线仍需实机验收。 diff --git a/global.json b/global.json index e1c5e99..6a28850 100644 --- a/global.json +++ b/global.json @@ -1,7 +1,7 @@ { "sdk": { - "version": "10.0.401", - "rollForward": "latestPatch", + "version": "10.0.100", + "rollForward": "latestMajor", "allowPrerelease": false } } diff --git a/packaging/linux/charac-server.service b/packaging/linux/charac-server.service new file mode 100644 index 0000000..52a203f --- /dev/null +++ b/packaging/linux/charac-server.service @@ -0,0 +1,25 @@ +[Unit] +Description=Charac Server +After=network-online.target +Wants=network-online.target + +[Service] +Type=notify +User=charac +Group=charac +WorkingDirectory=/opt/charac/server +ExecStart=/opt/charac/server/char_rac_server --config /etc/charac/workspace-access.config +Restart=on-failure +RestartSec=5 +TimeoutStopSec=30 +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +RuntimeDirectory=charac +RuntimeDirectoryMode=0700 +StateDirectory=charac +StateDirectoryMode=0700 +UMask=0077 + +[Install] +WantedBy=multi-user.target diff --git a/packaging/linux/charac.sysusers b/packaging/linux/charac.sysusers new file mode 100644 index 0000000..1f51733 --- /dev/null +++ b/packaging/linux/charac.sysusers @@ -0,0 +1 @@ +u charac - "Charac service" /var/lib/charac /usr/sbin/nologin diff --git a/packaging/linux/server-postinstall.sh b/packaging/linux/server-postinstall.sh new file mode 100644 index 0000000..14961b5 --- /dev/null +++ b/packaging/linux/server-postinstall.sh @@ -0,0 +1,4 @@ +#!/bin/sh +set -eu +systemd-sysusers /usr/lib/sysusers.d/charac.conf +systemctl daemon-reload diff --git a/packaging/linux/server-postremove.sh b/packaging/linux/server-postremove.sh new file mode 100644 index 0000000..3a2a0af --- /dev/null +++ b/packaging/linux/server-postremove.sh @@ -0,0 +1,3 @@ +#!/bin/sh +set -eu +systemctl daemon-reload diff --git a/packaging/package.ps1 b/packaging/package.ps1 new file mode 100644 index 0000000..9f2786e --- /dev/null +++ b/packaging/package.ps1 @@ -0,0 +1,143 @@ +param( + [Parameter(Mandatory = $true)] + [ValidateSet('ServerRpm', 'ClientRpm', 'ClientMsi', 'ClientApkDebug', 'ClientTermuxDeb')] + [string]$Target, + [string]$NfpmPath, + [string]$WixPath +) + +$ErrorActionPreference = 'Stop' +$repository = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +$version = ([xml](Get-Content -LiteralPath (Join-Path $repository 'Directory.Build.props') -Raw)).Project.PropertyGroup.Version +$packages = Join-Path $repository 'artifacts/packages' +$scratch = Join-Path $repository 'temp/packaging' +New-Item -ItemType Directory -Path $packages, $scratch -Force | Out-Null + +function Assert-Payload([string]$path, [string]$executable) { + if (-not (Test-Path -LiteralPath (Join-Path $path $executable) -PathType Leaf)) { + throw "Publish the matching runtime first: $path" + } + $unexpected = Get-ChildItem -LiteralPath $path -Recurse -File | Where-Object { + $_.Name -eq 'workspace-access.config' -or $_.Extension -in @('.key', '.pem', '.pfx') -or + $_.Name -like '*Tests*' + } + if ($unexpected) { throw 'Publish payload contains a private configuration, key, or test assembly.' } +} + +function Add-PackageFile([System.Collections.Generic.List[string]]$lines, + [string]$source, [string]$destination, [string]$mode = '0644') { + $normalized = $source.Replace('\', '/') + $lines.Add(' - src: ' + (ConvertTo-Json -InputObject $normalized -Compress)) + $lines.Add(' dst: ' + (ConvertTo-Json -InputObject $destination -Compress)) + $lines.Add(' file_info:') + $lines.Add(" mode: $mode") +} + +if ($Target -eq 'ClientApkDebug') { + $source = Join-Path $repository 'temp/CharacAndroid/bin/Debug/net10.0-android/com.nitload.charac-Signed.apk' + if (-not (Test-Path -LiteralPath $source -PathType Leaf)) { + throw 'Build src/android/CharacAndroid.csproj in Debug first.' + } + $output = Join-Path $packages "charac-client_${version}_android-debug.apk" + [IO.File]::Copy($source, $output, $true) + Write-Output $output + exit 0 +} + +if ($Target -eq 'ClientMsi') { + $payload = Join-Path $repository 'artifacts/publish/win-x64/client' + Assert-Payload $payload 'charac.exe' + if (-not $WixPath) { $WixPath = Join-Path $repository 'temp/tools/wix.exe' } + if (-not (Test-Path -LiteralPath $WixPath -PathType Leaf)) { throw 'WiX CLI 6.0.2 is required; pass -WixPath.' } + $output = Join-Path $packages "charac-client_${version}_win-x64.msi" + & $WixPath build '-arch' 'x64' '-d' "PackageVersion=$version" '-d' "PayloadDir=$payload" '-d' "ConfigTemplatePath=$(Join-Path $repository 'client.config.example')" '-pdbtype' 'none' '-o' $output (Join-Path $PSScriptRoot 'windows/Client.wxs') + if ($LASTEXITCODE -ne 0) { throw "WiX failed with exit code $LASTEXITCODE." } + Write-Output $output + exit 0 +} + +if ($Target -eq 'ClientTermuxDeb') { + $payload = Join-Path $repository 'artifacts/publish/linux-bionic-arm64/client' + Assert-Payload $payload 'charac.dll' + if (-not $NfpmPath) { $NfpmPath = Join-Path $repository 'temp/tools/nfpm/nfpm.exe' } + if (-not (Test-Path -LiteralPath $NfpmPath -PathType Leaf)) { throw 'nFPM 2.47.0 is required; pass -NfpmPath.' } + $prefix = '/data/data/com.termux/files/usr' + $lines = [System.Collections.Generic.List[string]]::new() + $lines.Add('name: charac') + $lines.Add("version: $version") + $lines.Add('release: 1') + $lines.Add('arch: aarch64') + $lines.Add('platform: linux') + $lines.Add('maintainer: nitload') + $lines.Add('vendor: nitload') + $lines.Add('license: Proprietary') + $lines.Add('description: Charac CLI for Termux') + $lines.Add('depends:') + $lines.Add(' - dotnet-runtime-10.0') + $lines.Add('contents:') + foreach ($file in (Get-ChildItem -LiteralPath $payload -Recurse -File | Sort-Object FullName)) { + $relative = [IO.Path]::GetRelativePath($payload, $file.FullName).Replace('\', '/') + if ($file.Extension -eq '.pdb' -or $relative.StartsWith('WorkspaceAccessClient', [StringComparison]::OrdinalIgnoreCase)) { continue } + $mode = if ($relative -eq 'charac') { '0755' } else { '0644' } + Add-PackageFile $lines $file.FullName "$prefix/opt/charac/$relative" $mode + } + Add-PackageFile $lines (Join-Path $PSScriptRoot 'termux/charac') "$prefix/bin/charac" '0755' + $config = Join-Path $scratch 'charac-termux.yaml' + [IO.File]::WriteAllLines($config, [string[]]$lines) + $output = Join-Path $packages "charac_${version}-1_aarch64.deb" + & $NfpmPath package --config $config --packager deb --target $output + if ($LASTEXITCODE -ne 0) { throw "nFPM failed with exit code $LASTEXITCODE." } + Write-Output $output + exit 0 +} + +$kind = if ($Target -eq 'ServerRpm') { 'server' } else { 'client' } +$executable = if ($kind -eq 'server') { 'char_rac_server' } else { 'charac' } +$payload = Join-Path $repository "artifacts/publish/linux-x64/$kind" +Assert-Payload $payload $executable +if (-not $NfpmPath) { $NfpmPath = Join-Path $repository 'temp/tools/nfpm/nfpm.exe' } +if (-not (Test-Path -LiteralPath $NfpmPath -PathType Leaf)) { throw 'nFPM 2.47.0 is required; pass -NfpmPath.' } + +$name = "charac-$kind" +$lines = [System.Collections.Generic.List[string]]::new() +$lines.Add("name: $name") +$lines.Add("version: $version") +$lines.Add('release: 1') +$lines.Add('arch: amd64') +$lines.Add('platform: linux') +$lines.Add('maintainer: nitload') +$lines.Add('vendor: nitload') +$lines.Add('license: Proprietary') +$lines.Add("description: Charac $kind") +if ($kind -eq 'server') { + $lines.Add('depends:') + $lines.Add(' - systemd') + $lines.Add('scripts:') + $lines.Add(' postinstall: ' + (ConvertTo-Json -InputObject (Join-Path $PSScriptRoot 'linux/server-postinstall.sh') -Compress)) + $lines.Add(' postremove: ' + (ConvertTo-Json -InputObject (Join-Path $PSScriptRoot 'linux/server-postremove.sh') -Compress)) +} +$lines.Add('contents:') +$files = Get-ChildItem -LiteralPath $payload -Recurse -File | Sort-Object FullName +foreach ($file in $files) { + $relative = [IO.Path]::GetRelativePath($payload, $file.FullName).Replace('\', '/') + if ($file.Extension -eq '.pdb' -or $relative.StartsWith('deploy/', [StringComparison]::OrdinalIgnoreCase)) { continue } + $mode = if ($relative -eq $executable) { '0755' } else { '0644' } + Add-PackageFile $lines $file.FullName "/opt/charac/$kind/$relative" $mode +} +if ($kind -eq 'server') { + Add-PackageFile $lines (Join-Path $PSScriptRoot 'linux/charac-server.service') '/usr/lib/systemd/system/charac-server.service' + Add-PackageFile $lines (Join-Path $PSScriptRoot 'linux/charac.sysusers') '/usr/lib/sysusers.d/charac.conf' + Add-PackageFile $lines (Join-Path $repository 'workspace-access.config.example') '/usr/share/doc/charac-server/workspace-access.config.example' +} else { + Add-PackageFile $lines (Join-Path $repository 'client.config.example') '/usr/share/doc/charac-client/client.config.example' + # The self-contained Client is already named charac; link it into PATH. + $lines.Add(' - src: /opt/charac/client/charac') + $lines.Add(' dst: /usr/bin/charac') + $lines.Add(' type: symlink') +} +$config = Join-Path $scratch "$name.yaml" +[IO.File]::WriteAllLines($config, [string[]]$lines) +$output = Join-Path $packages "${name}-${version}-1.x86_64.rpm" +& $NfpmPath package --config $config --packager rpm --target $output +if ($LASTEXITCODE -ne 0) { throw "nFPM failed with exit code $LASTEXITCODE." } +Write-Output $output diff --git a/packaging/termux/charac b/packaging/termux/charac new file mode 100644 index 0000000..a3ffd68 --- /dev/null +++ b/packaging/termux/charac @@ -0,0 +1,2 @@ +#!/data/data/com.termux/files/usr/bin/sh +exec /data/data/com.termux/files/usr/bin/dotnet /data/data/com.termux/files/usr/opt/charac/charac.dll "$@" diff --git a/packaging/windows/Client.wxs b/packaging/windows/Client.wxs new file mode 100644 index 0000000..f7e3fc9 --- /dev/null +++ b/packaging/windows/Client.wxs @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Agent/AgentApplication.cs b/src/Agent/AgentApplication.cs deleted file mode 100644 index 5c34c0b..0000000 --- a/src/Agent/AgentApplication.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace WorkspaceAccessHost.Agent; - -internal static class AgentApplication -{ - public static async Task RunAsync(string[] args) - { - var builder = Host.CreateApplicationBuilder(new HostApplicationBuilderSettings - { - Args = args, - ContentRootPath = AppContext.BaseDirectory - }); - builder.Services.AddHostedService(); - await builder.Build().RunAsync(); - } -} diff --git a/src/Agent/AgentWorker.cs b/src/Agent/AgentWorker.cs deleted file mode 100644 index 28775b8..0000000 --- a/src/Agent/AgentWorker.cs +++ /dev/null @@ -1,20 +0,0 @@ -namespace WorkspaceAccessHost.Agent; - -internal sealed class AgentWorker(ILogger logger) : BackgroundService -{ - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - logger.LogInformation( - "User agent scaffold running as {User}; IPC registration and PTY backend are pending integration.", - Environment.UserName); - - try - { - await Task.Delay(Timeout.InfiniteTimeSpan, stoppingToken); - } - catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) - { - // Normal host shutdown. - } - } -} diff --git a/src/Core/PlatformProfile.cs b/src/Core/PlatformProfile.cs deleted file mode 100644 index db06766..0000000 --- a/src/Core/PlatformProfile.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace WorkspaceAccessHost.Core; - -public enum UserEnvironmentMode -{ - ExistingLogonSession, - SystemAccountSession -} - -/// Platform design metadata; executable backend readiness is reported separately. -public sealed record PlatformProfile( - string OperatingSystem, - string ServiceManager, - string TerminalBackend, - UserEnvironmentMode UserEnvironmentMode); - -public interface IPlatformProfileProvider -{ - PlatformProfile Profile { get; } -} diff --git a/src/Hosting/HostApplication.cs b/src/Hosting/HostApplication.cs deleted file mode 100644 index 29a6310..0000000 --- a/src/Hosting/HostApplication.cs +++ /dev/null @@ -1,48 +0,0 @@ -using WorkspaceAccessHost.Connections; -using WorkspaceAccessHost.Core; -using WorkspaceAccessHost.Platforms.Linux; -using WorkspaceAccessHost.Platforms.Windows; - -namespace WorkspaceAccessHost.Hosting; - -internal static class HostApplication -{ - public static async Task RunAsync(string[] args) - { - var builder = WebApplication.CreateBuilder(new WebApplicationOptions - { - Args = args, - ContentRootPath = AppContext.BaseDirectory - }); - - IPlatformProfileProvider platform; - if (OperatingSystem.IsWindows()) - { - builder.Services.AddWindowsService(options => options.ServiceName = "WorkspaceAccessHost"); - platform = new WindowsPlatformProfile(); - } - else if (OperatingSystem.IsLinux()) - { - builder.Services.AddSystemd(); - platform = new LinuxPlatformProfile(); - } - else - { - throw new PlatformNotSupportedException("WorkspaceAccessHost supports Windows and Linux."); - } - - builder.Services.AddSingleton(platform); - builder.Services.AddSingleton(); - - var app = builder.Build(); - app.MapGet("/health/live", () => Results.Ok(new { status = "alive" })); - app.MapGet("/health/ready", () => Results.Json( - new { status = "initializing", stage = "scaffold", pending = new[] { "oidc", "agent-ipc", "pty", "websocket" } }, - statusCode: StatusCodes.Status503ServiceUnavailable)); - - app.Logger.LogInformation( - "Host scaffold started for {OperatingSystem}; remote workspace readiness is pending backend integration.", - platform.Profile.OperatingSystem); - await app.RunAsync(); - } -} diff --git a/src/Platforms/Linux/LinuxPlatformProfile.cs b/src/Platforms/Linux/LinuxPlatformProfile.cs deleted file mode 100644 index 015d797..0000000 --- a/src/Platforms/Linux/LinuxPlatformProfile.cs +++ /dev/null @@ -1,9 +0,0 @@ -using WorkspaceAccessHost.Core; - -namespace WorkspaceAccessHost.Platforms.Linux; - -public sealed class LinuxPlatformProfile : IPlatformProfileProvider -{ - public PlatformProfile Profile { get; } = new( - "linux", "systemd", "POSIX PTY", UserEnvironmentMode.SystemAccountSession); -} diff --git a/src/Platforms/Windows/WindowsPlatformProfile.cs b/src/Platforms/Windows/WindowsPlatformProfile.cs deleted file mode 100644 index 0a6625b..0000000 --- a/src/Platforms/Windows/WindowsPlatformProfile.cs +++ /dev/null @@ -1,9 +0,0 @@ -using WorkspaceAccessHost.Core; - -namespace WorkspaceAccessHost.Platforms.Windows; - -public sealed class WindowsPlatformProfile : IPlatformProfileProvider -{ - public PlatformProfile Profile { get; } = new( - "windows", "Windows Service", "ConPTY", UserEnvironmentMode.ExistingLogonSession); -} diff --git a/src/Program.cs b/src/Program.cs deleted file mode 100644 index 75c9a47..0000000 --- a/src/Program.cs +++ /dev/null @@ -1,34 +0,0 @@ -using WorkspaceAccessHost.Agent; -using WorkspaceAccessHost.Hosting; - -const string usage = "Usage: WorkspaceAccessHost [options]"; - -if (args.Length == 0 || args[0] is "--help" or "-h") -{ - Console.WriteLine(usage); - return 0; -} - -if (args[0] is not ("host" or "agent")) -{ - Console.Error.WriteLine(usage); - return 2; -} - -if (!OperatingSystem.IsWindows() && !OperatingSystem.IsLinux()) -{ - Console.Error.WriteLine("WorkspaceAccessHost supports Windows and Linux."); - return 1; -} - -var roleArguments = args[1..]; -if (args[0] == "host") -{ - await HostApplication.RunAsync(roleArguments); -} -else -{ - await AgentApplication.RunAsync(roleArguments); -} - -return 0; diff --git a/src/Terminals/IPtySession.cs b/src/Terminals/IPtySession.cs deleted file mode 100644 index 8f70f0b..0000000 --- a/src/Terminals/IPtySession.cs +++ /dev/null @@ -1,41 +0,0 @@ -namespace WorkspaceAccessHost.Terminals; - -public sealed record TerminalSize -{ - public TerminalSize(int columns, int rows) - { - ArgumentOutOfRangeException.ThrowIfLessThan(columns, 1); - ArgumentOutOfRangeException.ThrowIfGreaterThan(columns, short.MaxValue); - ArgumentOutOfRangeException.ThrowIfLessThan(rows, 1); - ArgumentOutOfRangeException.ThrowIfGreaterThan(rows, short.MaxValue); - Columns = columns; - Rows = rows; - } - - public int Columns { get; } - public int Rows { get; } -} - -/// Owned by a user agent for the lifetime of a terminal, independently of its transport. -public interface IPtySession : IAsyncDisposable -{ - Guid SessionId { get; } - Stream Output { get; } - Task ExitCode { get; } - ValueTask WriteAsync(ReadOnlyMemory input, CancellationToken cancellationToken); - ValueTask ResizeAsync(TerminalSize size, CancellationToken cancellationToken); - ValueTask TerminateAsync(CancellationToken cancellationToken); -} - -/// Invoked inside an already established system user context. -public interface IPtySessionFactory -{ - ValueTask StartAsync( - TerminalStartInfo startInfo, CancellationToken cancellationToken); -} - -public sealed record TerminalStartInfo( - string Executable, - IReadOnlyList Arguments, - string WorkingDirectory, - TerminalSize Size); diff --git a/src/WorkspaceAccessHost.csproj b/src/WorkspaceAccessHost.csproj deleted file mode 100644 index 355f68c..0000000 --- a/src/WorkspaceAccessHost.csproj +++ /dev/null @@ -1,9 +0,0 @@ - - - win-x64;win-arm64;linux-x64;linux-arm64 - - - - - - diff --git a/src/android/AndroidManifest.xml b/src/android/AndroidManifest.xml new file mode 100644 index 0000000..61ebbba --- /dev/null +++ b/src/android/AndroidManifest.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/android/CharacAndroid.csproj b/src/android/CharacAndroid.csproj new file mode 100644 index 0000000..3a9b500 --- /dev/null +++ b/src/android/CharacAndroid.csproj @@ -0,0 +1,18 @@ + + + net10.0-android + 31 + Exe + enable + enable + com.nitload.charac + Charac.Android + 1 + 0.1.0 + + full + + diff --git a/src/android/MainActivity.cs b/src/android/MainActivity.cs new file mode 100644 index 0000000..0f08cd6 --- /dev/null +++ b/src/android/MainActivity.cs @@ -0,0 +1,165 @@ +using Android; +using Android.App; +using Android.Content; +using Android.Content.PM; +using Android.OS; +using Android.Provider; +using Android.Widget; + +namespace Charac.Android; + +[Activity(Label = "@string/app_name", MainLauncher = true, Exported = true, LaunchMode = LaunchMode.SingleTop)] +public sealed class MainActivity : Activity +{ + private const string TermuxPackage = "com.termux"; + private const string TermuxHome = "/data/data/com.termux/files/home"; + private const string TermuxBin = "/data/data/com.termux/files/usr/bin"; + private const string ResultAction = "com.nitload.charac.TERMUX_CHECK_RESULT"; + private TextView? _status; + private bool _cliReady; + + protected override void OnCreate(Bundle? savedInstanceState) + { + base.OnCreate(savedInstanceState); + SetContentView(Resource.Layout.activity_main); + _status = FindViewById(Resource.Id.status); + FindViewById(Resource.Id.check)!.Click += (_, _) => CheckEnvironment(); + FindViewById(Resource.Id.settings)!.Click += (_, _) => OpenSettings(); + FindViewById(Resource.Id.launch)!.Click += (_, _) => Launch(); + ShowPrerequisites(); + if (Intent is not null) + HandleCheckResult(Intent); + } + + protected override void OnResume() + { + base.OnResume(); + ShowPrerequisites(); + } + + protected override void OnNewIntent(Intent? intent) + { + base.OnNewIntent(intent); + if (intent is not null) + HandleCheckResult(intent); + } + + private bool HasTermux() + { + try + { + _ = PackageManager!.GetPackageInfo(TermuxPackage, PackageInfoFlags.Services); + return true; + } + catch (PackageManager.NameNotFoundException) + { + return false; + } + } + + private bool HasRunPermission() => + CheckSelfPermission("com.termux.permission.RUN_COMMAND") == Permission.Granted; + + private void ShowPrerequisites() + { + if (!HasTermux()) + SetStatus("未安装 Termux。先从可信来源安装 Termux,再返回此页检查。", false); + else if (!HasRunPermission()) + SetStatus("Termux 已安装。请在本应用的系统权限设置中授予“在 Termux 中运行命令”,并在 Termux 的 ~/.termux/termux.properties 中设置 allow-external-apps=true。", false); + else if (!_cliReady) + SetStatus("Termux 和运行权限已就绪。请在 Termux 设置 allow-external-apps=true,并安装 .NET 10 与 charac CLI,然后点“检查 Termux 环境”。", false); + } + + private void CheckEnvironment() + { + if (!HasTermux() || !HasRunPermission()) + { + ShowPrerequisites(); + return; + } + + var callback = new Intent(this, typeof(MainActivity)); + callback.SetAction(ResultAction); + var nonce = Guid.NewGuid().ToString("N"); + GetPreferences(FileCreationMode.Private)!.Edit()!.PutString("pending_nonce", nonce)!.Apply(); + callback.PutExtra("check_nonce", nonce); + var flags = PendingIntentFlags.OneShot | PendingIntentFlags.UpdateCurrent | PendingIntentFlags.Mutable; + var pending = PendingIntent.GetActivity(this, 1, callback, flags); + var command = CommandIntent(TermuxBin + "/sh", ["-c", + "test -x /data/data/com.termux/files/usr/bin/charac && command -v dotnet >/dev/null 2>&1"], true); + command.PutExtra("com.termux.RUN_COMMAND_PENDING_INTENT", pending); + if (StartTermux(command)) + SetStatus("正在由 Termux 检查 charac 和 .NET;若无结果,请检查 allow-external-apps=true。", false); + } + + private void HandleCheckResult(Intent intent) + { + if (intent.Action != ResultAction) + return; + var preferences = GetPreferences(FileCreationMode.Private)!; + var expectedNonce = preferences.GetString("pending_nonce", null); + if (expectedNonce is null || !string.Equals(expectedNonce, + intent.GetStringExtra("check_nonce"), StringComparison.Ordinal)) + return; + preferences.Edit()!.Remove("pending_nonce")!.Apply(); + var result = intent.GetBundleExtra("result"); + _cliReady = result is not null && result.GetInt("err", 0) == -1 && + result.GetInt("exitCode", -1) == 0; + var detail = result?.GetString("errmsg"); + SetStatus(_cliReady + ? "环境检查通过。可以启动 charac connect。" + : "Termux 检查未通过。确认 charac 位于 $PREFIX/bin/charac、.NET 10 已安装,且 allow-external-apps=true。" + + (string.IsNullOrWhiteSpace(detail) ? "" : "\n" + detail), _cliReady); + intent.SetAction(null); + } + + private void Launch() + { + if (!_cliReady) + { + CheckEnvironment(); + return; + } + var command = CommandIntent(TermuxBin + "/charac", ["connect"], false); + if (StartTermux(command)) + SetStatus("已请求 Termux 启动 charac connect。若终端未出现,请查看 Termux 通知及“悬浮窗”权限。", true); + } + + private static Intent CommandIntent(string path, string[] arguments, bool background) + { + var command = new Intent("com.termux.RUN_COMMAND"); + command.SetClassName(TermuxPackage, "com.termux.app.RunCommandService"); + command.PutExtra("com.termux.RUN_COMMAND_PATH", path); + command.PutExtra("com.termux.RUN_COMMAND_ARGUMENTS", arguments); + command.PutExtra("com.termux.RUN_COMMAND_WORKDIR", TermuxHome); + command.PutExtra("com.termux.RUN_COMMAND_BACKGROUND", background); + return command; + } + + private bool StartTermux(Intent intent) + { + try + { + StartService(intent); + return true; + } + catch (Exception exception) + { + SetStatus("无法调用 Termux:" + exception.Message + "。检查运行命令权限和 allow-external-apps 设置。", false); + return false; + } + } + + private void OpenSettings() + { + var settings = new Intent(Settings.ActionApplicationDetailsSettings); + settings.SetData(global::Android.Net.Uri.Parse("package:" + PackageName)); + StartActivity(settings); + } + + private void SetStatus(string message, bool ready) + { + _status!.Text = message; + FindViewById(Resource.Id.launch)!.Enabled = ready; + } +} diff --git a/src/android/Resources/layout/activity_main.xml b/src/android/Resources/layout/activity_main.xml new file mode 100644 index 0000000..aa20710 --- /dev/null +++ b/src/android/Resources/layout/activity_main.xml @@ -0,0 +1,39 @@ + + + + + + + + + + diff --git a/src/android/Resources/mipmap-anydpi-v26/appicon.xml b/src/android/Resources/mipmap-anydpi-v26/appicon.xml new file mode 100644 index 0000000..7751f69 --- /dev/null +++ b/src/android/Resources/mipmap-anydpi-v26/appicon.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/src/android/Resources/mipmap-anydpi-v26/appicon_round.xml b/src/android/Resources/mipmap-anydpi-v26/appicon_round.xml new file mode 100644 index 0000000..7751f69 --- /dev/null +++ b/src/android/Resources/mipmap-anydpi-v26/appicon_round.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/src/android/Resources/mipmap-hdpi/appicon.png b/src/android/Resources/mipmap-hdpi/appicon.png new file mode 100644 index 0000000..0abfc1b Binary files /dev/null and b/src/android/Resources/mipmap-hdpi/appicon.png differ diff --git a/src/android/Resources/mipmap-hdpi/appicon_background.png b/src/android/Resources/mipmap-hdpi/appicon_background.png new file mode 100644 index 0000000..513e69d Binary files /dev/null and b/src/android/Resources/mipmap-hdpi/appicon_background.png differ diff --git a/src/android/Resources/mipmap-hdpi/appicon_foreground.png b/src/android/Resources/mipmap-hdpi/appicon_foreground.png new file mode 100644 index 0000000..99d3a29 Binary files /dev/null and b/src/android/Resources/mipmap-hdpi/appicon_foreground.png differ diff --git a/src/android/Resources/mipmap-mdpi/appicon.png b/src/android/Resources/mipmap-mdpi/appicon.png new file mode 100644 index 0000000..7b5a2e2 Binary files /dev/null and b/src/android/Resources/mipmap-mdpi/appicon.png differ diff --git a/src/android/Resources/mipmap-mdpi/appicon_background.png b/src/android/Resources/mipmap-mdpi/appicon_background.png new file mode 100644 index 0000000..9e2d1e4 Binary files /dev/null and b/src/android/Resources/mipmap-mdpi/appicon_background.png differ diff --git a/src/android/Resources/mipmap-mdpi/appicon_foreground.png b/src/android/Resources/mipmap-mdpi/appicon_foreground.png new file mode 100644 index 0000000..a28d342 Binary files /dev/null and b/src/android/Resources/mipmap-mdpi/appicon_foreground.png differ diff --git a/src/android/Resources/mipmap-xhdpi/appicon.png b/src/android/Resources/mipmap-xhdpi/appicon.png new file mode 100644 index 0000000..b28b73c Binary files /dev/null and b/src/android/Resources/mipmap-xhdpi/appicon.png differ diff --git a/src/android/Resources/mipmap-xhdpi/appicon_background.png b/src/android/Resources/mipmap-xhdpi/appicon_background.png new file mode 100644 index 0000000..658be3f Binary files /dev/null and b/src/android/Resources/mipmap-xhdpi/appicon_background.png differ diff --git a/src/android/Resources/mipmap-xhdpi/appicon_foreground.png b/src/android/Resources/mipmap-xhdpi/appicon_foreground.png new file mode 100644 index 0000000..70a542a Binary files /dev/null and b/src/android/Resources/mipmap-xhdpi/appicon_foreground.png differ diff --git a/src/android/Resources/mipmap-xxhdpi/appicon.png b/src/android/Resources/mipmap-xxhdpi/appicon.png new file mode 100644 index 0000000..f9af117 Binary files /dev/null and b/src/android/Resources/mipmap-xxhdpi/appicon.png differ diff --git a/src/android/Resources/mipmap-xxhdpi/appicon_background.png b/src/android/Resources/mipmap-xxhdpi/appicon_background.png new file mode 100644 index 0000000..9171c3e Binary files /dev/null and b/src/android/Resources/mipmap-xxhdpi/appicon_background.png differ diff --git a/src/android/Resources/mipmap-xxhdpi/appicon_foreground.png b/src/android/Resources/mipmap-xxhdpi/appicon_foreground.png new file mode 100644 index 0000000..cb63bfb Binary files /dev/null and b/src/android/Resources/mipmap-xxhdpi/appicon_foreground.png differ diff --git a/src/android/Resources/mipmap-xxxhdpi/appicon.png b/src/android/Resources/mipmap-xxxhdpi/appicon.png new file mode 100644 index 0000000..1d948d6 Binary files /dev/null and b/src/android/Resources/mipmap-xxxhdpi/appicon.png differ diff --git a/src/android/Resources/mipmap-xxxhdpi/appicon_background.png b/src/android/Resources/mipmap-xxxhdpi/appicon_background.png new file mode 100644 index 0000000..1232d8c Binary files /dev/null and b/src/android/Resources/mipmap-xxxhdpi/appicon_background.png differ diff --git a/src/android/Resources/mipmap-xxxhdpi/appicon_foreground.png b/src/android/Resources/mipmap-xxxhdpi/appicon_foreground.png new file mode 100644 index 0000000..9f9c9e6 Binary files /dev/null and b/src/android/Resources/mipmap-xxxhdpi/appicon_foreground.png differ diff --git a/src/android/Resources/values/ic_launcher_background.xml b/src/android/Resources/values/ic_launcher_background.xml new file mode 100644 index 0000000..6ec24e6 --- /dev/null +++ b/src/android/Resources/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #2C3E50 + \ No newline at end of file diff --git a/src/android/Resources/values/strings.xml b/src/android/Resources/values/strings.xml new file mode 100644 index 0000000..e9edafa --- /dev/null +++ b/src/android/Resources/values/strings.xml @@ -0,0 +1,3 @@ + + charac + diff --git a/src/android/packages.lock.json b/src/android/packages.lock.json new file mode 100644 index 0000000..8acdf78 --- /dev/null +++ b/src/android/packages.lock.json @@ -0,0 +1,15 @@ +{ + "version": 2, + "dependencies": { + "net10.0-android36.0": { + "Microsoft.NET.ILLink.Tasks": { + "type": "Direct", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "xi+BDjFpW+Sb+MHFHaH6Y/gV9I8BluFwRXc1QyCdoZbIK26eNiBeFuMTe/FMwc33G1wdHCyDg7CVTmb8OdQrMQ==" + } + }, + "net10.0-android36.0/android-arm64": {}, + "net10.0-android36.0/android-x64": {} + } +} \ No newline at end of file diff --git a/src/appsettings.json b/src/appsettings.json deleted file mode 100644 index c9fea6b..0000000 --- a/src/appsettings.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "Urls": "http://127.0.0.1:5080", - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, - "AllowedHosts": "localhost;127.0.0.1" -} diff --git a/src/client/Assets/Fonts/IBM-Plex-LICENSE.txt b/src/client/Assets/Fonts/IBM-Plex-LICENSE.txt new file mode 100644 index 0000000..670c6c0 --- /dev/null +++ b/src/client/Assets/Fonts/IBM-Plex-LICENSE.txt @@ -0,0 +1,93 @@ +Copyright © 2017 IBM Corp. with Reserved Font Name "Plex" + +This Font Software is licensed under the SIL Open Font License, Version 1.1. + +This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/src/client/Assets/Fonts/IBMPlexMono-Bold.woff2 b/src/client/Assets/Fonts/IBMPlexMono-Bold.woff2 new file mode 100644 index 0000000..6200c49 Binary files /dev/null and b/src/client/Assets/Fonts/IBMPlexMono-Bold.woff2 differ diff --git a/src/client/Assets/Fonts/IBMPlexMono-Regular.woff2 b/src/client/Assets/Fonts/IBMPlexMono-Regular.woff2 new file mode 100644 index 0000000..8bdb4c4 Binary files /dev/null and b/src/client/Assets/Fonts/IBMPlexMono-Regular.woff2 differ diff --git a/src/client/ClientConfiguration.cs b/src/client/ClientConfiguration.cs new file mode 100644 index 0000000..f164ec0 --- /dev/null +++ b/src/client/ClientConfiguration.cs @@ -0,0 +1,74 @@ +namespace Charac.Client; + +internal static class ClientConfiguration +{ + public static string DefaultPath + { + get + { + if (OperatingSystem.IsWindows()) + { + var applicationData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); + if (string.IsNullOrWhiteSpace(applicationData)) + throw new InvalidOperationException("Cannot locate the user configuration directory."); + return Path.Combine(applicationData, "Charac", "client.config"); + } + + var xdg = Environment.GetEnvironmentVariable("XDG_CONFIG_HOME"); + if (!string.IsNullOrWhiteSpace(xdg) && Path.IsPathFullyQualified(xdg)) + return Path.Combine(xdg, "charac", "client.config"); + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (string.IsNullOrWhiteSpace(home)) + throw new InvalidOperationException("Cannot locate the user configuration directory."); + return Path.Combine(home, ".config", "charac", "client.config"); + } + } + + private static string LegacyPath + { + get + { + if (OperatingSystem.IsWindows()) + return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "CharRAC", "client.config"); + var xdg = Environment.GetEnvironmentVariable("XDG_CONFIG_HOME"); + if (!string.IsNullOrWhiteSpace(xdg) && Path.IsPathFullyQualified(xdg)) + return Path.Combine(xdg, "char-rac", "client.config"); + return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".config", "char-rac", "client.config"); + } + } + public static string LoadServer(string? path = null) + { + path = path is null ? (File.Exists(DefaultPath) ? DefaultPath : + File.Exists(LegacyPath) ? LegacyPath : DefaultPath) : Path.GetFullPath(path); + if (!File.Exists(path)) + throw new InvalidOperationException($"Client configuration not found: {path}. Create [client] server=https://your-server or pass --server."); + + string? section = null; + string? server = null; + foreach (var source in File.ReadLines(path)) + { + var line = source.Trim(); + if (line.Length == 0 || line[0] is ';' or '#') + continue; + if (line.StartsWith('[') && line.EndsWith(']')) + { + section = line[1..^1].Trim(); + continue; + } + var equals = line.IndexOf('='); + if (equals <= 0) + throw new InvalidOperationException($"Invalid client configuration line in {path}."); + if (!string.Equals(section, "client", StringComparison.OrdinalIgnoreCase) || + !string.Equals(line[..equals].Trim(), "server", StringComparison.OrdinalIgnoreCase)) + continue; + if (server is not null) + throw new InvalidOperationException($"Duplicate [client] server in {path}."); + server = line[(equals + 1)..].Trim().Trim('"'); + } + if (server is null || !Program.TryGetServerUri(server, out var uri)) + throw new InvalidOperationException($"[client] server must be an HTTPS origin (HTTP only for loopback): {path}."); + return uri.ToString(); + } +} \ No newline at end of file diff --git a/src/client/ClientDeviceCredential.cs b/src/client/ClientDeviceCredential.cs new file mode 100644 index 0000000..dc63741 --- /dev/null +++ b/src/client/ClientDeviceCredential.cs @@ -0,0 +1,60 @@ +using System.Security.Cryptography; + +namespace Charac.Client; + +/// Stable, private identifier for one CLI installation; sent only in HTTPS headers. +internal static class ClientDeviceCredential +{ + public const string HeaderName = "X-RAC-Device"; + + public static string LoadOrCreate(string? directory = null) + { + if (directory is null) + { + var applicationData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + if (string.IsNullOrWhiteSpace(applicationData)) + throw new InvalidOperationException("Cannot locate the user application-data directory."); + directory = Path.Combine(applicationData, "workspace-access"); + } + if (string.IsNullOrWhiteSpace(directory)) + throw new InvalidOperationException("Cannot locate the user application-data directory."); + Directory.CreateDirectory(directory); + var path = Path.Combine(directory, "device-key"); + if (!File.Exists(path)) + { + var secret = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)) + .TrimEnd('=').Replace('+', '-').Replace('/', '_'); + var temporary = Path.Combine(directory, ".device-key-" + Guid.NewGuid().ToString("N")); + try + { + var options = new FileStreamOptions { Mode = FileMode.CreateNew, Access = FileAccess.Write, + Share = FileShare.None }; + if (!OperatingSystem.IsWindows()) + options.UnixCreateMode = UnixFileMode.UserRead | UnixFileMode.UserWrite; + using (var stream = new FileStream(temporary, options)) + using (var writer = new StreamWriter(stream)) + writer.Write(secret); + File.Move(temporary, path); + } + catch (IOException) when (File.Exists(path)) + { + // Another CLI invocation created the credential first. + } + finally + { + if (File.Exists(temporary)) + File.Delete(temporary); + } + } + if ((File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0) + throw new InvalidOperationException("The client device credential must not be a symbolic link."); + if (!OperatingSystem.IsWindows() && (File.GetUnixFileMode(path) & + (UnixFileMode.GroupRead | UnixFileMode.GroupWrite | UnixFileMode.GroupExecute | + UnixFileMode.OtherRead | UnixFileMode.OtherWrite | UnixFileMode.OtherExecute)) != 0) + throw new InvalidOperationException("The client device credential is readable by another Unix user."); + var value = File.ReadAllText(path).Trim(); + if (value.Length != 43 || value.Any(ch => !char.IsAsciiLetterOrDigit(ch) && ch is not '-' and not '_')) + throw new InvalidOperationException("The client device credential is invalid."); + return value; + } +} diff --git a/src/client/ClientJsonContext.cs b/src/client/ClientJsonContext.cs new file mode 100644 index 0000000..9f1ffd7 --- /dev/null +++ b/src/client/ClientJsonContext.cs @@ -0,0 +1,19 @@ +using System.Text.Json.Serialization; + +namespace Charac.Client; + +internal sealed record CreateSessionRequest(int Columns, int Rows); +internal sealed record PasswordSessionRequest(string Password, int Columns, int Rows); +internal sealed record ResizeFrame(string Type, int Columns, int Rows); +internal sealed record InputFrame(string Type, string Data); + +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true)] +[JsonSerializable(typeof(ResourceList))] +[JsonSerializable(typeof(CreateSessionRequest))] +[JsonSerializable(typeof(PasswordSessionRequest))] +[JsonSerializable(typeof(ResizeFrame))] +[JsonSerializable(typeof(InputFrame))] +internal partial class ClientJsonContext : JsonSerializerContext +{ +} diff --git a/src/client/OidcLogin.cs b/src/client/OidcLogin.cs new file mode 100644 index 0000000..2487494 --- /dev/null +++ b/src/client/OidcLogin.cs @@ -0,0 +1,269 @@ +using System.Diagnostics; +using System.Net; +using System.Net.Sockets; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; + +namespace Charac.Client; + +internal static class OidcLogin +{ + public static async Task LoginAsync(HttpClient client, string issuer, string clientId, + string discoveryUrl, + CancellationToken cancellationToken = default) + { + if (!Uri.TryCreate(issuer, UriKind.Absolute, out var issuerUri) || + issuerUri.Scheme != Uri.UriSchemeHttps || string.IsNullOrWhiteSpace(clientId)) + throw new InvalidOperationException("The Server returned invalid OIDC settings."); + + if (!Uri.TryCreate(discoveryUrl, UriKind.Absolute, out var discoveryUri) || + discoveryUri.Scheme != Uri.UriSchemeHttps) + throw new InvalidOperationException("The Server returned an invalid OIDC discovery URL."); + using var discoveryResponse = await client.GetAsync(discoveryUri, cancellationToken); + discoveryResponse.EnsureSuccessStatusCode(); + using var discovery = await JsonDocument.ParseAsync( + await discoveryResponse.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken); + var metadata = discovery.RootElement; + if (metadata.GetProperty("issuer").GetString() != issuer) + throw new InvalidOperationException("OIDC discovery issuer does not match the Server configuration."); + var authorize = HttpsEndpoint(metadata.GetProperty("authorization_endpoint").GetString()); + var token = HttpsEndpoint(metadata.GetProperty("token_endpoint").GetString()); + + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + var redirectUri = $"http://127.0.0.1:{port}/callback"; + var verifier = Base64Url(RandomNumberGenerator.GetBytes(32)); + var challenge = Base64Url(SHA256.HashData(Encoding.ASCII.GetBytes(verifier))); + var state = Base64Url(RandomNumberGenerator.GetBytes(32)); + var query = new Dictionary + { + ["response_type"] = "code", + ["client_id"] = clientId, + ["redirect_uri"] = redirectUri, + ["scope"] = "openid", + ["state"] = state, + ["code_challenge"] = challenge, + ["code_challenge_method"] = "S256" + }; + var authorizationUri = new UriBuilder(authorize) + { + Query = string.Join('&', query.Select(pair => + $"{Uri.EscapeDataString(pair.Key)}={Uri.EscapeDataString(pair.Value)}")) + }.Uri; + Console.WriteLine($"Open this URL to sign in: {authorizationUri}"); + try + { + Process.Start(new ProcessStartInfo(authorizationUri.ToString()) { UseShellExecute = true }); + } + catch (Exception exception) when (exception is System.ComponentModel.Win32Exception or + PlatformNotSupportedException or InvalidOperationException) + { + Console.WriteLine("Open the URL manually in a browser on this device."); + } + + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(TimeSpan.FromMinutes(3)); + var code = await ReceiveCodeAsync(listener, state, timeout.Token); + using var body = new FormUrlEncodedContent(new Dictionary + { + ["grant_type"] = "authorization_code", + ["client_id"] = clientId, + ["code"] = code, + ["redirect_uri"] = redirectUri, + ["code_verifier"] = verifier + }); + using var tokenResponse = await client.PostAsync(token, body, cancellationToken); + if (!tokenResponse.IsSuccessStatusCode) + throw new InvalidOperationException(await DescribeTokenErrorAsync(tokenResponse, cancellationToken)); + using var tokenDocument = await JsonDocument.ParseAsync( + await tokenResponse.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken); + if (tokenDocument.RootElement.GetProperty("token_type").GetString() is not { } tokenType || + !string.Equals(tokenType, "Bearer", StringComparison.OrdinalIgnoreCase) || + tokenDocument.RootElement.GetProperty("access_token").GetString() is not { Length: > 0 } accessToken) + throw new InvalidOperationException("OIDC token response did not contain a bearer access token."); + return accessToken; + } + + private static async Task ReceiveCodeAsync(TcpListener listener, string expectedState, + CancellationToken cancellationToken) + { + using var connection = await listener.AcceptTcpClientAsync(cancellationToken); + await using var stream = connection.GetStream(); + using var reader = new StreamReader(stream, Encoding.ASCII, leaveOpen: true); + var requestLine = await reader.ReadLineAsync(cancellationToken); + if (requestLine is null || requestLine.Length > 8192 || !requestLine.StartsWith("GET ", StringComparison.Ordinal)) + throw new InvalidOperationException("The OIDC callback request is invalid."); + string? header; + do + { + header = await reader.ReadLineAsync(cancellationToken); + if (header is { Length: > 8192 }) + throw new InvalidOperationException("The OIDC callback request is too large."); + } while (header is { Length: > 0 }); + + var path = requestLine.Split(' ', 3)[1]; + if (!Uri.TryCreate("http://127.0.0.1" + path, UriKind.Absolute, out var callback) || + callback.AbsolutePath != "/callback") + throw new InvalidOperationException("The OIDC callback path is invalid."); + var values = callback.Query.TrimStart('?').Split('&', StringSplitOptions.RemoveEmptyEntries) + .Select(part => part.Split('=', 2)) + .ToDictionary(parts => Uri.UnescapeDataString(parts[0]), + parts => Uri.UnescapeDataString(parts.Length == 2 ? parts[1] : "")); + values.TryGetValue("code", out var code); + var valid = values.TryGetValue("state", out var state) && + CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(state), + Encoding.ASCII.GetBytes(expectedState)) && + !string.IsNullOrWhiteSpace(code); + var page = CallbackPage + .Replace("__FONT_REGULAR__", ReadFontBase64("Regular"), StringComparison.Ordinal) + .Replace("__FONT_BOLD__", ReadFontBase64("Bold"), StringComparison.Ordinal) + .Replace("__STATE__", valid ? "success" : "failure", StringComparison.Ordinal) + .Replace("__ICON__", valid ? "✓" : "!", StringComparison.Ordinal) + .Replace("__TITLE__", valid ? "已收到授权码" : "授权回调未通过验证", StringComparison.Ordinal) + .Replace("__MESSAGE__", valid + ? "请返回终端,等待 RAC 完成登录。现在可以关闭此页面。" + : "请关闭此页面,在终端重新发起登录。", StringComparison.Ordinal); + var content = Encoding.UTF8.GetBytes(page); + var headers = Encoding.ASCII.GetBytes( + $"HTTP/1.1 {(valid ? "200 OK" : "400 Bad Request")}\r\n" + + "Content-Type: text/html; charset=utf-8\r\n" + + $"Content-Length: {content.Length}\r\n" + + "Cache-Control: no-store\r\n" + + "Referrer-Policy: no-referrer\r\n" + + "X-Content-Type-Options: nosniff\r\n" + + "Content-Security-Policy: default-src 'none'; font-src data:; style-src 'unsafe-inline'; script-src 'unsafe-inline'; base-uri 'none'; form-action 'none'\r\n" + + "Connection: close\r\n\r\n"); + await stream.WriteAsync(headers, cancellationToken); + await stream.WriteAsync(content, cancellationToken); + if (!valid) + throw new InvalidOperationException("OIDC callback state or authorization code is invalid."); + return code!; + } + + private static async Task DescribeTokenErrorAsync(HttpResponseMessage response, + CancellationToken cancellationToken) + { + var message = $"OIDC token exchange failed (HTTP {(int)response.StatusCode})"; + try + { + using var document = await JsonDocument.ParseAsync( + await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken); + if (document.RootElement.ValueKind != JsonValueKind.Object || + !document.RootElement.TryGetProperty("error", out var error) || + error.ValueKind != JsonValueKind.String) + return message; + + var code = error.GetString(); + if (string.IsNullOrEmpty(code) || code.Length > 80 || + !code.All(character => char.IsAsciiLetterOrDigit(character) || character is '_' or '-')) + return message; + + message += $": {code}"; + if (document.RootElement.TryGetProperty("error_description", out var description) && + description.ValueKind == JsonValueKind.String) + { + var detail = description.GetString(); + if (!string.IsNullOrWhiteSpace(detail)) + { + detail = new string(detail.Take(300).Select(character => + char.IsControl(character) ? ' ' : character).ToArray()); + message += $" ({detail})"; + } + } + } + catch (JsonException) + { + // A non-JSON proxy or provider error still reports the HTTP status. + } + + return message; + } + + private static Uri HttpsEndpoint(string? value) + { + if (!Uri.TryCreate(value, UriKind.Absolute, out var uri) || + uri.Scheme != Uri.UriSchemeHttps || uri.UserInfo.Length != 0) + throw new InvalidOperationException("OIDC discovery returned an invalid HTTPS endpoint."); + return uri; + } + + private static string Base64Url(byte[] bytes) => Convert.ToBase64String(bytes) + .TrimEnd('=').Replace('+', '-').Replace('/', '_'); + + private static string ReadFontBase64(string weight) + { + using var stream = typeof(OidcLogin).Assembly.GetManifestResourceStream( + $"Charac.Fonts.IBMPlexMono-{weight}.woff2") ?? + throw new InvalidOperationException("The bundled callback font is missing."); + using var buffer = new MemoryStream(); + stream.CopyTo(buffer); + return Convert.ToBase64String(buffer.ToArray()); + } + private const string CallbackPage = """ + + + + + + + RAC · 授权回调 + + + + + + RACWORKSPACEACCESS + AUTH / 01 + + + __ICON__授权回调 / AUTHORIZATION + __TITLE__ + __MESSAGE__ + + + + + + + """; +} diff --git a/src/client/Program.cs b/src/client/Program.cs new file mode 100644 index 0000000..ff75e3a --- /dev/null +++ b/src/client/Program.cs @@ -0,0 +1,140 @@ +using System.Text.Json; + +namespace Charac.Client; + +internal static class Program +{ + private const int ProtocolVersion = 3; + + public static async Task Main(string[] args) + { + string? configPath = null; + if (args.Length >= 2 && args[0] == "--config") + { + configPath = args[1]; + args = args[2..]; + } + if (args.Length == 0 || args is ["--help"] or ["-h"]) + { + PrintUsage(); + return 0; + } + if (args[0] is "status" or "login" or "whoami" or "connect" or "run" or "attach" or "probe-session" or "shell" && + !args.Contains("--server", StringComparer.Ordinal)) + { + try + { + var origin = ClientConfiguration.LoadServer(configPath); + args = [args[0], "--server", origin, .. args[1..]]; + } + catch (Exception exception) when (exception is InvalidOperationException or IOException or UnauthorizedAccessException or ArgumentException) + { + Console.Error.WriteLine(exception.Message); + return 2; + } + } + + if (args is ["status", "--server", var server]) + { + return await ShowStatusAsync(server); + } + if (args is ["login" or "whoami", "--server", var identityServer]) + return await WorkspaceClient.WhoAmIAsync(identityServer); + if (args is ["connect", "--server", var resourceServer]) + return await WorkspaceClient.RunAsync(resourceServer, null, null, interactive: true); + if (args is ["connect", "--server", var connectServer, "--target", var connectTarget] && + Guid.TryParse(connectTarget, out var connectTargetId) && connectTargetId != Guid.Empty) + return await WorkspaceClient.RunAsync(connectServer, connectTargetId, null, interactive: true); + if (args is ["connect", "--server", var reconnectServer, "--id", var reconnectSession] && + Guid.TryParse(reconnectSession, out var reconnectId) && reconnectId != Guid.Empty) + return await WorkspaceClient.RunAsync(reconnectServer, null, reconnectId, interactive: true); + if (args is ["run", "--server", var runServer, "--target", var targetText] && + Guid.TryParse(targetText, out var targetId) && targetId != Guid.Empty) + return await WorkspaceClient.RunAsync(runServer, targetId, null); + if (args is ["attach", "--server", var attachServer, "--id", var sessionText] && + Guid.TryParse(sessionText, out var sessionId) && sessionId != Guid.Empty) + return await WorkspaceClient.RunAsync(attachServer, null, sessionId); + if (args is ["probe-session", "--server", var probeServer]) + return await WorkspaceClient.RunLocalProbeAsync(probeServer); + if (args is ["shell", "--server", var shellServer]) + return await WorkspaceClient.RunLocalShellAsync(shellServer); + + PrintUsage(Console.Error); + return 2; + } + + private static async Task ShowStatusAsync(string server) + { + if (!TryGetServerUri(server, out var serverUri)) + { + Console.Error.WriteLine("Server must be an HTTPS origin (HTTP is allowed only for loopback)."); + return 2; + } + + try + { + using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(10) }; + var statusUri = new Uri(serverUri, "/status"); + using var response = await client.GetAsync(statusUri); + response.EnsureSuccessStatusCode(); + await using var body = await response.Content.ReadAsStreamAsync(); + using var document = await JsonDocument.ParseAsync(body); + var status = document.RootElement; + if (status.GetProperty("service").GetString() != "workspace-access" || + status.GetProperty("protocolVersion").GetInt32() != ProtocolVersion) + { + Console.Error.WriteLine("The endpoint is not a compatible Workspace Access Server."); + return 1; + } + + var ready = status.GetProperty("ready").GetBoolean(); + Console.WriteLine($"Server reachable: {serverUri}"); + Console.WriteLine(ready ? "Service ready." : "Service not ready yet."); + return 0; + } + catch (Exception exception) when ( + exception is HttpRequestException or TaskCanceledException or JsonException or KeyNotFoundException or InvalidOperationException) + { + Console.Error.WriteLine($"Could not read server status: {exception.Message}"); + return 1; + } + } + + internal static bool TryGetServerUri(string value, out Uri serverUri) + { + serverUri = null!; + if (!Uri.TryCreate(value, UriKind.Absolute, out var parsed) || + (parsed.Scheme != Uri.UriSchemeHttps && parsed.Scheme != Uri.UriSchemeHttp) || + (parsed.Scheme == Uri.UriSchemeHttp && !parsed.IsLoopback) || + parsed.AbsolutePath != "/" || + parsed.Query.Length != 0 || + parsed.Fragment.Length != 0 || + parsed.UserInfo.Length != 0) + { + return false; + } + + serverUri = parsed; + return true; + } + + private static void PrintUsage(TextWriter? output = null) + { + output ??= Console.Out; + output.WriteLine("Usage: charac [--config ] status [--server https://access.example.com]"); + output.WriteLine(" charac login --server https://access.example.com"); + output.WriteLine(" charac connect [--server https://access.example.com]"); + output.WriteLine(" charac connect [--server https://access.example.com] --target "); + output.WriteLine(" charac connect [--server https://access.example.com] --id "); + output.WriteLine(" charac whoami --server https://access.example.com"); + output.WriteLine(" charac run --server https://access.example.com --target "); + output.WriteLine(" charac attach --server https://access.example.com --id "); + output.WriteLine(" charac probe-session --server http://127.0.0.1:5080"); + output.WriteLine(" charac shell --server http://127.0.0.1:5080"); + output.WriteLine("Login opens the browser and verifies identity with Server; tokens are kept only for this command."); + output.WriteLine("Connect signs in, lists your resources when no ID is supplied, and opens an interactive workspace. Ctrl+] detaches; Server keeps the SSH session."); + output.WriteLine("Run and attach sign in with OIDC and verify a Linux ls through the managed SSH workspace."); + output.WriteLine("Probe-session verifies one loopback-only Client -> Server -> SSH ls without OIDC."); + output.WriteLine("Shell opens a loopback-only interactive SSH terminal; Ctrl+] exits (F12 in key fallback mode)."); + } +} diff --git a/src/client/ResourceMenu.cs b/src/client/ResourceMenu.cs new file mode 100644 index 0000000..8b018a0 --- /dev/null +++ b/src/client/ResourceMenu.cs @@ -0,0 +1,75 @@ +using System.Net.Http.Json; + +namespace Charac.Client; + +internal sealed record ResourceTarget(Guid Id, string Name, string Account); +internal sealed record ResourceSession(Guid Id, Guid TargetId, string TargetName, string Account, + DateTimeOffset CreatedAt, string State); +internal sealed record ResourceList(ResourceTarget[] Targets, ResourceSession[] Sessions); +internal sealed record ResourceSelection(Guid? TargetId = null, Guid? SessionId = null, bool Refresh = false); + +internal static class ResourceMenu +{ + public static async Task SelectAsync(HttpClient http, Uri origin, + TextReader input, TextWriter output, CancellationToken cancellationToken = default) + { + while (true) + { + var resources = await http.GetFromJsonAsync(new Uri(origin, "/resources"), + ClientJsonContext.Default.ResourceList, cancellationToken); + if (resources?.Targets is null || resources.Sessions is null) + throw new InvalidOperationException("Server returned an invalid resource list."); + var selection = ReadSelection(resources, input, output); + if (selection is not { Refresh: true }) + return selection; + } + } + + internal static ResourceSelection? ReadSelection(ResourceList resources, TextReader input, TextWriter output) + { + var choices = new List(); + output.WriteLine(); + output.WriteLine("已有会话"); + if (resources.Sessions.Length == 0) + output.WriteLine(" 暂无运行中的会话。"); + foreach (var session in resources.Sessions) + { + var caption = $"{Label(session.TargetName)} / {Label(session.Account)} · {session.CreatedAt.ToLocalTime():g}"; + if (session.State == "detached") + { + choices.Add(new(SessionId: session.Id)); + output.WriteLine($" [{choices.Count}] 返回 {caption}"); + } + else + output.WriteLine($" [占用] {caption} · 已有客户端控制,暂不可选"); + output.WriteLine($" {session.Id}"); + } + output.WriteLine(); + output.WriteLine("新建会话"); + if (resources.Targets.Length == 0) + output.WriteLine(" 暂无已授权的目标,请管理员登记目标访问权限。"); + foreach (var target in resources.Targets) + { + choices.Add(new(TargetId: target.Id)); + output.WriteLine($" [{choices.Count}] 新建 {Label(target.Name)} / {Label(target.Account)}"); + output.WriteLine($" {target.Id}"); + } + output.WriteLine(" 目标列表表示访问授权;实际连接时检查 SSH 是否可达。"); + output.WriteLine(); + while (true) + { + output.Write("输入编号连接,r 刷新,q 退出:"); + var value = input.ReadLine()?.Trim(); + if (value is null || string.Equals(value, "q", StringComparison.OrdinalIgnoreCase)) + return null; + if (string.Equals(value, "r", StringComparison.OrdinalIgnoreCase)) + return new(Refresh: true); + if (int.TryParse(value, out var number) && number >= 1 && number <= choices.Count) + return choices[number - 1]; + output.WriteLine("请选择列表中的编号,或输入 r / q。"); + } + } + + private static string Label(string value) => new(value.Where(character => + !char.IsControl(character) && character is not ('\u2028' or '\u2029')).Take(160).ToArray()); +} diff --git a/src/client/TerminalLine.cs b/src/client/TerminalLine.cs new file mode 100644 index 0000000..3856991 --- /dev/null +++ b/src/client/TerminalLine.cs @@ -0,0 +1,30 @@ +namespace Charac.Client; + +/// Places local prompts and status messages below the current terminal text. +internal static class TerminalLine +{ + public static void Align() + { + if (Console.IsOutputRedirected) + return; + try + { + if (Console.GetCursorPosition().Left != 0) + Console.WriteLine(); + } + catch (IOException) + { + Console.WriteLine(); + } + catch (InvalidOperationException) + { + Console.WriteLine(); + } + } + + public static void WriteStatus(string status) + { + Align(); + Console.WriteLine(status); + } +} diff --git a/src/client/TerminalMouseTracking.cs b/src/client/TerminalMouseTracking.cs new file mode 100644 index 0000000..54269cb --- /dev/null +++ b/src/client/TerminalMouseTracking.cs @@ -0,0 +1,128 @@ +using System.Globalization; + +namespace Charac.Client; + +/// Tracks remote VT mouse modes and keeps stale mouse reports out of the shell. +internal sealed class TerminalMouseTracking +{ + private readonly byte[] _outputSequence = new byte[64]; + private readonly byte[] _inputSequence = new byte[64]; + private int _outputLength; + private int _inputLength; + private int _trackingModes; + private int _forwardMouse; + private int _alternateScreen; + + public bool HasPendingInput => _inputLength != 0; + public bool InAlternateScreen => Volatile.Read(ref _alternateScreen) != 0; + + public void ObserveOutput(ReadOnlySpan data) + { + foreach (var value in data) + { + if (_outputLength == 0) + { + if (value == 0x1b) + _outputSequence[_outputLength++] = value; + continue; + } + if (_outputLength == 1 && value != (byte)'[' || + _outputLength == 2 && value != (byte)'?') + { + _outputLength = value == 0x1b ? 1 : 0; + continue; + } + if (_outputLength == _outputSequence.Length) + { + _outputLength = 0; + continue; + } + _outputSequence[_outputLength++] = value; + if (value is (byte)'h' or (byte)'l') + { + UpdateModes(value == (byte)'h'); + _outputLength = 0; + } + else if (_outputLength > 3 && value is not ((byte)';') && + (value < (byte)'0' || value > (byte)'9')) + _outputLength = 0; + } + } + + public byte[] FilterInput(ReadOnlySpan data) + { + using var output = new MemoryStream(data.Length + _inputLength); + foreach (var value in data) + { + if (_inputLength == 0) + { + if (value == 0x1b) + _inputSequence[_inputLength++] = value; + else + output.WriteByte(value); + continue; + } + + var expected = _inputLength switch + { + 1 => (byte)'[', + 2 => (byte)'<', + _ => (byte)0 + }; + if (expected != 0 && value != expected || _inputLength >= 3 && + value is not ((byte)'M' or (byte)'m' or (byte)';') && + (value < (byte)'0' || value > (byte)'9') || + _inputLength == _inputSequence.Length) + { + output.Write(_inputSequence.AsSpan(0, _inputLength)); + _inputLength = 0; + if (value == 0x1b) + _inputSequence[_inputLength++] = value; + else + output.WriteByte(value); + continue; + } + + _inputSequence[_inputLength++] = value; + if (value is (byte)'M' or (byte)'m') + { + if (Volatile.Read(ref _forwardMouse) != 0) + output.Write(_inputSequence.AsSpan(0, _inputLength)); + _inputLength = 0; + } + } + return output.ToArray(); + } + + public byte[] FlushPendingInput() + { + var pending = _inputSequence[.._inputLength]; + _inputLength = 0; + return pending; + } + + private void UpdateModes(bool enable) + { + var text = System.Text.Encoding.ASCII.GetString(_outputSequence.AsSpan(3, _outputLength - 4)); + foreach (var part in text.Split(';')) + { + if (!int.TryParse(part, NumberStyles.None, CultureInfo.InvariantCulture, out var mode)) + continue; + if (mode == 1049) + { + Volatile.Write(ref _alternateScreen, enable ? 1 : 0); + if (!enable) + _trackingModes = 0; + } + else + { + var bit = mode switch { 1000 => 1, 1002 => 2, 1003 => 4, _ => 0 }; + if (enable) + _trackingModes |= bit; + else + _trackingModes &= ~bit; + } + } + Volatile.Write(ref _forwardMouse, _trackingModes != 0 ? 1 : 0); + } +} diff --git a/src/client/WindowsTerminalInputMode.cs b/src/client/WindowsTerminalInputMode.cs new file mode 100644 index 0000000..a3e2e84 --- /dev/null +++ b/src/client/WindowsTerminalInputMode.cs @@ -0,0 +1,113 @@ +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Text; + +namespace Charac.Client; + +/// Passes VT output through the Windows console and reads VT keyboard and mouse input. +internal sealed class WindowsTerminalInputMode : IDisposable +{ + private const int StandardInput = -10; + private const int StandardOutput = -11; + private const uint ProcessedInput = 0x0001; + private const uint LineInput = 0x0002; + private const uint EchoInput = 0x0004; + private const uint MouseInput = 0x0010; + private const uint QuickEdit = 0x0040; + private const uint ExtendedFlags = 0x0080; + private const uint VirtualTerminalInput = 0x0200; + private const uint ProcessedOutput = 0x0001; + private const uint VirtualTerminalOutput = 0x0004; + private const uint DisableNewlineAutoReturn = 0x0008; + + private readonly nint _inputHandle; + private readonly uint _originalInputMode; + private readonly nint _outputHandle; + private readonly uint _originalOutputMode; + private bool _disposed; + + private WindowsTerminalInputMode(nint inputHandle, uint originalInputMode, + nint outputHandle, uint originalOutputMode) + { + _inputHandle = inputHandle; + _originalInputMode = originalInputMode; + _outputHandle = outputHandle; + _originalOutputMode = originalOutputMode; + } + + public static WindowsTerminalInputMode? TryEnable() + { + if (!OperatingSystem.IsWindows()) + return null; + var inputHandle = GetStdHandle(StandardInput); + var outputHandle = GetStdHandle(StandardOutput); + if (inputHandle == 0 || inputHandle == -1 || outputHandle == 0 || outputHandle == -1 || + !GetConsoleMode(inputHandle, out var inputMode) || + !GetConsoleMode(outputHandle, out var outputMode)) + return null; + + var virtualTerminalOutput = outputMode | ProcessedOutput | VirtualTerminalOutput | + DisableNewlineAutoReturn; + if (!SetConsoleMode(outputHandle, virtualTerminalOutput)) + throw new Win32Exception(Marshal.GetLastWin32Error(), "Could not enable terminal VT output."); + var raw = (inputMode | MouseInput | ExtendedFlags | VirtualTerminalInput) & + ~(ProcessedInput | LineInput | EchoInput | QuickEdit); + if (!SetConsoleMode(inputHandle, raw)) + { + var error = Marshal.GetLastWin32Error(); + _ = SetConsoleMode(outputHandle, outputMode); + throw new Win32Exception(error, "Could not enable terminal mouse input."); + } + return new WindowsTerminalInputMode(inputHandle, inputMode, outputHandle, outputMode); + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + _ = SetConsoleMode(_inputHandle, _originalInputMode); + _ = SetConsoleMode(_outputHandle, _originalOutputMode); + } + + public static void ResetTerminalState(bool leaveAlternateScreen = false) + { + if (!OperatingSystem.IsWindows()) + return; + var outputHandle = GetStdHandle(StandardOutput); + if (outputHandle == 0 || outputHandle == -1 || !GetConsoleMode(outputHandle, out var originalMode)) + return; + var inputHandle = GetStdHandle(StandardInput); + if (!SetConsoleMode(outputHandle, originalMode | ProcessedOutput | VirtualTerminalOutput)) + return; + try + { + var output = Console.OpenStandardOutput(); + output.Write(Encoding.ASCII.GetBytes( + "\u001b[?1000l\u001b[?1002l\u001b[?1003l\u001b[?1006l\u001b[?1015l\u001b[?1004l\u001b[?2004l\u001b[?2026l" + + (leaveAlternateScreen ? "\u001b[?1049l" : "") + "\u001b[?25h")); + output.Flush(); + if (inputHandle != 0 && inputHandle != -1) + _ = FlushConsoleInputBuffer(inputHandle); + } + finally + { + _ = SetConsoleMode(outputHandle, originalMode); + } + } + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern nint GetStdHandle(int standardHandle); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool GetConsoleMode(nint consoleHandle, out uint mode); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SetConsoleMode(nint consoleHandle, uint mode); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool FlushConsoleInputBuffer(nint consoleHandle); +} diff --git a/src/client/WorkspaceAccessClient.csproj b/src/client/WorkspaceAccessClient.csproj new file mode 100644 index 0000000..29adbfe --- /dev/null +++ b/src/client/WorkspaceAccessClient.csproj @@ -0,0 +1,15 @@ + + + Exe + charac + Charac.Client + win-x64;win-arm64;linux-x64;linux-arm64;linux-bionic-arm64 + + + + + + + + + diff --git a/src/client/WorkspaceClient.cs b/src/client/WorkspaceClient.cs new file mode 100644 index 0000000..7a13fe1 --- /dev/null +++ b/src/client/WorkspaceClient.cs @@ -0,0 +1,590 @@ +using System.Net; +using System.ComponentModel; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Net.WebSockets; +using System.Text; +using System.Text.Json; + +namespace Charac.Client; + +internal static class WorkspaceClient +{ + public static async Task RunAsync(string server, Guid? targetId, Guid? sessionId, bool interactive = false) + { + if (!Program.TryGetServerUri(server, out var origin) || + (!interactive && targetId is null && sessionId is null) || + (targetId is not null && sessionId is not null)) + { + Console.Error.WriteLine("Provide a valid Server origin and one target or session ID."); + return 2; + } + try + { + using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) }; + var deviceCredential = ClientDeviceCredential.LoadOrCreate(); + http.DefaultRequestHeaders.Add(ClientDeviceCredential.HeaderName, deviceCredential); + if (interactive && (Console.IsInputRedirected || Console.IsOutputRedirected)) + throw new InvalidOperationException("Connect requires an interactive terminal."); + var accessToken = await SignInAsync(http, origin, showIdentity: false); + if (targetId is null && sessionId is null) + { + var selection = await ResourceMenu.SelectAsync(http, origin, Console.In, Console.Out); + if (selection is null) + { + Console.WriteLine("已取消连接。"); + return 0; + } + targetId = selection.TargetId; + sessionId = selection.SessionId; + } + var id = sessionId ?? await CreateAsync(http, origin, targetId!.Value, interactive); + if (interactive) + { + Console.WriteLine($"Workspace: {id}"); + Console.WriteLine($"Reconnect: connect --server {origin} --id {id}"); + await RunInteractiveAsync(origin, id, accessToken, "/session", deviceCredential); + } + else + { + await RunLsAsync(origin, id, accessToken, "/session", deviceCredential); + Console.WriteLine($"Workspace remains on Server: {id}"); + } + return 0; + } + catch (HttpRequestException exception) when (exception.StatusCode == HttpStatusCode.Conflict) + { + Console.Error.WriteLine("This identity is already connected from another Client installation. Disconnect it first."); + return 1; + } + catch (Exception exception) when (exception is HttpRequestException or WebSocketException or + TaskCanceledException or OperationCanceledException or InvalidOperationException or JsonException or + IOException or UnauthorizedAccessException or FormatException or KeyNotFoundException or + Win32Exception or ArgumentException) + { + Console.Error.WriteLine($"Workspace command failed: {exception.Message}"); + return 1; + } + } + + public static Task RunLocalProbeAsync(string server) => RunLocalAsync(server, interactive: false); + + public static Task RunLocalShellAsync(string server) => RunLocalAsync(server, interactive: true); + + private static async Task RunLocalAsync(string server, bool interactive) + { + if (!Program.TryGetServerUri(server, out var origin) || !origin.IsLoopback) + { + Console.Error.WriteLine("Local probe requires a loopback Server origin."); + return 2; + } + try + { + using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) }; + var id = await CreateLocalAsync(http, origin, interactive); + try + { + if (interactive) + await RunInteractiveAsync(origin, id, null, "/local/session", null); + else + { + await RunLsAsync(origin, id, null, "/local/session", null); + Console.WriteLine("Client, Server, WebSocket and remote Linux ls verified."); + } + return 0; + } + finally + { + using var response = await http.DeleteAsync(new Uri(origin, $"/local/session?id={id}")); + // The SSH peer may have already ended the workspace when its shell exited. + if (response.StatusCode != HttpStatusCode.NotFound) + response.EnsureSuccessStatusCode(); + } + } + catch (Exception exception) when (exception is HttpRequestException or WebSocketException or + TaskCanceledException or OperationCanceledException or InvalidOperationException or JsonException or + IOException or FormatException or KeyNotFoundException or Win32Exception) + { + Console.Error.WriteLine($"Local session probe failed: {exception.Message}"); + return 1; + } + } + + public static async Task WhoAmIAsync(string server) + { + if (!Program.TryGetServerUri(server, out var origin)) + { + Console.Error.WriteLine("Server must be an HTTPS origin (HTTP is allowed only for loopback)."); + return 2; + } + try + { + using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) }; + http.DefaultRequestHeaders.Add(ClientDeviceCredential.HeaderName, + ClientDeviceCredential.LoadOrCreate()); + await SignInAsync(http, origin, showIdentity: true); + return 0; + } + catch (Exception exception) when (exception is HttpRequestException or TaskCanceledException or + OperationCanceledException or InvalidOperationException or JsonException or IOException or + UnauthorizedAccessException or KeyNotFoundException or ArgumentException) + { + Console.Error.WriteLine($"Login failed: {exception.Message}"); + return 1; + } + } + + private static async Task SignInAsync(HttpClient http, Uri origin, bool showIdentity) + { + var oidc = await ReadOidcSettingsAsync(http, origin); + var token = await OidcLogin.LoginAsync(http, oidc.Issuer, oidc.ClientId, oidc.DiscoveryUrl); + http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + using var response = await http.GetAsync(new Uri(origin, "/identity")); + if (response.StatusCode == HttpStatusCode.Unauthorized) + throw new InvalidOperationException("Server rejected the OIDC access token. Check issuer, audience and signing configuration."); + if (response.StatusCode == HttpStatusCode.Conflict) + throw new InvalidOperationException("This identity is already connected from another Client installation."); + response.EnsureSuccessStatusCode(); + using var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync()); + if (document.RootElement.GetProperty("issuer").GetString() is not { Length: > 0 } issuer || + document.RootElement.GetProperty("subject").GetString() is not { Length: > 0 } subject) + throw new InvalidOperationException("Server returned an invalid identity."); + Console.WriteLine("Signed in. Identity verified by Server."); + if (showIdentity) + { + Console.WriteLine($"Issuer: {issuer}"); + Console.WriteLine($"Subject: {subject}"); + Console.WriteLine("This command does not save a login token; connect signs in when it starts."); + } + return token; + } + + private static async Task<(string Issuer, string ClientId, string DiscoveryUrl)> ReadOidcSettingsAsync( + HttpClient http, Uri origin) + { + using var response = await http.GetAsync(new Uri(origin, "/status")); + response.EnsureSuccessStatusCode(); + using var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync()); + var status = document.RootElement; + if (status.GetProperty("service").GetString() != "workspace-access" || + status.GetProperty("protocolVersion").GetInt32() != 3 || + status.GetProperty("ready").GetBoolean() != true || + status.GetProperty("issuer").GetString() is not { Length: > 0 } issuer || + status.GetProperty("clientId").GetString() is not { Length: > 0 } clientId || + status.GetProperty("discoveryUrl").GetString() is not { Length: > 0 } discoveryUrl) + throw new InvalidOperationException("Server OIDC session service is not configured."); + return (issuer, clientId, discoveryUrl); + } + + private static async Task CreateAsync(HttpClient http, Uri origin, Guid targetId, bool interactive) + { + var (columns, rows) = interactive ? GetConsoleSize() : (80, 24); + var endpoint = new Uri(origin, $"/session?target={targetId}"); + using var initial = await http.PostAsJsonAsync(endpoint, + new CreateSessionRequest(columns, rows), ClientJsonContext.Default.CreateSessionRequest); + HttpResponseMessage? passwordResponse = null; + if (initial.StatusCode == HttpStatusCode.BadRequest && + await RequiresPasswordAsync(initial)) + { + if (Console.IsInputRedirected) + throw new InvalidOperationException("An interactive terminal is required to enter the SSH password."); + WindowsTerminalInputMode.ResetTerminalState(); + TerminalLine.Align(); + Console.Write("SSH password: "); + var password = ReadPassword(); + Console.WriteLine(); + passwordResponse = await http.PostAsJsonAsync(endpoint, + new PasswordSessionRequest(password, columns, rows), ClientJsonContext.Default.PasswordSessionRequest); + } + using var response = passwordResponse; + var result = response ?? initial; + if (result.StatusCode == HttpStatusCode.Forbidden) + throw new InvalidOperationException("This authentik identity has no enabled grant for the target."); + if (result.StatusCode == HttpStatusCode.Conflict) + throw new InvalidOperationException("This identity is already connected from another Client installation."); + result.EnsureSuccessStatusCode(); + using var document = await JsonDocument.ParseAsync(await result.Content.ReadAsStreamAsync()); + return document.RootElement.GetProperty("id").GetGuid(); + } + + private static async Task RequiresPasswordAsync(HttpResponseMessage response) + { + using var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync()); + return document.RootElement.TryGetProperty("error", out var error) && + error.GetString() == "password_required"; + } + + private static async Task CreateLocalAsync(HttpClient http, Uri origin, bool interactive) + { + if (Console.IsInputRedirected) + throw new InvalidOperationException("An interactive terminal is required to enter the SSH password."); + WindowsTerminalInputMode.ResetTerminalState(); + TerminalLine.Align(); + Console.Write("SSH password: "); + var password = ReadPassword(); + Console.WriteLine(); + var (columns, rows) = interactive ? GetConsoleSize() : (80, 24); + using var response = await http.PostAsJsonAsync(new Uri(origin, "/local/session"), + new PasswordSessionRequest(password, columns, rows), ClientJsonContext.Default.PasswordSessionRequest); + response.EnsureSuccessStatusCode(); + using var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync()); + return document.RootElement.GetProperty("id").GetGuid(); + } + + private static async Task RunInteractiveAsync(Uri origin, Guid sessionId, string? accessToken, + string path, string? deviceCredential) + { + using var socket = await OpenSessionSocketAsync(origin, sessionId, accessToken, path, + deviceCredential: deviceCredential); + await SendResizeAsync(socket, GetConsoleSize(), CancellationToken.None); + + using var terminalInput = WindowsTerminalInputMode.TryEnable(); + var previousControlC = Console.TreatControlCAsInput; + Console.TreatControlCAsInput = true; + using var closing = new CancellationTokenSource(); + var mouseTracking = new TerminalMouseTracking(); + string? exitMessage = null; + try + { + Console.WriteLine(terminalInput is null + ? "Connected. Ctrl+] or F12 detaches from this workspace. Mouse input requires VT input support." + : "Connected. Ctrl+] detaches from this workspace; mouse input is forwarded."); + var receiver = ReceiveShellOutputAsync(socket, mouseTracking, closing.Token); + try + { + if (terminalInput is not null) + await SendRawInputAsync(socket, receiver, mouseTracking, closing.Token); + else + await SendShellInputAsync(socket, receiver, closing.Token); + exitMessage = receiver.IsCompleted + ? (await receiver) switch + { + "ssh_closed" => "SSH connection closed; the workspace has ended.", + "workspace_closed" => "Workspace closed by Server.", + _ => "Server connection closed." + } + : "Detached from workspace."; + } + finally + { + closing.Cancel(); + if (socket.State is WebSocketState.Open or WebSocketState.CloseReceived) + await socket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "Detached", CancellationToken.None); + try { await receiver; } + catch (OperationCanceledException) when (closing.IsCancellationRequested) { } + catch (WebSocketException) when (closing.IsCancellationRequested) { } + } + } + finally + { + Console.TreatControlCAsInput = previousControlC; + WindowsTerminalInputMode.ResetTerminalState(mouseTracking.InAlternateScreen); + if (exitMessage is null) + TerminalLine.Align(); + else + TerminalLine.WriteStatus(exitMessage); + } + } + + private static async Task ReceiveShellOutputAsync(ClientWebSocket socket, TerminalMouseTracking mouseTracking, + CancellationToken cancellationToken) + { + var buffer = new byte[16384]; + var output = Console.OpenStandardOutput(); + long? lastSequence = null; + while (true) + { + var frame = await socket.ReceiveAsync(buffer, cancellationToken); + if (frame.MessageType == WebSocketMessageType.Close) + return "server_disconnected"; + if (frame.MessageType != WebSocketMessageType.Text || !frame.EndOfMessage) + throw new InvalidOperationException("The workspace WebSocket sent an invalid frame."); + using var message = JsonDocument.Parse(buffer.AsMemory(0, frame.Count)); + var type = message.RootElement.GetProperty("type").GetString(); + if (type == "ended") + return message.RootElement.GetProperty("reason").GetString() ?? "server_disconnected"; + if (type != "output") + continue; + var sequence = message.RootElement.GetProperty("sequence").GetInt64(); + if (lastSequence is { } previous && sequence != previous + 1) + throw new InvalidOperationException("SSH output sequence gap; the terminal view needs a fresh session."); + lastSequence = sequence; + var data = Convert.FromBase64String(message.RootElement.GetProperty("data").GetString() ?? ""); + mouseTracking.ObserveOutput(data); + await output.WriteAsync(data, cancellationToken); + await output.FlushAsync(cancellationToken); + } + } + + private static async Task SendShellInputAsync(ClientWebSocket socket, Task receiver, + CancellationToken cancellationToken) + { + var size = GetConsoleSize(); + while (!receiver.IsCompleted && !cancellationToken.IsCancellationRequested) + { + var nextSize = GetConsoleSize(); + if (nextSize != size) + { + await SendResizeAsync(socket, nextSize, cancellationToken); + size = nextSize; + } + if (!Console.KeyAvailable) + { + await Task.Delay(30, cancellationToken); + continue; + } + var key = Console.ReadKey(intercept: true); + if (key.Key == ConsoleKey.F12 || key.KeyChar == '\u001d' || + key.Key == ConsoleKey.Oem6 && key.Modifiers.HasFlag(ConsoleModifiers.Control)) + return; + var input = EncodeShellKey(key); + if (input.Length != 0) + await SendInputAsync(socket, input); + } + } + + private static async Task SendRawInputAsync(ClientWebSocket socket, Task receiver, + TerminalMouseTracking mouseTracking, + CancellationToken cancellationToken) + { + using var sendLock = new SemaphoreSlim(1, 1); + using var resizeStop = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var resize = WatchConsoleSizeAsync(socket, sendLock, resizeStop.Token); + try + { + var input = Console.OpenStandardInput(); + var buffer = new byte[8192]; + Task? pendingRead = null; + while (!receiver.IsCompleted && !cancellationToken.IsCancellationRequested) + { + pendingRead ??= input.ReadAsync(buffer, cancellationToken).AsTask(); + var completed = mouseTracking.HasPendingInput + ? await Task.WhenAny(pendingRead, receiver, Task.Delay(30, cancellationToken)) + : await Task.WhenAny(pendingRead, receiver); + if (completed == receiver) + return; + if (completed != pendingRead) + { + var pending = mouseTracking.FlushPendingInput(); + if (pending.Length > 0) + { + await sendLock.WaitAsync(cancellationToken); + try { await SendInputAsync(socket, pending); } + finally { sendLock.Release(); } + } + continue; + } + var count = await pendingRead; + pendingRead = null; + if (count == 0) + return; + var detach = buffer.AsSpan(0, count).IndexOf((byte)0x1d); + var length = detach >= 0 ? detach : count; + if (length > 0) + { + var filtered = mouseTracking.FilterInput(buffer.AsSpan(0, length)); + if (filtered.Length > 0) + { + await sendLock.WaitAsync(cancellationToken); + try { await SendInputAsync(socket, filtered); } + finally { sendLock.Release(); } + } + } + if (detach >= 0) + return; + } + } + finally + { + resizeStop.Cancel(); + try { await resize; } + catch (OperationCanceledException) when (resizeStop.IsCancellationRequested) { } + } + } + + private static async Task WatchConsoleSizeAsync(ClientWebSocket socket, SemaphoreSlim sendLock, + CancellationToken cancellationToken) + { + var size = GetConsoleSize(); + while (true) + { + await Task.Delay(100, cancellationToken); + var next = GetConsoleSize(); + if (next == size) + continue; + await sendLock.WaitAsync(cancellationToken); + try { await SendResizeAsync(socket, next, cancellationToken); } + finally { sendLock.Release(); } + size = next; + } + } + + private static Task SendResizeAsync(ClientWebSocket socket, (int Columns, int Rows) size, + CancellationToken cancellationToken) + { + var message = JsonSerializer.SerializeToUtf8Bytes( + new ResizeFrame("resize", size.Columns, size.Rows), ClientJsonContext.Default.ResizeFrame); + return socket.SendAsync(message, WebSocketMessageType.Text, true, cancellationToken); + } + + private static byte[] EncodeShellKey(ConsoleKeyInfo key) + { + var escape = key.Key switch + { + ConsoleKey.UpArrow => "\u001b[A", + ConsoleKey.DownArrow => "\u001b[B", + ConsoleKey.RightArrow => "\u001b[C", + ConsoleKey.LeftArrow => "\u001b[D", + ConsoleKey.Home => "\u001b[H", + ConsoleKey.End => "\u001b[F", + ConsoleKey.Delete => "\u001b[3~", + ConsoleKey.PageUp => "\u001b[5~", + ConsoleKey.PageDown => "\u001b[6~", + _ => null + }; + if (escape is not null) + return Encoding.ASCII.GetBytes(escape); + if (key.Key == ConsoleKey.Enter) + return [13]; + if (key.Key == ConsoleKey.Backspace) + return [127]; + if (key.KeyChar != 0) + return Encoding.UTF8.GetBytes(key.KeyChar.ToString()); + if (key.Modifiers.HasFlag(ConsoleModifiers.Control) && key.Key is >= ConsoleKey.A and <= ConsoleKey.Z) + return [(byte)(key.Key - ConsoleKey.A + 1)]; + return []; + } + + private static (int Columns, int Rows) GetConsoleSize() + { + if (Console.IsOutputRedirected) + return (80, 24); + try + { + return (Math.Clamp(Console.WindowWidth, 1, 500), Math.Clamp(Console.WindowHeight, 1, 200)); + } + catch (IOException) + { + return (80, 24); + } + } + + internal static async Task OpenSessionSocketAsync(Uri origin, Guid sessionId, + string? accessToken, string path, CancellationToken cancellationToken = default, + string? deviceCredential = null) + { + if (!Program.TryGetServerUri(origin.ToString(), out _) || sessionId == Guid.Empty || + path is not ("/session" or "/local/session")) + throw new InvalidOperationException("Invalid workspace endpoint."); + if (path == "/session" && string.IsNullOrWhiteSpace(accessToken)) + throw new InvalidOperationException("An OIDC access token is required for a managed workspace."); + if (path == "/session" && string.IsNullOrWhiteSpace(deviceCredential)) + throw new InvalidOperationException("A client device credential is required for a managed workspace."); + if (path == "/local/session" && (!origin.IsLoopback || accessToken is not null)) + throw new InvalidOperationException("Local probe requires a loopback endpoint without an OIDC token."); + var socket = new ClientWebSocket(); + try + { + if (accessToken is not null) + socket.Options.SetRequestHeader("Authorization", "Bearer " + accessToken); + if (deviceCredential is not null) + socket.Options.SetRequestHeader(ClientDeviceCredential.HeaderName, deviceCredential); + var endpoint = new UriBuilder(origin) + { + Scheme = origin.Scheme == Uri.UriSchemeHttps ? "wss" : "ws", + Path = path, + Query = $"id={sessionId}" + }.Uri; + await socket.ConnectAsync(endpoint, cancellationToken); + return socket; + } + catch + { + socket.Dispose(); + throw; + } + } + + private static async Task RunLsAsync(Uri origin, Guid sessionId, string? accessToken, + string path, string? deviceCredential) + { + using var socket = await OpenSessionSocketAsync(origin, sessionId, accessToken, path, + deviceCredential: deviceCredential); + var marker = Guid.NewGuid().ToString("N"); + var begin = "RAC_BEGIN_" + marker; + var end = "RAC_END_" + marker + ":"; + await SendInputAsync(socket, "stty -echo\n"); + await SendInputAsync(socket, + $"printf '\\n{begin}\\n'; ls; result=$?; printf '\\n{end}%s\\n' \"$result\"\n"); + + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + using var received = new MemoryStream(); + var buffer = new byte[16384]; + while (true) + { + var frame = await socket.ReceiveAsync(buffer, timeout.Token); + if (frame.MessageType != WebSocketMessageType.Text || !frame.EndOfMessage) + throw new InvalidOperationException("The workspace WebSocket closed or sent an invalid frame."); + using var message = JsonDocument.Parse(buffer.AsMemory(0, frame.Count)); + if (message.RootElement.GetProperty("type").GetString() != "output") + continue; + var data = Convert.FromBase64String(message.RootElement.GetProperty("data").GetString() ?? ""); + received.Write(data); + if (received.Length > 256 * 1024) + throw new InvalidOperationException("Linux ls output exceeded the CLI limit."); + var text = Encoding.UTF8.GetString(received.GetBuffer().AsSpan(0, (int)received.Length)); + var finish = text.IndexOf(end, StringComparison.Ordinal); + while (finish >= 0 && (text.Length <= finish + end.Length || + !char.IsAsciiDigit(text[finish + end.Length]))) + finish = text.IndexOf(end, finish + end.Length, StringComparison.Ordinal); + if (finish < 0) + continue; + var start = text.LastIndexOf(begin, finish, StringComparison.Ordinal); + if (start < 0) + continue; + var listing = text[(start + begin.Length)..finish].Trim(); + Console.WriteLine("Remote ls output:"); + Console.WriteLine(listing.Length == 0 ? "(empty directory)" : listing); + if (text[finish + end.Length] != '0') + throw new InvalidOperationException("Linux ls returned a nonzero exit code."); + await socket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "Detached", CancellationToken.None); + return; + } + } + + private static Task SendInputAsync(ClientWebSocket socket, string input) => + SendInputAsync(socket, Encoding.UTF8.GetBytes(input)); + + private static Task SendInputAsync(ClientWebSocket socket, ReadOnlyMemory input) + { + var message = JsonSerializer.SerializeToUtf8Bytes( + new InputFrame("input", Convert.ToBase64String(input.Span)), ClientJsonContext.Default.InputFrame); + return socket.SendAsync(message, WebSocketMessageType.Text, true, CancellationToken.None); + } + + private static string ReadPassword() + { + var characters = new List(); + try + { + while (true) + { + var key = Console.ReadKey(intercept: true); + if (key.Key == ConsoleKey.Enter) + return new string(characters.ToArray()); + if (key.Key == ConsoleKey.Backspace) + { + if (characters.Count > 0) + characters.RemoveAt(characters.Count - 1); + } + else if (!char.IsControl(key.KeyChar)) + characters.Add(key.KeyChar); + } + } + finally + { + for (var index = 0; index < characters.Count; index++) + characters[index] = '\0'; + } + } +} diff --git a/src/client/packages.lock.json b/src/client/packages.lock.json new file mode 100644 index 0000000..9c252f0 --- /dev/null +++ b/src/client/packages.lock.json @@ -0,0 +1,11 @@ +{ + "version": 2, + "dependencies": { + "net10.0": {}, + "net10.0/linux-arm64": {}, + "net10.0/linux-bionic-arm64": {}, + "net10.0/linux-x64": {}, + "net10.0/win-arm64": {}, + "net10.0/win-x64": {} + } +} \ No newline at end of file diff --git a/src/packages.lock.json b/src/packages.lock.json deleted file mode 100644 index 56c0a7c..0000000 --- a/src/packages.lock.json +++ /dev/null @@ -1,55 +0,0 @@ -{ - "version": 2, - "dependencies": { - "net10.0": { - "Microsoft.Extensions.Hosting.Systemd": { - "type": "Direct", - "requested": "[10.0.12, )", - "resolved": "10.0.12", - "contentHash": "WC1u8RbTqJTY/JknZFHX0+TDptgtpoBRc8Kro0xPSwunMql3e1zBxnK+OJbx30ozSCfMpHaYAfaeiVQgM5sizw==" - }, - "Microsoft.Extensions.Hosting.WindowsServices": { - "type": "Direct", - "requested": "[10.0.12, )", - "resolved": "10.0.12", - "contentHash": "Za5xCWb7jkHd7+hYu8C22uIf0DGhNASIiXo1xSU450nj01RFCerP06h1Bh3kxUm+5DijfgP0gtpY6v+oI/UI4g==", - "dependencies": { - "System.ServiceProcess.ServiceController": "10.0.12" - } - }, - "System.ServiceProcess.ServiceController": { - "type": "Transitive", - "resolved": "10.0.12", - "contentHash": "sFV+RM/yPtshiCAEIuTm8Dt4F7VuKRbL50PnGC9nXHolk/jw+z3T8zx7axKTBC8mqtQU2ZmjsoPC9b1iwOilNQ==" - } - }, - "net10.0/linux-arm64": { - "System.ServiceProcess.ServiceController": { - "type": "Transitive", - "resolved": "10.0.12", - "contentHash": "sFV+RM/yPtshiCAEIuTm8Dt4F7VuKRbL50PnGC9nXHolk/jw+z3T8zx7axKTBC8mqtQU2ZmjsoPC9b1iwOilNQ==" - } - }, - "net10.0/linux-x64": { - "System.ServiceProcess.ServiceController": { - "type": "Transitive", - "resolved": "10.0.12", - "contentHash": "sFV+RM/yPtshiCAEIuTm8Dt4F7VuKRbL50PnGC9nXHolk/jw+z3T8zx7axKTBC8mqtQU2ZmjsoPC9b1iwOilNQ==" - } - }, - "net10.0/win-arm64": { - "System.ServiceProcess.ServiceController": { - "type": "Transitive", - "resolved": "10.0.12", - "contentHash": "sFV+RM/yPtshiCAEIuTm8Dt4F7VuKRbL50PnGC9nXHolk/jw+z3T8zx7axKTBC8mqtQU2ZmjsoPC9b1iwOilNQ==" - } - }, - "net10.0/win-x64": { - "System.ServiceProcess.ServiceController": { - "type": "Transitive", - "resolved": "10.0.12", - "contentHash": "sFV+RM/yPtshiCAEIuTm8Dt4F7VuKRbL50PnGC9nXHolk/jw+z3T8zx7axKTBC8mqtQU2ZmjsoPC9b1iwOilNQ==" - } - } - } -} \ No newline at end of file diff --git a/src/Authentication/ExternalIdentity.cs b/src/server/Authentication/ExternalIdentity.cs similarity index 90% rename from src/Authentication/ExternalIdentity.cs rename to src/server/Authentication/ExternalIdentity.cs index be02d1e..b1287d3 100644 --- a/src/Authentication/ExternalIdentity.cs +++ b/src/server/Authentication/ExternalIdentity.cs @@ -1,4 +1,4 @@ -namespace WorkspaceAccessHost.Authentication; +namespace Charac.Server.Authentication; /// An identity obtained from a validated OIDC authentication result. public sealed record ExternalIdentity diff --git a/src/server/Authentication/OidcSettings.cs b/src/server/Authentication/OidcSettings.cs new file mode 100644 index 0000000..98c1757 --- /dev/null +++ b/src/server/Authentication/OidcSettings.cs @@ -0,0 +1,25 @@ +namespace Charac.Server.Authentication; + +internal sealed record OidcSettings(string Issuer, string ClientId, string DiscoveryUrl) +{ + public static OidcSettings? FromConfiguration(IConfiguration configuration) + { + var issuer = configuration["oidc:issuer"]?.Trim(); + var clientId = configuration["oidc:client_id"]?.Trim(); + var discovery = configuration["oidc:discovery_url"]?.Trim(); + if (string.IsNullOrWhiteSpace(issuer) && string.IsNullOrWhiteSpace(clientId) && + string.IsNullOrWhiteSpace(discovery)) + return null; + if (string.IsNullOrWhiteSpace(clientId) || + !Uri.TryCreate(issuer, UriKind.Absolute, out var uri) || + uri.Scheme != Uri.UriSchemeHttps || uri.UserInfo.Length != 0 || + uri.Query.Length != 0 || uri.Fragment.Length != 0) + throw new InvalidOperationException("Configure [oidc] issuer as an HTTPS URL and client_id as a non-empty value."); + discovery ??= issuer!.TrimEnd('/') + "/.well-known/openid-configuration"; + if (!Uri.TryCreate(discovery, UriKind.Absolute, out var metadataUri) || + metadataUri.Scheme != Uri.UriSchemeHttps || metadataUri.UserInfo.Length != 0 || + metadataUri.Query.Length != 0 || metadataUri.Fragment.Length != 0) + throw new InvalidOperationException("[oidc] discovery_url must be an HTTPS URL."); + return new OidcSettings(issuer!, clientId, discovery); + } +} diff --git a/src/server/Authentication/SshAccessDecision.cs b/src/server/Authentication/SshAccessDecision.cs new file mode 100644 index 0000000..0bffaa8 --- /dev/null +++ b/src/server/Authentication/SshAccessDecision.cs @@ -0,0 +1,17 @@ +namespace Charac.Server.Authentication; + +/// A point-in-time authorization result, not a client token or a reusable authorization cache. +internal sealed record SshAccessDecision( + Guid GrantId, + Guid TargetId, + string Address, + int Port, + string Account, + string? CertificatePrincipal, + Guid? UserCertificateAuthorityId, + string? UserCertificateAuthorityPublicKey, + string? SigningKeyReference, + IReadOnlyList HostPublicKeys, + DateTimeOffset? GrantExpiresAt, + Guid? SshLoginKeyId, + string? SshLoginKeyFileName); diff --git a/src/server/Authentication/SshAccessResolver.cs b/src/server/Authentication/SshAccessResolver.cs new file mode 100644 index 0000000..d855bb6 --- /dev/null +++ b/src/server/Authentication/SshAccessResolver.cs @@ -0,0 +1,54 @@ +using Microsoft.EntityFrameworkCore; +using Charac.Server.Data; +using Charac.Server.Data.Entities; + +namespace Charac.Server.Authentication; + +internal sealed record SshTargetResource(Guid Id, string Name, string Account); + +/// Queries current grants only after OIDC validation. +internal sealed class SshAccessResolver(AccessDbContext database, TimeProvider clock) +{ + public Task ListTargetsAsync(ExternalIdentity identity, + CancellationToken cancellationToken = default) => + AuthorizedGrants(identity).OrderBy(x => x.Target.Name).ThenBy(x => x.TargetId) + .Select(x => new SshTargetResource(x.TargetId, x.Target.Name, x.Account)) + .ToArrayAsync(cancellationToken); + + public async Task ResolveAsync( + ExternalIdentity identity, Guid targetId, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(identity); + if (targetId == Guid.Empty) + return null; + + var grant = await AuthorizedGrants(identity).AsSingleQuery() + .Include(x => x.SshLoginKey) + .Include(x => x.Target).ThenInclude(x => x.UserCertificateAuthority) + .Include(x => x.Target).ThenInclude(x => x.HostKeys.Where(key => key.Enabled)) + .Where(x => x.TargetId == targetId) + .SingleOrDefaultAsync(cancellationToken); + + if (grant is null) + return null; + + var target = grant.Target; + return new SshAccessDecision(grant.Id, target.Id, target.Address, target.Port, + grant.Account, grant.CertificatePrincipal, target.UserCertificateAuthorityId, + target.UserCertificateAuthority?.PublicKey, target.UserCertificateAuthority?.SigningKeyReference, + target.HostKeys.Select(x => x.PublicKey).ToArray(), grant.ExpiresAt, + grant.SshLoginKeyId, grant.SshLoginKey?.FileName); + } + + private IQueryable AuthorizedGrants(ExternalIdentity identity) + { + ArgumentNullException.ThrowIfNull(identity); + var now = clock.GetUtcNow(); + return database.Grants.AsNoTracking().Where(x => + x.Identity.Issuer == identity.Issuer && x.Identity.Subject == identity.Subject && + x.Enabled && x.Identity.Enabled && x.Target.Enabled && + (x.SshLoginKeyId == null || x.SshLoginKey!.Enabled) && + (x.ExpiresAt == null || x.ExpiresAt > now) && + x.Target.HostKeys.Any(key => key.Enabled)); + } +} diff --git a/src/server/Connections/ActiveClientDevices.cs b/src/server/Connections/ActiveClientDevices.cs new file mode 100644 index 0000000..b0154bb --- /dev/null +++ b/src/server/Connections/ActiveClientDevices.cs @@ -0,0 +1,146 @@ +using System.Security.Cryptography; +using Charac.Server.Authentication; + +namespace Charac.Server.Connections; + +internal enum ClientDeviceStatus { Allowed, Invalid, Occupied } + +/// One active client installation per OIDC identity; several workspaces may share it. +internal sealed class ActiveClientDevices +{ + public const string HeaderName = "X-RAC-Device"; + private readonly Lock _sync = new(); + private readonly Dictionary _active = []; + private readonly HashSet _disconnecting = []; + + public ClientDeviceStatus Check(ExternalIdentity owner, string? credential) + { + if (!TryFingerprint(credential, out var fingerprint)) + return ClientDeviceStatus.Invalid; + lock (_sync) + return _disconnecting.Contains(owner) || + _active.TryGetValue(owner, out var active) && active.Fingerprint != fingerprint + ? ClientDeviceStatus.Occupied : ClientDeviceStatus.Allowed; + } + + public ClientDeviceStatus Acquire(ExternalIdentity owner, string? credential, out ClientDeviceLease? lease) + { + lease = null; + if (!TryFingerprint(credential, out var fingerprint)) + return ClientDeviceStatus.Invalid; + lock (_sync) + { + if (_disconnecting.Contains(owner)) + return ClientDeviceStatus.Occupied; + if (_active.TryGetValue(owner, out var active)) + { + if (active.Fingerprint != fingerprint) + return ClientDeviceStatus.Occupied; + active.Count++; + } + else + { + active = new ActiveDevice(fingerprint); + _active.Add(owner, active); + } + lease = new ClientDeviceLease(this, owner, active); + return ClientDeviceStatus.Allowed; + } + } + + public bool TryUse(ClientDeviceLease lease, Func action, out T? result) where T : class + { + lock (_sync) + { + if (lease.IsDisposed || _disconnecting.Contains(lease.Owner) || + !_active.TryGetValue(lease.Owner, out var current) || + !ReferenceEquals(current, lease.Device)) + { + result = null; + return false; + } + result = action(); + return true; + } + } + + public IDisposable BeginDisconnect(ExternalIdentity owner) + { + lock (_sync) + { + if (!_disconnecting.Add(owner)) + throw new InvalidOperationException("A disconnect operation is already running for this identity."); + _active.Remove(owner); + } + return new OperatorDisconnectLease(this, owner); + } + + private void FinishDisconnect(ExternalIdentity owner) + { + lock (_sync) + _disconnecting.Remove(owner); + } + + private void Release(ExternalIdentity owner, ActiveDevice active) + { + lock (_sync) + { + if (!_active.TryGetValue(owner, out var current) || !ReferenceEquals(current, active)) + return; + if (--current.Count == 0) + _active.Remove(owner); + } + } + + private static bool TryFingerprint(string? credential, out string fingerprint) + { + fingerprint = ""; + if (credential is not { Length: 43 } || credential.Any(ch => + !char.IsAsciiLetterOrDigit(ch) && ch is not '-' and not '_')) + return false; + try + { + var bytes = Convert.FromBase64String(credential.Replace('-', '+').Replace('_', '/') + "="); + if (bytes.Length != 32) + return false; + fingerprint = Convert.ToHexString(SHA256.HashData(bytes)); + return true; + } + catch (FormatException) + { + return false; + } + } + + internal sealed class ActiveDevice(string fingerprint) + { + public string Fingerprint { get; } = fingerprint; + public int Count { get; set; } = 1; + } + + internal sealed class ClientDeviceLease(ActiveClientDevices gate, ExternalIdentity owner, + ActiveDevice device) : IDisposable + { + private int _disposed; + internal ExternalIdentity Owner => owner; + internal ActiveDevice Device => device; + internal bool IsDisposed => Volatile.Read(ref _disposed) != 0; + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) == 0) + gate.Release(owner, device); + } + } + + private sealed class OperatorDisconnectLease(ActiveClientDevices gate, ExternalIdentity owner) : IDisposable + { + private int _disposed; + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) == 0) + gate.FinishDisconnect(owner); + } + } +} diff --git a/src/Connections/HostConnectionGate.cs b/src/server/Connections/WorkspaceConnectionGate.cs similarity index 83% rename from src/Connections/HostConnectionGate.cs rename to src/server/Connections/WorkspaceConnectionGate.cs index 9272772..a880e93 100644 --- a/src/Connections/HostConnectionGate.cs +++ b/src/server/Connections/WorkspaceConnectionGate.cs @@ -1,6 +1,6 @@ -using WorkspaceAccessHost.Authentication; +using Charac.Server.Authentication; -namespace WorkspaceAccessHost.Connections; +namespace Charac.Server.Connections; public enum AcquisitionStatus { @@ -27,12 +27,12 @@ public sealed class ConnectionLease public sealed record AcquisitionResult(AcquisitionStatus Status, ConnectionLease? Lease); /// -/// Serializes control of one host workspace. Workspace ownership survives transport disconnection. -/// Authentication and host authorization must succeed before calling Acquire. +/// Serializes control of one workspace. Workspace ownership survives transport disconnection. +/// Authentication and workspace authorization must succeed before calling Acquire. /// -public sealed class HostConnectionGate +public sealed class WorkspaceConnectionGate { - private readonly object _sync = new(); + private readonly Lock _sync = new(); private ExternalIdentity? _owner; private ConnectionLease? _active; private long _generation; @@ -105,6 +105,18 @@ public sealed class HostConnectionGate } } + /// Invalidate the current controller without releasing workspace ownership. + public bool DisconnectCurrent() + { + lock (_sync) + { + if (_active is null) + return false; + _active = null; + return true; + } + } + /// Release ownership after the session manager has terminated the workspace. public bool CloseWorkspace(ConnectionLease lease) { diff --git a/src/server/Data/AccessDbContext.cs b/src/server/Data/AccessDbContext.cs new file mode 100644 index 0000000..b04da59 --- /dev/null +++ b/src/server/Data/AccessDbContext.cs @@ -0,0 +1,188 @@ +using Microsoft.EntityFrameworkCore; +using Charac.Server.Data.Entities; + +namespace Charac.Server.Data; + +internal sealed class AccessDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Identities => Set(); + public DbSet UserCertificateAuthorities => Set(); + public DbSet Targets => Set(); + public DbSet HostKeys => Set(); + public DbSet LoginKeys => Set(); + public DbSet WorkspaceRecords => Set(); + public DbSet Grants => Set(); + + protected override void OnModelCreating(ModelBuilder model) + { + model.HasDefaultSchema("access"); + + model.Entity(entity => + { + entity.ToTable("identities"); + entity.HasKey(x => x.Id); + entity.Property(x => x.Issuer).HasMaxLength(512); + entity.Property(x => x.Subject).HasMaxLength(256); + entity.HasIndex(x => new { x.Issuer, x.Subject }).IsUnique(); + }); + + model.Entity(entity => + { + entity.ToTable("user_certificate_authorities"); + entity.HasKey(x => x.Id); + entity.Property(x => x.Name).HasMaxLength(128); + entity.Property(x => x.PublicKey).HasMaxLength(2048); + entity.Property(x => x.SigningKeyReference).HasMaxLength(1024); + entity.HasIndex(x => x.Name).IsUnique(); + }); + + model.Entity(entity => + { + entity.ToTable("targets"); + entity.HasKey(x => x.Id); + entity.Property(x => x.Name).HasMaxLength(128); + entity.Property(x => x.Address).HasMaxLength(253); + entity.HasIndex(x => x.Name).IsUnique(); + entity.HasOne(x => x.UserCertificateAuthority).WithMany() + .HasForeignKey(x => x.UserCertificateAuthorityId).OnDelete(DeleteBehavior.Restrict); + }); + + model.Entity(entity => + { + entity.ToTable("host_keys"); + entity.HasKey(x => x.Id); + entity.Property(x => x.PublicKey).HasMaxLength(2048); + entity.HasIndex(x => new { x.TargetId, x.PublicKey }).IsUnique(); + entity.HasOne(x => x.Target).WithMany(x => x.HostKeys) + .HasForeignKey(x => x.TargetId).OnDelete(DeleteBehavior.Restrict); + }); + + model.Entity(entity => + { + entity.ToTable("ssh_login_keys"); + entity.HasKey(x => x.Id); + entity.Property(x => x.Name).HasMaxLength(128); + entity.Property(x => x.FileName).HasMaxLength(255); + entity.HasIndex(x => x.Name).IsUnique(); + entity.HasIndex(x => x.FileName).IsUnique(); + }); + + model.Entity(entity => + { + entity.ToTable("workspace_records"); + entity.HasKey(x => x.Id); + entity.Property(x => x.Issuer).HasMaxLength(512); + entity.Property(x => x.Subject).HasMaxLength(256); + entity.Property(x => x.Account).HasMaxLength(256); + entity.Property(x => x.Authentication).HasMaxLength(16); + entity.Property(x => x.EndReason).HasMaxLength(32); + entity.HasIndex(x => new { x.Issuer, x.Subject, x.OpenedAt }); + }); + + model.Entity(entity => + { + entity.ToTable("grants"); + entity.HasKey(x => x.Id); + entity.Property(x => x.Account).HasMaxLength(256); + entity.Property(x => x.CertificatePrincipal).HasMaxLength(256); + entity.HasIndex(x => new { x.IdentityId, x.TargetId }).IsUnique(); + entity.HasOne(x => x.Identity).WithMany() + .HasForeignKey(x => x.IdentityId).OnDelete(DeleteBehavior.Restrict); + entity.HasOne(x => x.Target).WithMany() + .HasForeignKey(x => x.TargetId).OnDelete(DeleteBehavior.Restrict); + entity.HasOne(x => x.SshLoginKey).WithMany() + .HasForeignKey(x => x.SshLoginKeyId).OnDelete(DeleteBehavior.Restrict); + }); + } + + public override int SaveChanges(bool acceptAllChangesOnSuccess) + { + ValidateChanges(); + return base.SaveChanges(acceptAllChangesOnSuccess); + } + + public override Task SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default) + { + ValidateChanges(); + return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken); + } + + private void ValidateChanges() + { + foreach (var entry in ChangeTracker.Entries().Where(x => x.State is EntityState.Added or EntityState.Modified)) + { + switch (entry.Entity) + { + case AccessIdentity identity: + RequireId(identity.Id); + RequireText(identity.Issuer, 512); + RequireText(identity.Subject, 256); + break; + case UserCertificateAuthority authority: + RequireId(authority.Id); + RequireText(authority.Name, 128); + RequireText(authority.PublicKey, 2048); + RequireText(authority.SigningKeyReference, 1024); + break; + case SshTarget target: + RequireId(target.Id); + if (target.UserCertificateAuthorityId is { } authorityId) + RequireId(authorityId); + RequireText(target.Name, 128); + RequireText(target.Address, 253); + if (target.Port is < 1 or > 65535) + throw new InvalidOperationException("SSH port must be between 1 and 65535."); + break; + case SshHostKey key: + RequireId(key.Id); + RequireId(key.TargetId); + RequireText(key.PublicKey, 2048); + break; + case SshLoginKey loginKey: + RequireId(loginKey.Id); + RequireText(loginKey.Name, 128); + RequireText(loginKey.FileName, 255); + if (!Charac.Server.Ssh.SshKeyStore.IsValidFileName(loginKey.FileName)) + throw new InvalidOperationException("SSH login key file name must be a plain file name."); + break; + case WorkspaceRecord workspace: + RequireId(workspace.Id); + RequireId(workspace.TargetId); + RequireText(workspace.Issuer, 512); + RequireText(workspace.Subject, 256); + RequireText(workspace.Account, 256); + RequireText(workspace.Authentication, 16); + if (workspace.EndReason is not null) + RequireText(workspace.EndReason, 32); + if (workspace.OpenedAt.Offset != TimeSpan.Zero || + workspace.ClosedAt is { Offset: var closedOffset } && closedOffset != TimeSpan.Zero) + throw new InvalidOperationException("Workspace timestamps must use UTC."); + break; + case AccessGrant grant: + RequireId(grant.Id); + RequireId(grant.IdentityId); + RequireId(grant.TargetId); + if (grant.SshLoginKeyId is { } loginKeyId) + RequireId(loginKeyId); + RequireText(grant.Account, 256); + if (grant.CertificatePrincipal is not null) + RequireText(grant.CertificatePrincipal, 256); + if (grant.ExpiresAt is { Offset: var offset } && offset != TimeSpan.Zero) + throw new InvalidOperationException("Grant expiry must use UTC."); + break; + } + } + } + + private static void RequireId(Guid value) + { + if (value == Guid.Empty) + throw new InvalidOperationException("Management record IDs must not be empty."); + } + + private static void RequireText(string value, int maxLength) + { + if (string.IsNullOrWhiteSpace(value) || value.Length > maxLength) + throw new InvalidOperationException("A management field is empty or exceeds its length limit."); + } +} diff --git a/src/server/Data/AccessDbContextFactory.cs b/src/server/Data/AccessDbContextFactory.cs new file mode 100644 index 0000000..d2dfde4 --- /dev/null +++ b/src/server/Data/AccessDbContextFactory.cs @@ -0,0 +1,20 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; +using Charac.Server.Hosting; + +namespace Charac.Server.Data; + +/// EF tooling entry point; the runtime uses the same provider configuration. +internal sealed class AccessDbContextFactory : IDesignTimeDbContextFactory +{ + public AccessDbContext CreateDbContext(string[] args) + { + var connection = DatabaseSettings.ConnectionString(DatabaseSettings.LoadConfiguration(ServerCommandLine.Parse(args).ConfigPath)) + ?? throw new InvalidOperationException("Configure [database] host, name and username or WORKSPACE_ACCESS_DATABASE."); + var options = new DbContextOptionsBuilder(); + DatabaseSettings.Configure(options, connection); + var database = new AccessDbContext(options.Options); + database.Database.SetCommandTimeout(TimeSpan.FromMinutes(2)); + return database; + } +} diff --git a/src/server/Data/AccessRegistrationCommand.cs b/src/server/Data/AccessRegistrationCommand.cs new file mode 100644 index 0000000..314f6ad --- /dev/null +++ b/src/server/Data/AccessRegistrationCommand.cs @@ -0,0 +1,194 @@ +using System.Security.Cryptography; +using Microsoft.EntityFrameworkCore; +using Renci.SshNet.Common; +using Charac.Server.Authentication; +using Charac.Server.Data.Entities; +using Charac.Server.Hosting; +using Charac.Server.Ssh; + +namespace Charac.Server.Data; + +/// Local administrator commands for persistent SSH targets and OIDC grants. +internal static class AccessRegistrationCommand +{ + public static async Task RunAsync(string[] args, string? configPath) + { + try + { + var configuration = WorkspaceAccessConfiguration.Load(configPath); + var connection = DatabaseSettings.ConnectionString(configuration) + ?? throw new InvalidOperationException("Configure [database] before registering targets or grants."); + var options = new DbContextOptionsBuilder(); + DatabaseSettings.Configure(options, connection); + await using var database = new AccessDbContext(options.Options); + if ((await database.Database.GetPendingMigrationsAsync()).Any()) + throw new InvalidOperationException("Apply database migrations first."); + + return args switch + { + ["database", "target", "register", var name, var address, var portText, var hostKeyFile] + when int.TryParse(portText, out var port) => + await RegisterTargetAsync(database, name, address, port, ReadHostKey(hostKeyFile)), + ["database", "target", "list"] => await ListTargetsAsync(database), + ["database", "grant", "register", var target, var subject, var account] => + await RegisterGrantAsync(database, configuration, target, subject, account, null), + ["database", "grant", "register", var target, var subject, var account, var keyName] => + await RegisterGrantAsync(database, configuration, target, subject, account, keyName), + ["database", "grant", "list"] => await ListGrantsAsync(database), + _ => throw new ArgumentException("Use database target register , target list, grant register [], or grant list.") + }; + } + catch (Exception exception) when (exception is ArgumentException or InvalidOperationException or + IOException or UnauthorizedAccessException or System.Data.Common.DbException or DbUpdateException or + SshException) + { + Console.Error.WriteLine($"Access registration failed: {exception.Message}"); + return 1; + } + } + + internal static async Task RegisterTargetAsync(AccessDbContext database, string name, + string address, int port, string hostPublicKey, bool announce = true) + { + RequireText(name, 128, "target name"); + RequireText(address, 253, "SSH address"); + if (Uri.CheckHostName(address) == UriHostNameType.Unknown || port is < 1 or > 65535) + throw new ArgumentException("Provide a valid SSH address and port."); + _ = new SshHostKeyTrust([hostPublicKey]); + + var existing = await database.Targets.Include(x => x.HostKeys) + .SingleOrDefaultAsync(x => x.Name == name); + if (existing is not null) + { + if (!existing.Enabled || existing.Address != address || existing.Port != port || + !existing.HostKeys.Any(key => key.Enabled && key.PublicKey == hostPublicKey)) + throw new InvalidOperationException("The target name is already registered with different or disabled connection details."); + if (announce) Console.WriteLine($"SSH target already registered: {existing.Id} {existing.Name}."); + return 0; + } + + var target = new SshTarget { Name = name, Address = address, Port = port, Enabled = true }; + target.HostKeys.Add(new SshHostKey + { + Target = target, TargetId = target.Id, PublicKey = hostPublicKey, Enabled = true + }); + database.Targets.Add(target); + await database.SaveChangesAsync(); + if (announce) Console.WriteLine($"SSH target registered: {target.Id} {target.Name} host_key={Fingerprint(hostPublicKey)}."); + return 0; + } + + internal static async Task RegisterGrantAsync(AccessDbContext database, + Microsoft.Extensions.Configuration.IConfiguration configuration, string targetName, + string subject, string account, string? keyName, bool announce = true) + { + var oidc = OidcSettings.FromConfiguration(configuration) + ?? throw new InvalidOperationException("Configure [oidc] before registering a grant."); + RequireText(targetName, 128, "target name"); + RequireText(subject, 256, "OIDC subject"); + RequireText(account, 256, "SSH account"); + if (keyName is not null) + RequireText(keyName, 128, "SSH login key name"); + + var target = await database.Targets.Include(x => x.HostKeys) + .SingleOrDefaultAsync(x => x.Name == targetName) + ?? throw new InvalidOperationException("Register the SSH target first."); + if (!target.Enabled || !target.HostKeys.Any(key => key.Enabled)) + throw new InvalidOperationException("The SSH target needs an enabled host key."); + SshLoginKey? loginKey = null; + if (keyName is not null) + { + loginKey = await database.LoginKeys.SingleOrDefaultAsync(x => x.Name == keyName && x.Enabled) + ?? throw new InvalidOperationException("Register and enable the SSH login key first."); + using var privateKey = new SshKeyStore(configuration).Load(loginKey.FileName); + } + + var identity = await database.Identities.SingleOrDefaultAsync(x => + x.Issuer == oidc.Issuer && x.Subject == subject); + if (identity is { Enabled: false }) + throw new InvalidOperationException("The OIDC identity is disabled."); + identity ??= new AccessIdentity { Issuer = oidc.Issuer, Subject = subject, Enabled = true }; + + var existing = await database.Grants.SingleOrDefaultAsync(x => + x.IdentityId == identity.Id && x.TargetId == target.Id); + if (existing is not null) + { + if (!existing.Enabled || existing.Account != account || existing.SshLoginKeyId != loginKey?.Id) + throw new InvalidOperationException("The identity already has a different or disabled grant for this target."); + if (announce) Console.WriteLine($"SSH grant already registered: {existing.Id} target={target.Name} account={account}."); + return 0; + } + + if (database.Entry(identity).State == EntityState.Detached) + database.Identities.Add(identity); + var grant = new AccessGrant + { + Identity = identity, IdentityId = identity.Id, + Target = target, TargetId = target.Id, + Account = account, SshLoginKey = loginKey, SshLoginKeyId = loginKey?.Id, + Enabled = true + }; + database.Grants.Add(grant); + await database.SaveChangesAsync(); + if (announce) Console.WriteLine($"SSH grant registered: {grant.Id} target={target.Name} account={account} key={keyName ?? "(password)"}."); + return 0; + } + + internal static string ReadHostKey(string fileName) + { + if (string.IsNullOrWhiteSpace(fileName)) + throw new ArgumentException("Provide a trusted host public key file."); + var file = new FileInfo(Path.GetFullPath(fileName)); + if (!file.Exists || file.Length is < 1 or > 8192 || (file.Attributes & FileAttributes.ReparsePoint) != 0) + throw new InvalidOperationException("The SSH host public key file is missing or unsafe."); + var lines = File.ReadAllLines(file.FullName).Select(line => line.Trim()) + .Where(line => line.Length > 0).ToArray(); + if (lines.Length != 1) + throw new InvalidOperationException("The SSH host public key file must contain exactly one key line."); + var fields = lines[0].Split((char[]?)null, 3, StringSplitOptions.RemoveEmptyEntries); + if (fields.Length < 2) + throw new InvalidOperationException("The SSH host public key line is incomplete."); + var publicKey = $"{fields[0]} {fields[1]}"; + if (publicKey.Length > 2048) + throw new InvalidOperationException("The SSH host public key is too long."); + _ = new SshHostKeyTrust([publicKey]); + return publicKey; + } + + private static async Task ListTargetsAsync(AccessDbContext database) + { + var targets = await database.Targets.AsNoTracking().Include(x => x.HostKeys) + .OrderBy(x => x.Name).ToArrayAsync(); + foreach (var target in targets) + { + Console.WriteLine($"{target.Id} {target.Name} address={target.Address} port={target.Port} enabled={target.Enabled}"); + foreach (var key in target.HostKeys.OrderBy(x => x.Id)) + Console.WriteLine($" host_key={Fingerprint(key.PublicKey)} enabled={key.Enabled}"); + } + Console.WriteLine($"Targets: {targets.Length}."); + return 0; + } + + private static async Task ListGrantsAsync(AccessDbContext database) + { + var grants = await database.Grants.AsNoTracking().Include(x => x.Target) + .Include(x => x.Identity).Include(x => x.SshLoginKey) + .OrderBy(x => x.Target.Name).ThenBy(x => x.Identity.Subject).ToArrayAsync(); + foreach (var grant in grants) + Console.WriteLine($"{grant.Id} target={grant.Target.Name} issuer={grant.Identity.Issuer} subject={grant.Identity.Subject} account={grant.Account} key={grant.SshLoginKey?.Name ?? "(password)"} enabled={grant.Enabled}"); + Console.WriteLine($"Grants: {grants.Length}."); + return 0; + } + + internal static string Fingerprint(string publicKey) + { + var base64 = publicKey.Split(' ', StringSplitOptions.RemoveEmptyEntries)[1]; + return "SHA256:" + Convert.ToBase64String(SHA256.HashData(Convert.FromBase64String(base64))).TrimEnd('='); + } + + private static void RequireText(string value, int maximum, string field) + { + if (string.IsNullOrWhiteSpace(value) || value.Length > maximum || value.Any(char.IsControl)) + throw new ArgumentException($"The {field} must be nonempty and at most {maximum} characters."); + } +} diff --git a/src/server/Data/DatabaseCommands.cs b/src/server/Data/DatabaseCommands.cs new file mode 100644 index 0000000..58e4ec5 --- /dev/null +++ b/src/server/Data/DatabaseCommands.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore; + +namespace Charac.Server.Data; + +internal static class DatabaseCommands +{ + public static async Task MigrateAsync(string? configPath = null) + { + try + { + await using var database = new AccessDbContextFactory().CreateDbContext(configPath is null ? [] : ["--config", configPath]); + await database.Database.MigrateAsync(); + Console.WriteLine("Database migrations applied."); + return 0; + } + catch (Exception exception) when (exception is InvalidOperationException or System.Data.Common.DbException or ArgumentException) + { + Console.Error.WriteLine($"Database migration failed ({exception.GetType().Name}). Check connection configuration and migration permissions."); + return 1; + } + } +} diff --git a/src/server/Data/DatabaseReadiness.cs b/src/server/Data/DatabaseReadiness.cs new file mode 100644 index 0000000..7e908ce --- /dev/null +++ b/src/server/Data/DatabaseReadiness.cs @@ -0,0 +1,30 @@ +using Microsoft.EntityFrameworkCore; + +namespace Charac.Server.Data; + +internal sealed class DatabaseReadiness(IServiceScopeFactory scopes, IConfiguration configuration) +{ + public async Task CheckAsync(CancellationToken cancellationToken) + { + if (DatabaseSettings.ConnectionString(configuration) is null) + return "not_configured"; + + try + { + await using var scope = scopes.CreateAsyncScope(); + var database = scope.ServiceProvider.GetRequiredService(); + if (!await database.Database.CanConnectAsync(cancellationToken)) + return "unavailable"; + if ((await database.Database.GetPendingMigrationsAsync(cancellationToken)).Any()) + return "migration_required"; + await database.Grants.AsNoTracking().AnyAsync(grant => + grant.Identity.Enabled && grant.Target.Enabled && + grant.Target.HostKeys.Any(), cancellationToken); + return "ready"; + } + catch (Exception exception) when (exception is System.Data.Common.DbException or InvalidOperationException or ArgumentException) + { + return "unavailable"; + } + } +} diff --git a/src/server/Data/DatabaseSettings.cs b/src/server/Data/DatabaseSettings.cs new file mode 100644 index 0000000..38de4c8 --- /dev/null +++ b/src/server/Data/DatabaseSettings.cs @@ -0,0 +1,76 @@ +using Microsoft.EntityFrameworkCore; +using Npgsql; +using Charac.Server.Hosting; + +namespace Charac.Server.Data; + +internal static class DatabaseSettings +{ + public static string? ConnectionString(IConfiguration configuration) + { + var environmentValue = Environment.GetEnvironmentVariable("WORKSPACE_ACCESS_DATABASE"); + if (!string.IsNullOrWhiteSpace(environmentValue)) + return environmentValue; + + return ConnectionStringFromConfiguration(configuration); + } + + internal static string? ConnectionStringFromConfiguration(IConfiguration configuration) + { + var legacy = configuration["database:connection_string"]; + var host = configuration["database:host"]; + var port = configuration["database:port"]; + var database = configuration["database:name"]; + var username = configuration["database:username"]; + var password = configuration["database:password"]; + var timeout = configuration["database:timeout"]; + var maximumPoolSize = configuration["database:maximum_pool_size"]; + var hasFields = new[] { host, port, database, username, password, timeout, maximumPoolSize } + .Any(value => value is not null); + + if (!string.IsNullOrWhiteSpace(legacy)) + { + if (hasFields) + throw new InvalidOperationException("Use either [database] connection_string or separate database fields, not both."); + return legacy; + } + if (!hasFields) + return null; + if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(database) || + string.IsNullOrWhiteSpace(username)) + throw new InvalidOperationException("Configure [database] host, name and username."); + + var builder = new NpgsqlConnectionStringBuilder + { + Host = host.Trim(), + Database = database.Trim(), + Username = username.Trim() + }; + if (password is not null) + builder.Password = password; + if (port is not null) + builder.Port = PositiveNumber(port, "port", 65535); + if (timeout is not null) + builder.Timeout = PositiveNumber(timeout, "timeout"); + if (maximumPoolSize is not null) + builder.MaxPoolSize = PositiveNumber(maximumPoolSize, "maximum_pool_size"); + return builder.ConnectionString; + } + + private static int PositiveNumber(string value, string field, int maximum = int.MaxValue) + { + if (!int.TryParse(value, out var number) || number < 1 || number > maximum) + throw new InvalidOperationException($"[database] {field} must be an integer between 1 and {maximum}."); + return number; + } + + public static IConfiguration LoadConfiguration(string? configPath = null) => WorkspaceAccessConfiguration.Load(configPath); + + public static void Configure(DbContextOptionsBuilder options, string connectionString) => + options.UseNpgsql(connectionString, postgres => + { + postgres.SetPostgresVersion(18, 0); + postgres.CommandTimeout(10); + postgres.MigrationsHistoryTable("__EFMigrationsHistory", "access"); + }); +} diff --git a/src/server/Data/EndpointRegistrationCommand.cs b/src/server/Data/EndpointRegistrationCommand.cs new file mode 100644 index 0000000..7a062bb --- /dev/null +++ b/src/server/Data/EndpointRegistrationCommand.cs @@ -0,0 +1,93 @@ +using Microsoft.EntityFrameworkCore; +using Renci.SshNet.Common; +using Charac.Server.Authentication; +using Charac.Server.Hosting; +using Charac.Server.Ssh; + +namespace Charac.Server.Data; + +/// Interactive, local-only registration of a target and its managed SSH login key. +internal static class EndpointRegistrationCommand +{ + public static async Task RunAsync(string name, string address, string sourceKeyPath, string? configPath) + { + try + { + var configuration = WorkspaceAccessConfiguration.Load(configPath); + var connection = DatabaseSettings.ConnectionString(configuration) + ?? throw new InvalidOperationException("Configure [database] before registering an endpoint."); + var options = new DbContextOptionsBuilder(); + DatabaseSettings.Configure(options, connection); + await using var database = new AccessDbContext(options.Options); + if ((await database.Database.GetPendingMigrationsAsync()).Any()) + throw new InvalidOperationException("Apply database migrations first."); + if (Console.IsInputRedirected) + throw new InvalidOperationException("Endpoint registration requires an interactive local terminal."); + + var portText = Prompt("SSH port [22]", optional: true); + if (portText.Length != 0 && !int.TryParse(portText, out _)) + throw new ArgumentException("Provide a valid SSH port."); + var port = portText.Length == 0 ? 22 : int.Parse(portText); + var hostKeyPath = Prompt("Trusted SSH host public key file"); + var hostPublicKey = AccessRegistrationCommand.ReadHostKey(hostKeyPath); + var fingerprint = AccessRegistrationCommand.Fingerprint(hostPublicKey); + Console.WriteLine($"Host key fingerprint: {fingerprint}"); + Console.WriteLine("Compare this fingerprint with the target's console or another trusted channel."); + if (Prompt("Paste the fingerprint to confirm") != fingerprint) + throw new InvalidOperationException("The SSH host key fingerprint was not confirmed."); + + var subject = Prompt("OIDC subject (blank to register without an access grant)", optional: true); + var account = subject.Length == 0 ? null : Prompt("Target OS account"); + if (string.IsNullOrWhiteSpace(name) || name.Length > 128 || name.Any(char.IsControl)) + throw new ArgumentException("The endpoint name is invalid."); + if (address.Length > 253 || Uri.CheckHostName(address) == UriHostNameType.Unknown || + port is < 1 or > 65535) + throw new ArgumentException("Provide a valid SSH address and port."); + if (subject.Length > 256 || subject.Any(char.IsControl) || + account is { Length: > 256 } || account is not null && account.Any(char.IsControl)) + throw new ArgumentException("The OIDC subject or OS account is invalid."); + if (account is not null && OidcSettings.FromConfiguration(configuration) is null) + throw new InvalidOperationException("Configure [oidc] before registering a grant."); + + var existingTarget = await database.Targets.Include(x => x.HostKeys) + .SingleOrDefaultAsync(x => x.Name == name); + if (existingTarget is not null && (!existingTarget.Enabled || existingTarget.Address != address || + existingTarget.Port != port || + !existingTarget.HostKeys.Any(x => x.Enabled && x.PublicKey == hostPublicKey))) + throw new InvalidOperationException("This endpoint name is already registered with different or disabled connection details."); + + var existingKey = await database.LoginKeys.AsNoTracking().SingleOrDefaultAsync(x => x.Name == name); + if (existingKey is { Enabled: false }) + throw new InvalidOperationException("The existing SSH login key is disabled."); + var store = new SshKeyStore(configuration); + var fileName = new SshKeyImporter(store).Import(name, sourceKeyPath, existingKey?.FileName); + + await using var transaction = await database.Database.BeginTransactionAsync(); + await AccessRegistrationCommand.RegisterTargetAsync(database, name, address, port, hostPublicKey, announce: false); + await SshLoginKeyCommand.RegisterAsync(database, store, name, fileName, announce: false); + if (account is not null) + await AccessRegistrationCommand.RegisterGrantAsync(database, configuration, name, subject, account, name, announce: false); + await transaction.CommitAsync(); + Console.WriteLine(account is null + ? "Endpoint registered. Add an OIDC grant before connecting from Client." + : "Endpoint and OIDC grant registered. Client can now select the target."); + return 0; + } + catch (Exception exception) when (exception is ArgumentException or InvalidOperationException or + IOException or UnauthorizedAccessException or System.Data.Common.DbException or DbUpdateException or + SshException or FormatException) + { + Console.Error.WriteLine($"Endpoint registration failed: {exception.Message}"); + return 1; + } + } + + private static string Prompt(string label, bool optional = false) + { + Console.Write($"{label}: "); + var value = Console.ReadLine()?.Trim(); + if (value is null || !optional && value.Length == 0) + throw new InvalidOperationException($"{label} is required."); + return value; + } +} diff --git a/src/server/Data/Entities/AccessGrant.cs b/src/server/Data/Entities/AccessGrant.cs new file mode 100644 index 0000000..a26029e --- /dev/null +++ b/src/server/Data/Entities/AccessGrant.cs @@ -0,0 +1,17 @@ +namespace Charac.Server.Data.Entities; + +/// One external identity maps to one OS account per target in the first schema. +internal sealed class AccessGrant +{ + public Guid Id { get; set; } = Guid.CreateVersion7(); + public Guid IdentityId { get; set; } + public AccessIdentity Identity { get; set; } = null!; + public Guid TargetId { get; set; } + public SshTarget Target { get; set; } = null!; + public required string Account { get; set; } + public Guid? SshLoginKeyId { get; set; } + public SshLoginKey? SshLoginKey { get; set; } + public string? CertificatePrincipal { get; set; } + public bool Enabled { get; set; } + public DateTimeOffset? ExpiresAt { get; set; } +} diff --git a/src/server/Data/Entities/AccessIdentity.cs b/src/server/Data/Entities/AccessIdentity.cs new file mode 100644 index 0000000..0fb53c5 --- /dev/null +++ b/src/server/Data/Entities/AccessIdentity.cs @@ -0,0 +1,9 @@ +namespace Charac.Server.Data.Entities; + +internal sealed class AccessIdentity +{ + public Guid Id { get; set; } = Guid.CreateVersion7(); + public required string Issuer { get; set; } + public required string Subject { get; set; } + public bool Enabled { get; set; } +} diff --git a/src/server/Data/Entities/SshHostKey.cs b/src/server/Data/Entities/SshHostKey.cs new file mode 100644 index 0000000..df1400f --- /dev/null +++ b/src/server/Data/Entities/SshHostKey.cs @@ -0,0 +1,11 @@ +namespace Charac.Server.Data.Entities; + +/// An explicitly enrolled host public key, not a user login key or certificate authority. +internal sealed class SshHostKey +{ + public Guid Id { get; set; } = Guid.CreateVersion7(); + public Guid TargetId { get; set; } + public SshTarget Target { get; set; } = null!; + public required string PublicKey { get; set; } + public bool Enabled { get; set; } +} diff --git a/src/server/Data/Entities/SshLoginKey.cs b/src/server/Data/Entities/SshLoginKey.cs new file mode 100644 index 0000000..daaa057 --- /dev/null +++ b/src/server/Data/Entities/SshLoginKey.cs @@ -0,0 +1,10 @@ +namespace Charac.Server.Data.Entities; + +/// Metadata for a server-owned SSH login key; private material stays in a protected file. +internal sealed class SshLoginKey +{ + public Guid Id { get; set; } = Guid.CreateVersion7(); + public required string Name { get; set; } + public required string FileName { get; set; } + public bool Enabled { get; set; } +} diff --git a/src/server/Data/Entities/SshTarget.cs b/src/server/Data/Entities/SshTarget.cs new file mode 100644 index 0000000..c6dd4d8 --- /dev/null +++ b/src/server/Data/Entities/SshTarget.cs @@ -0,0 +1,13 @@ +namespace Charac.Server.Data.Entities; + +internal sealed class SshTarget +{ + public Guid Id { get; set; } = Guid.CreateVersion7(); + public required string Name { get; set; } + public required string Address { get; set; } + public int Port { get; set; } = 22; + public bool Enabled { get; set; } + public Guid? UserCertificateAuthorityId { get; set; } + public UserCertificateAuthority? UserCertificateAuthority { get; set; } + public List HostKeys { get; set; } = []; +} diff --git a/src/server/Data/Entities/UserCertificateAuthority.cs b/src/server/Data/Entities/UserCertificateAuthority.cs new file mode 100644 index 0000000..4fdb8d9 --- /dev/null +++ b/src/server/Data/Entities/UserCertificateAuthority.cs @@ -0,0 +1,11 @@ +namespace Charac.Server.Data.Entities; + +/// User certificate issuer. Private key material is stored outside this database. +internal sealed class UserCertificateAuthority +{ + public Guid Id { get; set; } = Guid.CreateVersion7(); + public required string Name { get; set; } + public required string PublicKey { get; set; } + public required string SigningKeyReference { get; set; } + public bool Enabled { get; set; } +} diff --git a/src/server/Data/Entities/WorkspaceRecord.cs b/src/server/Data/Entities/WorkspaceRecord.cs new file mode 100644 index 0000000..28c8115 --- /dev/null +++ b/src/server/Data/Entities/WorkspaceRecord.cs @@ -0,0 +1,15 @@ +namespace Charac.Server.Data.Entities; + +/// Durable history; a row is never proof that an SSH channel is still alive. +internal sealed class WorkspaceRecord +{ + public Guid Id { get; set; } + public required string Issuer { get; set; } + public required string Subject { get; set; } + public Guid TargetId { get; set; } + public required string Account { get; set; } + public required string Authentication { get; set; } + public DateTimeOffset OpenedAt { get; set; } + public DateTimeOffset? ClosedAt { get; set; } + public string? EndReason { get; set; } +} diff --git a/src/server/Data/Migrations/20260930203202_InitialAccessManagement.Designer.cs b/src/server/Data/Migrations/20260930203202_InitialAccessManagement.Designer.cs new file mode 100644 index 0000000..7cab94b --- /dev/null +++ b/src/server/Data/Migrations/20260930203202_InitialAccessManagement.Designer.cs @@ -0,0 +1,234 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Charac.Server.Data; + +#nullable disable + +namespace Charac.Server.Data.Migrations +{ + [DbContext(typeof(AccessDbContext))] + [Migration("20260930203202_InitialAccessManagement")] + partial class InitialAccessManagement + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("access") + .HasAnnotation("ProductVersion", "10.0.12") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Charac.Server.Data.Entities.AccessGrant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Account") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("CertificatePrincipal") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IdentityId") + .HasColumnType("uuid"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetId"); + + b.HasIndex("IdentityId", "TargetId") + .IsUnique(); + + b.ToTable("grants", "access"); + }); + + modelBuilder.Entity("Charac.Server.Data.Entities.AccessIdentity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("Issuer") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("Issuer", "Subject") + .IsUnique(); + + b.ToTable("identities", "access"); + }); + + modelBuilder.Entity("Charac.Server.Data.Entities.SshHostKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("PublicKey") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetId", "PublicKey") + .IsUnique(); + + b.ToTable("host_keys", "access"); + }); + + modelBuilder.Entity("Charac.Server.Data.Entities.SshTarget", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Address") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("character varying(253)"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Port") + .HasColumnType("integer"); + + b.Property("UserCertificateAuthorityId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("UserCertificateAuthorityId"); + + b.ToTable("targets", "access"); + }); + + modelBuilder.Entity("Charac.Server.Data.Entities.UserCertificateAuthority", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("PublicKey") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("SigningKeyReference") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("user_certificate_authorities", "access"); + }); + + modelBuilder.Entity("Charac.Server.Data.Entities.AccessGrant", b => + { + b.HasOne("Charac.Server.Data.Entities.AccessIdentity", "Identity") + .WithMany() + .HasForeignKey("IdentityId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Charac.Server.Data.Entities.SshTarget", "Target") + .WithMany() + .HasForeignKey("TargetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Identity"); + + b.Navigation("Target"); + }); + + modelBuilder.Entity("Charac.Server.Data.Entities.SshHostKey", b => + { + b.HasOne("Charac.Server.Data.Entities.SshTarget", "Target") + .WithMany("HostKeys") + .HasForeignKey("TargetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Target"); + }); + + modelBuilder.Entity("Charac.Server.Data.Entities.SshTarget", b => + { + b.HasOne("Charac.Server.Data.Entities.UserCertificateAuthority", "UserCertificateAuthority") + .WithMany() + .HasForeignKey("UserCertificateAuthorityId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("UserCertificateAuthority"); + }); + + modelBuilder.Entity("Charac.Server.Data.Entities.SshTarget", b => + { + b.Navigation("HostKeys"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/server/Data/Migrations/20260930203202_InitialAccessManagement.cs b/src/server/Data/Migrations/20260930203202_InitialAccessManagement.cs new file mode 100644 index 0000000..ff5d53a --- /dev/null +++ b/src/server/Data/Migrations/20260930203202_InitialAccessManagement.cs @@ -0,0 +1,198 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Charac.Server.Data.Migrations +{ + /// + public partial class InitialAccessManagement : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "access"); + + migrationBuilder.CreateTable( + name: "identities", + schema: "access", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Issuer = table.Column(type: "character varying(512)", maxLength: 512, nullable: false), + Subject = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + Enabled = table.Column(type: "boolean", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_identities", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "user_certificate_authorities", + schema: "access", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + PublicKey = table.Column(type: "character varying(2048)", maxLength: 2048, nullable: false), + SigningKeyReference = table.Column(type: "character varying(1024)", maxLength: 1024, nullable: false), + Enabled = table.Column(type: "boolean", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_user_certificate_authorities", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "targets", + schema: "access", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + Address = table.Column(type: "character varying(253)", maxLength: 253, nullable: false), + Port = table.Column(type: "integer", nullable: false), + Enabled = table.Column(type: "boolean", nullable: false), + UserCertificateAuthorityId = table.Column(type: "uuid", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_targets", x => x.Id); + table.ForeignKey( + name: "FK_targets_user_certificate_authorities_UserCertificateAuthori~", + column: x => x.UserCertificateAuthorityId, + principalSchema: "access", + principalTable: "user_certificate_authorities", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "grants", + schema: "access", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + IdentityId = table.Column(type: "uuid", nullable: false), + TargetId = table.Column(type: "uuid", nullable: false), + Account = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + CertificatePrincipal = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + Enabled = table.Column(type: "boolean", nullable: false), + ExpiresAt = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_grants", x => x.Id); + table.ForeignKey( + name: "FK_grants_identities_IdentityId", + column: x => x.IdentityId, + principalSchema: "access", + principalTable: "identities", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_grants_targets_TargetId", + column: x => x.TargetId, + principalSchema: "access", + principalTable: "targets", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "host_keys", + schema: "access", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + TargetId = table.Column(type: "uuid", nullable: false), + PublicKey = table.Column(type: "character varying(2048)", maxLength: 2048, nullable: false), + Enabled = table.Column(type: "boolean", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_host_keys", x => x.Id); + table.ForeignKey( + name: "FK_host_keys_targets_TargetId", + column: x => x.TargetId, + principalSchema: "access", + principalTable: "targets", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_grants_IdentityId_TargetId", + schema: "access", + table: "grants", + columns: new[] { "IdentityId", "TargetId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_grants_TargetId", + schema: "access", + table: "grants", + column: "TargetId"); + + migrationBuilder.CreateIndex( + name: "IX_host_keys_TargetId_PublicKey", + schema: "access", + table: "host_keys", + columns: new[] { "TargetId", "PublicKey" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_identities_Issuer_Subject", + schema: "access", + table: "identities", + columns: new[] { "Issuer", "Subject" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_targets_Name", + schema: "access", + table: "targets", + column: "Name", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_targets_UserCertificateAuthorityId", + schema: "access", + table: "targets", + column: "UserCertificateAuthorityId"); + + migrationBuilder.CreateIndex( + name: "IX_user_certificate_authorities_Name", + schema: "access", + table: "user_certificate_authorities", + column: "Name", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "grants", + schema: "access"); + + migrationBuilder.DropTable( + name: "host_keys", + schema: "access"); + + migrationBuilder.DropTable( + name: "identities", + schema: "access"); + + migrationBuilder.DropTable( + name: "targets", + schema: "access"); + + migrationBuilder.DropTable( + name: "user_certificate_authorities", + schema: "access"); + } + } +} diff --git a/src/server/Data/Migrations/20261002004150_PasswordSshAccess.Designer.cs b/src/server/Data/Migrations/20261002004150_PasswordSshAccess.Designer.cs new file mode 100644 index 0000000..9ca526b --- /dev/null +++ b/src/server/Data/Migrations/20261002004150_PasswordSshAccess.Designer.cs @@ -0,0 +1,232 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Charac.Server.Data; + +#nullable disable + +namespace Charac.Server.Data.Migrations +{ + [DbContext(typeof(AccessDbContext))] + [Migration("20261002004150_PasswordSshAccess")] + partial class PasswordSshAccess + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("access") + .HasAnnotation("ProductVersion", "10.0.12") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Charac.Server.Data.Entities.AccessGrant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Account") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("CertificatePrincipal") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IdentityId") + .HasColumnType("uuid"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetId"); + + b.HasIndex("IdentityId", "TargetId") + .IsUnique(); + + b.ToTable("grants", "access"); + }); + + modelBuilder.Entity("Charac.Server.Data.Entities.AccessIdentity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("Issuer") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("Issuer", "Subject") + .IsUnique(); + + b.ToTable("identities", "access"); + }); + + modelBuilder.Entity("Charac.Server.Data.Entities.SshHostKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("PublicKey") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetId", "PublicKey") + .IsUnique(); + + b.ToTable("host_keys", "access"); + }); + + modelBuilder.Entity("Charac.Server.Data.Entities.SshTarget", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Address") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("character varying(253)"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Port") + .HasColumnType("integer"); + + b.Property("UserCertificateAuthorityId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("UserCertificateAuthorityId"); + + b.ToTable("targets", "access"); + }); + + modelBuilder.Entity("Charac.Server.Data.Entities.UserCertificateAuthority", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("PublicKey") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("SigningKeyReference") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("user_certificate_authorities", "access"); + }); + + modelBuilder.Entity("Charac.Server.Data.Entities.AccessGrant", b => + { + b.HasOne("Charac.Server.Data.Entities.AccessIdentity", "Identity") + .WithMany() + .HasForeignKey("IdentityId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Charac.Server.Data.Entities.SshTarget", "Target") + .WithMany() + .HasForeignKey("TargetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Identity"); + + b.Navigation("Target"); + }); + + modelBuilder.Entity("Charac.Server.Data.Entities.SshHostKey", b => + { + b.HasOne("Charac.Server.Data.Entities.SshTarget", "Target") + .WithMany("HostKeys") + .HasForeignKey("TargetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Target"); + }); + + modelBuilder.Entity("Charac.Server.Data.Entities.SshTarget", b => + { + b.HasOne("Charac.Server.Data.Entities.UserCertificateAuthority", "UserCertificateAuthority") + .WithMany() + .HasForeignKey("UserCertificateAuthorityId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("UserCertificateAuthority"); + }); + + modelBuilder.Entity("Charac.Server.Data.Entities.SshTarget", b => + { + b.Navigation("HostKeys"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/server/Data/Migrations/20261002004150_PasswordSshAccess.cs b/src/server/Data/Migrations/20261002004150_PasswordSshAccess.cs new file mode 100644 index 0000000..d009a3d --- /dev/null +++ b/src/server/Data/Migrations/20261002004150_PasswordSshAccess.cs @@ -0,0 +1,63 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Charac.Server.Data.Migrations +{ + /// + public partial class PasswordSshAccess : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "UserCertificateAuthorityId", + schema: "access", + table: "targets", + type: "uuid", + nullable: true, + oldClrType: typeof(Guid), + oldType: "uuid"); + + migrationBuilder.AlterColumn( + name: "CertificatePrincipal", + schema: "access", + table: "grants", + type: "character varying(256)", + maxLength: 256, + nullable: true, + oldClrType: typeof(string), + oldType: "character varying(256)", + oldMaxLength: 256); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "UserCertificateAuthorityId", + schema: "access", + table: "targets", + type: "uuid", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000"), + oldClrType: typeof(Guid), + oldType: "uuid", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "CertificatePrincipal", + schema: "access", + table: "grants", + type: "character varying(256)", + maxLength: 256, + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "character varying(256)", + oldMaxLength: 256, + oldNullable: true); + } + } +} diff --git a/src/server/Data/Migrations/20261004140715_SshLoginKeysAndWorkspaceHistory.Designer.cs b/src/server/Data/Migrations/20261004140715_SshLoginKeysAndWorkspaceHistory.Designer.cs new file mode 100644 index 0000000..2f04776 --- /dev/null +++ b/src/server/Data/Migrations/20261004140715_SshLoginKeysAndWorkspaceHistory.Designer.cs @@ -0,0 +1,325 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Charac.Server.Data; + +#nullable disable + +namespace Charac.Server.Data.Migrations +{ + [DbContext(typeof(AccessDbContext))] + [Migration("20261004140715_SshLoginKeysAndWorkspaceHistory")] + partial class SshLoginKeysAndWorkspaceHistory + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("access") + .HasAnnotation("ProductVersion", "10.0.12") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Charac.Server.Data.Entities.AccessGrant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Account") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("CertificatePrincipal") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IdentityId") + .HasColumnType("uuid"); + + b.Property("SshLoginKeyId") + .HasColumnType("uuid"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SshLoginKeyId"); + + b.HasIndex("TargetId"); + + b.HasIndex("IdentityId", "TargetId") + .IsUnique(); + + b.ToTable("grants", "access"); + }); + + modelBuilder.Entity("Charac.Server.Data.Entities.AccessIdentity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("Issuer") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("Issuer", "Subject") + .IsUnique(); + + b.ToTable("identities", "access"); + }); + + modelBuilder.Entity("Charac.Server.Data.Entities.SshHostKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("PublicKey") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetId", "PublicKey") + .IsUnique(); + + b.ToTable("host_keys", "access"); + }); + + modelBuilder.Entity("Charac.Server.Data.Entities.SshLoginKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("PublicKey") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.HasKey("Id"); + + b.HasIndex("FileName") + .IsUnique(); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ssh_login_keys", "access"); + }); + + modelBuilder.Entity("Charac.Server.Data.Entities.SshTarget", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Address") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("character varying(253)"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Port") + .HasColumnType("integer"); + + b.Property("UserCertificateAuthorityId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("UserCertificateAuthorityId"); + + b.ToTable("targets", "access"); + }); + + modelBuilder.Entity("Charac.Server.Data.Entities.UserCertificateAuthority", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("PublicKey") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("SigningKeyReference") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("user_certificate_authorities", "access"); + }); + + modelBuilder.Entity("Charac.Server.Data.Entities.WorkspaceRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property
__MESSAGE__