9 Commits
Author SHA1 Message Date
Nitload e4b82aeea7 Bump Android broker version code
Verify / verify (ubuntu-latest, linux-bionic-arm64) (push) Canceled after 0s
Verify / verify (ubuntu-latest, linux-x64) (push) Canceled after 0s
Verify / verify (windows-latest, win-x64) (push) Canceled after 0s
Verify / postgres (push) Canceled after 0s
2026-10-06 11:42:34 +08:00
Nitload 3f3fd613db Launch Android broker through Termux URL handler
Verify / verify (ubuntu-latest, linux-bionic-arm64) (push) Canceled after 0s
Verify / verify (ubuntu-latest, linux-x64) (push) Canceled after 0s
Verify / verify (windows-latest, win-x64) (push) Canceled after 0s
Verify / postgres (push) Canceled after 0s
2026-10-06 11:34:42 +08:00
Nitload 6d072b3718 Add Android OIDC callback broker for Termux
Verify / verify (ubuntu-latest, linux-bionic-arm64) (push) Canceled after 0s
Verify / verify (ubuntu-latest, linux-x64) (push) Canceled after 0s
Verify / verify (windows-latest, win-x64) (push) Canceled after 0s
Verify / postgres (push) Canceled after 0s
2026-10-06 11:28:13 +08:00
Nitload f9effc890b Use Termux aarch64 package metadata
Verify / verify (ubuntu-latest, linux-bionic-arm64) (push) Canceled after 0s
Verify / verify (ubuntu-latest, linux-x64) (push) Canceled after 0s
Verify / verify (windows-latest, win-x64) (push) Canceled after 0s
Verify / postgres (push) Canceled after 0s
2026-10-06 10:14:13 +08:00
Nitload 111029fde3 Package the Termux client for Termux runtime
Verify / verify (ubuntu-latest, linux-bionic-arm64) (push) Canceled after 0s
Verify / verify (ubuntu-latest, linux-x64) (push) Canceled after 0s
Verify / verify (windows-latest, win-x64) (push) Canceled after 0s
Verify / postgres (push) Canceled after 0s
2026-10-06 10:01:39 +08:00
Nitload 63b37047e1 Version packages and standardize installation paths
Verify / verify (ubuntu-latest, linux-bionic-arm64) (push) Canceled after 0s
Verify / verify (ubuntu-latest, linux-x64) (push) Canceled after 0s
Verify / verify (windows-latest, win-x64) (push) Canceled after 0s
Verify / postgres (push) Canceled after 0s
2026-10-06 09:30:59 +08:00
Nitload d7bab46552 Improve client setup and Termux guidance
Verify / verify (ubuntu-latest, linux-bionic-arm64) (push) Canceled after 0s
Verify / verify (ubuntu-latest, linux-x64) (push) Canceled after 0s
Verify / verify (windows-latest, win-x64) (push) Canceled after 0s
Verify / postgres (push) Canceled after 0s
2026-10-06 09:16:39 +08:00
Nitload 8c0edfd546 Finalize Charac release cleanup
Verify / verify (ubuntu-latest, linux-bionic-arm64) (push) Canceled after 0s
Verify / verify (ubuntu-latest, linux-x64) (push) Canceled after 0s
Verify / verify (windows-latest, win-x64) (push) Canceled after 0s
Verify / postgres (push) Canceled after 0s
2026-10-06 08:53:37 +08:00
Nitload 9fa9f29259 License Charac under MIT and include package notices 2026-10-06 08:20:07 +08:00
26 changed files with 348 additions and 191 deletions
+1 -1
View File
@@ -32,7 +32,7 @@ jobs:
run: dotnet run --project build/Build.csproj --configuration Release --no-restore -- --target Publish --runtime ${{ matrix.runtime }} --locked-restore
- uses: actions/upload-artifact@v4
with:
name: workspace-access-${{ matrix.runtime }}
name: charac-${{ matrix.runtime }}
path: artifacts/publish/${{ matrix.runtime }}/
- uses: actions/upload-artifact@v4
if: always()
+1 -1
View File
@@ -1,7 +1,7 @@
<Project>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Version>0.1.0</Version>
<Version>0.1.1</Version>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Nitload-NSI
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+11 -3
View File
@@ -1,4 +1,4 @@
# Workspace Access
# Charac
面向 Windows 和 Linux 目标的远程 SSH 工作区管理服务,使用 .NET 10 开发;CLI 客户端另支持 Android ARM64 的 Termux 终端发布。服务端与 CLI 客户端分别位于 `src/server/` 和 `src/client/`。
@@ -10,7 +10,7 @@ SSH CA 是后端凭据的可选方案;正式用户登录使用 OIDC。首期
## 当前状态
当前已实现 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)。
当前已实现 Server 服务入口、EF Core/PostgreSQL 授权、OIDC 令牌校验、托管私钥及密码 SSH Broker、Session Manager 与 WebSocket;Client 提供 `login` 登录验证及 `connect` 的资源选择、交互创建/重新附着。正式 Client 已在 `.100 → .101` 环境通过 authentik 登录、目标选择和交互式 `ls`;自动重连和画面恢复仍待完善。`/health/live` 返回 200,`/health/ready` 返回 503,检查真实数据库状态并列出待集成项,数据库配置与迁移见[数据库文档](docs/database.md)。
## 构建与运行
@@ -38,4 +38,12 @@ docs/ 设计、状态与验收文档
deploy/ Windows Service 与 systemd 模板
```
阅读[文档库](docs/README.md)、[单域名通信入口](docs/client-server-transport.md)、[架构](docs/architecture.md)、[交付状态](docs/implementation-status.md)和[贡献指南](CONTRIBUTING.md)。
阅读[文档库](docs/README.md)、[单域名通信入口](docs/client-server-transport.md)、[架构](docs/architecture.md)、[交付状态](docs/implementation-status.md)和[贡献指南](CONTRIBUTING.md)。
## 开发方式
本项目采用 vibe coding 方式开发,代码和文档由 AI 辅助编写与迭代。功能范围与尚未完成的验收项以[交付状态](docs/implementation-status.md)为准。
## 许可证
Charac 项目代码采用 [MIT 许可证](LICENSE)。Client 内嵌的 IBM Plex Mono 字体采用 [SIL Open Font License 1.1](src/client/Assets/Fonts/IBM-Plex-LICENSE.txt),不属于项目的 MIT 授权范围。
+1 -1
View File
@@ -24,7 +24,7 @@ internal sealed class Build : NukeBuild
[Parameter("Require dependency versions from packages.lock.json.")]
readonly bool LockedRestore;
AbsolutePath SolutionFile => RootDirectory / "WorkspaceAccess.slnx";
AbsolutePath SolutionFile => RootDirectory / "Charac.slnx";
AbsolutePath ServerProject => RootDirectory / "src/server/WorkspaceAccessServer.csproj";
AbsolutePath ClientProject => RootDirectory / "src/client/WorkspaceAccessClient.csproj";
AbsolutePath TestProject => RootDirectory / "tests/WorkspaceAccessServer.Tests/WorkspaceAccessServer.Tests.csproj";
-17
View File
@@ -1,17 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<RootNamespace>Charac.Client</RootNamespace>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<Using Include="Microsoft.Extensions.Configuration" />
<Compile Include="../../src/client/OidcLogin.cs" Link="OidcLogin.cs" />
<Compile Include="../../src/server/Authentication/OidcSettings.cs" Link="OidcSettings.cs" />
<Compile Include="../../src/server/Hosting/WorkspaceAccessConfiguration.cs" Link="WorkspaceAccessConfiguration.cs" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="../../src/client/Assets/Fonts/*.woff2" LogicalName="Charac.Fonts.%(Filename)%(Extension)" />
<None Include="../../src/client/Assets/Fonts/IBM-Plex-LICENSE.txt" Link="Assets/Fonts/IBM-Plex-LICENSE.txt" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
</ItemGroup>
</Project>
-88
View File
@@ -1,88 +0,0 @@
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<int> 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 <workspace-access.config path>]");
}
}
-34
View File
@@ -1,34 +0,0 @@
# 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=<authentik-public-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 为 `<issuer 去掉末尾斜杠>/.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 <origin>` 验证登录,或 `connect --server <origin> --target <id>` 登录并进入交互工作区。正式 Client 从 Server 取得 OIDC 配置;本 demo 仍直接读 INI,用于独立排查身份提供方。命令及验收边界见[正式 Client 流程](../../docs/client-server-transport.md)。
-6
View File
@@ -1,6 +0,0 @@
{
"version": 2,
"dependencies": {
"net10.0": {}
}
}
-1
View File
@@ -21,7 +21,6 @@
- [开发与构建](development.md)
- [验证与验收](testing.md)
- [CLI 唤起 authentik Playground](../demo/oidc-cli/README.md)
- [Windows 部署模板](../deploy/windows/README.md)
- [Linux 部署模板](../deploy/linux/README.md)
+1 -1
View File
@@ -20,7 +20,7 @@ Server 对每个工作区持有一条后端 SSH 会话。WebSocket 只是客户
解决方案包含 `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。
CLI 的 `login` 和 `connect` 通过单一域名发现 OIDC 配置,复用 PKCE 浏览器登录与回调页,并让 Server 验证身份;`connect` 从受保护的 `/resources` 取得当前授权目标及会话状态,选择后按目标或会话 ID 的 query 建立正式 WebSocket 交互;也支持直接指定 ID。原有一次性 Linux `ls` 命令继续用于诊断。正式 Client 的真实 authentik 登录、Server 身份验证及 `.100 → .101` 交互式 `ls` 已实测,见[单域名入口](client-server-transport.md)。Caddy 等反向代理可承接公网 TLS 入口;内网 `sshd` 只允许 Server 访问。Windows OpenSSH 已使用 ConPTY,因此 Server 不再自己启动 `pwsh.exe` 或实现平台 PTY。
## 信任与生命周期
+7 -2
View File
@@ -1,10 +1,15 @@
# 身份认证与授权
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`;正常退出及重新附着待复测。
桌面 Client 使用动态 loopback URI `http://127.0.0.1:<动态端口>/callback`;Termux
Client 使用 `com.nitload.charac://oauth/callback`,由 Android APK 接收 authentik
回调,再通过 Termux `RUN_COMMAND` 转回 CLI。authentik Provider 必须同时允许这两类
Redirect URI。
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`。正式 Client 的 `login` 已通过真实 authentik 浏览器登录及 Server 令牌校验;2026-10-05 已在 `.100 → .101` 实测目标授权、资源选择和交互式 `ls`;正常退出及重新附着待复测。
管理员将外部身份映射到允许访问的目标机器和系统账户。Windows 目标使用账户 SID,Linux 目标使用 UID/GID;实际登录环境及文件权限由目标 sshd 和操作系统建立。不同外部身份即使映射到同一个系统账户,工作区归属仍独立。
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 当前的 `access.identities` 只保存 `(issuer, subject)` 身份键和启用状态,并不保存用户资料。若未来加入本地易读名称,应命名为 **user label**,仅供管理员识别和显示;标签不能作为认证或授权依据,也不能声称是从身份平台实时同步的资料。姓名、邮箱等真正的用户资料由 authentik 或其他 OIDC Provider 管理;Server 不直接读取 authentik 的 PostgreSQL。正式 Client 的 `login` 和 `connect` 使用相同的浏览器登录与回调页。具体命令见[正式 Client 流程](client-server-transport.md)。
当前建连支持 Server 托管的 SSH 登录私钥或一次性输入的 SSH 密码,两者均继续验证数据库中登记的目标主机公钥。密钥授权不要求 Client 提供 SSH 密码;密码授权的密码在 HTTPS 请求体中传给 Server,不写入数据库或日志。私钥内容只保存在服务账户可访问的文件中,数据库保存引用。目标 sshd 只在内网对 Server 开放。正式用户登录只使用 OIDC;SSH CA 仍是可选的后端凭据方案,不是用户登录步骤。
+1 -1
View File
@@ -2,7 +2,7 @@
使用 .NET 10 或更高版本 SDK。global.json 设置最低 SDK 并允许更高版本;项目目标框架为 net10.0,入口使用显式 Program.Main。包版本集中在 Directory.Packages.props,锁文件固定依赖解析。
解决方案 WorkspaceAccess.slnx 包含 src/server/WorkspaceAccessServer.csproj、src/client/WorkspaceAccessClient.csproj、测试项目和 NUKE 项目。各项目的构建输出统一位于根目录 `temp/<项目名>/bin/`,中间文件位于 `temp/<项目名>/obj/`;`temp/` 被 Git 忽略。Windows 用 ./build.ps1,Linux 用 bash ./build.sh。
解决方案 Charac.slnx 包含 src/server/WorkspaceAccessServer.csproj、src/client/WorkspaceAccessClient.csproj、测试项目和 NUKE 项目。各项目的构建输出统一位于根目录 `temp/<项目名>/bin/`,中间文件位于 `temp/<项目名>/obj/`;`temp/` 被 Git 忽略。Windows 用 ./build.ps1,Linux 用 bash ./build.sh。
| NUKE 目标 | 行为 |
| --- | --- |
+1 -1
View File
@@ -12,7 +12,7 @@
| 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)。
旧 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 关闭修复待复测。正式 Client 的 `login` 已实测通过真实 authentik 浏览器 PKCE 登录与 Server 令牌验证;资源选择到 SSH 交互主链路已在 `.100 → .101` 实测,见[通信入口](client-server-transport.md)。
## 待实现
+6 -2
View File
@@ -1,10 +1,14 @@
# Android CLI 客户端
Termux 登录不让浏览器直接访问 Termux 的 `127.0.0.1`:CLI 通过 `termux-open-url` 启动 APK,APK 注册 `com.nitload.charac://oauth/callback` 并接收 authentik 回调,再用 `RUN_COMMAND` 调用 `charac callback` 将授权码转回 CLI 的本地监听器。
推荐把 Android CLI 作为 Termux DEB 分发,而不是把 CLI 当成普通 Android APK。APK 只是可选的 One UI 启动器:它不能单独运行 CLI,必须与 Termux、Termux DEB、运行命令权限和 `allow-external-apps=true` 配置一起使用。直接安装 APK 后点击图标不能替代 Termux 环境,也不会自动安装 .NET 或 `charac`。
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 的环境检查会识别包提供的固定入口。
执行 `./build.ps1 --target Publish --runtime linux-bionic-arm64 --locked-restore`,或在 Linux 上使用 `bash ./build.sh`。随后运行 `./packaging/package.ps1 -Target ClientTermuxDeb`,生成 `artifacts/packages/<版本>/charac-termux_<版本>-2_aarch64.deb`。该包面向标准包名 `com.termux` 的 apt 版 aarch64 Termux,将 framework-dependent Client 安装到 `$PREFIX/opt/charac`,将 `charac` 入口装到 `$PREFIX/bin`,依赖 Termux 提供的 `dotnet-runtime-10.0`。它不承诺裸 Debian/Ubuntu 环境兼容。在 Termux 中复制 DEB 后运行 `apt install ./charac-termux_<版本>-2_aarch64.deb`;当前尚未发布到 Termux 仓库,因此 `pkg install charac-termux` 还不能从公共仓库下载它。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` 检查。
如果暂时不使用 DEB,也可以复制 `artifacts/publish/linux-bionic-arm64/client/` 到 Termux 私有目录,例如 `$HOME/.local/opt/charac`;该目录是 framework-dependent 发布,需要先安装 Termux 的 `dotnet-runtime-10.0`。为 APK 提供固定入口 `$PREFIX/bin/charac`,其内容用 Termux 的 `sh` 调用 `dotnet $HOME/.local/opt/charac/charac.dll` 并原样传递参数;入口必须可执行。
手动复制方案在 Termux 中完成后,创建入口:
+3 -3
View File
@@ -15,7 +15,7 @@ $env:NuGetAudit='false'
.\packaging\package.ps1 -Target ClientTermuxDeb
```
封装需要 [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 尚未配置。
封装需要 [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/<版本>/`,例如 `artifacts/packages/0.1.1/`;这两个工具和发布产物均不提交仓库。Fedora/RHEL 的 ARM64 RPM 尚未配置。
## Server RPM 首次部署前提
@@ -36,7 +36,7 @@ Server 运行时只需 `--config /etc/charac/workspace-access.config`。这一
服务单元保留 `ProtectSystem=strict`,并用 `ReadWritePaths=/tmp` 允许 .NET 在 `/tmp` 创建本机管理通道的 Unix socket 和 Data Protection 临时文件。不要启用 `PrivateTmp`:管理命令从服务外部以同一个 `charac` 账户连接该 socket,需要看到同一个 `/tmp`。已安装旧 RPM 的机器可先执行 `sudo systemctl edit charac-server`,在 `[Service]` 下添加 `ReadWritePaths=/tmp`,保存后运行 `sudo systemctl daemon-reload && sudo systemctl restart charac-server`;新版 RPM 将直接包含这一设置。
Client RPM 安装自包含程序到 `/opt/charac/client`,并提供 `/usr/bin/charac` 命令。Windows MSI 安装到 `Program Files\nitload\Charac Client`,把安装目录加入系统 `PATH`;新终端可运行 `charac.exe --help`。MSI 内嵌所需文件,不依赖独立 CAB。两个 Client 包均不预置登录令牌或目标配置。
Client RPM 安装自包含程序到 `/opt/charac/client`,并提供 `/usr/bin/charac` 命令。Windows MSI 以 per-machine 方式安装到 `Program Files\nitload\charac`,会触发 UAC 管理员授权,并把安装目录加入系统 `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 <origin>` 可临时覆盖,`--config <路径>` 可在命令前选用其他配置。该文件只存入口地址,不存 OIDC 令牌;旧版 `%APPDATA%\CharRAC`、`~/.config/char-rac` 配置在新位置不存在时仍会自动读取。Client 安装级设备凭据仍独立存于用户本地应用数据目录,不能复制到多台设备共用。
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`。首次执行无配置的 Client 命令会自动创建默认配置目录,但不会写入服务器地址。之后可直接执行 `charac connect`(Windows 为 `charac.exe connect`),或使用更短的 `charac connect https://access.example.com`;`--server <origin>` 可临时覆盖,`--config <路径>` 可在命令前选用其他配置。该文件只存入口地址,不存 OIDC 令牌;旧版 `%APPDATA%\CharRAC`、`~/.config/char-rac` 配置在新位置不存在时仍会自动读取。Client 安装级设备凭据仍独立存于用户本地应用数据目录,不能复制到多台设备共用。
+69 -8
View File
@@ -9,7 +9,7 @@ param(
$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'
$packages = Join-Path $repository "artifacts/packages/$version"
$scratch = Join-Path $repository 'temp/packaging'
New-Item -ItemType Directory -Path $packages, $scratch -Force | Out-Null
@@ -33,6 +33,59 @@ function Add-PackageFile([System.Collections.Generic.List[string]]$lines,
$lines.Add(" mode: $mode")
}
function Normalize-TermuxDebArchitecture([string]$path) {
$ar = (Get-Command ar -ErrorAction SilentlyContinue).Source
if (-not $ar) {
throw 'The Termux DEB post-processing step requires ar.'
}
$work = Join-Path $scratch 'termux-deb-architecture'
Remove-Item -LiteralPath $work -Recurse -Force -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Path $work | Out-Null
Push-Location $work
try {
& $ar x $path
if ($LASTEXITCODE -ne 0) { throw 'Failed to unpack the Termux DEB archive.' }
$controlDirectory = Join-Path $work 'control'
New-Item -ItemType Directory -Path $controlDirectory | Out-Null
$controlInput = [IO.File]::OpenRead((Join-Path $work 'control.tar.gz'))
$controlGzip = [IO.Compression.GZipStream]::new($controlInput, [IO.Compression.CompressionMode]::Decompress)
try {
[System.Formats.Tar.TarFile]::ExtractToDirectory($controlGzip, $controlDirectory, $true)
}
finally {
$controlGzip.Dispose()
$controlInput.Dispose()
}
$controlPath = Join-Path $controlDirectory 'control'
$control = [IO.File]::ReadAllText($controlPath)
if (-not $control.Contains("Architecture: arm64")) {
throw 'The generated DEB does not contain the expected arm64 architecture field.'
}
[IO.File]::WriteAllText($controlPath, $control.Replace('Architecture: arm64', 'Architecture: aarch64'))
Remove-Item -LiteralPath (Join-Path $work 'control.tar.gz') -Force
$controlOutput = [IO.File]::Create((Join-Path $work 'control.tar.gz'))
$controlGzip = [IO.Compression.GZipStream]::new($controlOutput, [IO.Compression.CompressionLevel]::SmallestSize)
try {
[System.Formats.Tar.TarFile]::CreateFromDirectory($controlDirectory, $controlGzip, $false)
}
finally {
$controlGzip.Dispose()
$controlOutput.Dispose()
}
$rebuilt = Join-Path $work 'rebuilt.deb'
& $ar r $rebuilt 'debian-binary' 'control.tar.gz' 'data.tar.gz'
if ($LASTEXITCODE -ne 0) { throw 'Failed to rebuild the Termux DEB archive.' }
Move-Item -LiteralPath $rebuilt -Destination $path -Force
}
finally {
Pop-Location
}
}
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)) {
@@ -50,7 +103,7 @@ if ($Target -eq 'ClientMsi') {
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')
& $WixPath build '-arch' 'x64' '-d' "PackageVersion=$version" '-d' "PayloadDir=$payload" '-d' "ConfigTemplatePath=$(Join-Path $repository 'client.config.example')" '-d' "LicensePath=$(Join-Path $repository 'LICENSE')" '-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
@@ -63,14 +116,14 @@ if ($Target -eq 'ClientTermuxDeb') {
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('name: charac-termux')
$lines.Add("version: $version")
$lines.Add('release: 1')
$lines.Add('release: 2')
$lines.Add('arch: aarch64')
$lines.Add('platform: linux')
$lines.Add('maintainer: nitload')
$lines.Add('vendor: nitload')
$lines.Add('license: Proprietary')
$lines.Add('license: MIT AND OFL-1.1')
$lines.Add('description: Charac CLI for Termux')
$lines.Add('depends:')
$lines.Add(' - dotnet-runtime-10.0')
@@ -82,11 +135,14 @@ if ($Target -eq 'ClientTermuxDeb') {
Add-PackageFile $lines $file.FullName "$prefix/opt/charac/$relative" $mode
}
Add-PackageFile $lines (Join-Path $PSScriptRoot 'termux/charac') "$prefix/bin/charac" '0755'
Add-PackageFile $lines (Join-Path $repository 'LICENSE') "$prefix/share/doc/charac/LICENSE"
Add-PackageFile $lines (Join-Path $repository 'src/client/Assets/Fonts/IBM-Plex-LICENSE.txt') "$prefix/share/doc/charac/IBM-Plex-LICENSE.txt"
$config = Join-Path $scratch 'charac-termux.yaml'
[IO.File]::WriteAllLines($config, [string[]]$lines)
$output = Join-Path $packages "charac_${version}-1_aarch64.deb"
$output = Join-Path $packages "charac-termux_${version}-2_aarch64.deb"
& $NfpmPath package --config $config --packager deb --target $output
if ($LASTEXITCODE -ne 0) { throw "nFPM failed with exit code $LASTEXITCODE." }
Normalize-TermuxDebArchitecture $output
Write-Output $output
exit 0
}
@@ -99,7 +155,7 @@ if (-not $NfpmPath) { $NfpmPath = Join-Path $repository 'temp/tools/nfpm/nfpm.ex
if (-not (Test-Path -LiteralPath $NfpmPath -PathType Leaf)) { throw 'nFPM 2.47.0 is required; pass -NfpmPath.' }
$name = "charac-$kind"
$release = if ($kind -eq 'server') { '2' } else { '1' }
$release = if ($kind -eq 'server') { '3' } else { '2' }
$lines = [System.Collections.Generic.List[string]]::new()
$lines.Add("name: $name")
$lines.Add("version: $version")
@@ -108,7 +164,7 @@ $lines.Add('arch: amd64')
$lines.Add('platform: linux')
$lines.Add('maintainer: nitload')
$lines.Add('vendor: nitload')
$lines.Add('license: Proprietary')
$lines.Add($(if ($kind -eq 'server') { 'license: MIT' } else { 'license: MIT AND OFL-1.1' }))
$lines.Add("description: Charac $kind")
if ($kind -eq 'server') {
$lines.Add('depends:')
@@ -118,6 +174,11 @@ if ($kind -eq 'server') {
$lines.Add(' postremove: ' + (ConvertTo-Json -InputObject (Join-Path $PSScriptRoot 'linux/server-postremove.sh') -Compress))
}
$lines.Add('contents:')
$licenseDirectory = "/usr/share/licenses/$name"
Add-PackageFile $lines (Join-Path $repository 'LICENSE') "$licenseDirectory/LICENSE"
if ($kind -eq 'client') {
Add-PackageFile $lines (Join-Path $repository 'src/client/Assets/Fonts/IBM-Plex-LICENSE.txt') "$licenseDirectory/IBM-Plex-LICENSE.txt"
}
$files = Get-ChildItem -LiteralPath $payload -Recurse -File | Sort-Object FullName
foreach ($file in $files) {
$relative = [IO.Path]::GetRelativePath($payload, $file.FullName).Replace('\', '/')
+7 -3
View File
@@ -3,9 +3,9 @@
UpgradeCode="0F2E0762-42F8-431B-BA94-B8BD63B0C103" Scope="perMachine">
<MajorUpgrade DowngradeErrorMessage="A newer Charac Client is already installed." />
<MediaTemplate EmbedCab="yes" />
<StandardDirectory Id="ProgramFiles6432Folder">
<StandardDirectory Id="ProgramFiles64Folder">
<Directory Id="CompanyFolder" Name="nitload">
<Directory Id="INSTALLFOLDER" Name="Charac Client" />
<Directory Id="INSTALLFOLDER" Name="charac" />
</Directory>
</StandardDirectory>
<Component Id="ClientPath" Directory="INSTALLFOLDER" Guid="*">
@@ -17,12 +17,16 @@
<Component Id="ClientConfigTemplate" Directory="INSTALLFOLDER" Guid="*">
<File Id="ClientConfigTemplateFile" Source="$(var.ConfigTemplatePath)" KeyPath="yes" />
</Component>
<Component Id="ClientLicense" Directory="INSTALLFOLDER" Guid="*">
<File Id="ClientLicenseFile" Source="$(var.LicensePath)" KeyPath="yes" />
</Component>
<Feature Id="Complete" Title="Charac Client" Level="1">
<Files Directory="INSTALLFOLDER" Include="$(var.PayloadDir)\**">
<Exclude Files="$(var.PayloadDir)\**.pdb" />
</Files>
<ComponentRef Id="ClientPath" />
<ComponentRef Id="ClientConfigTemplate" />
<ComponentRef Id="ClientLicense" />
</Feature>
</Package>
</Wix>
</Wix>
+2 -2
View File
@@ -7,8 +7,8 @@
<ImplicitUsings>enable</ImplicitUsings>
<ApplicationId>com.nitload.charac</ApplicationId>
<RootNamespace>Charac.Android</RootNamespace>
<ApplicationVersion>1</ApplicationVersion>
<ApplicationDisplayVersion>0.1.0</ApplicationDisplayVersion>
<ApplicationVersion>2</ApplicationVersion>
<ApplicationDisplayVersion>0.1.1</ApplicationDisplayVersion>
<!--
Enables trim analyzers and full trimming during Release mode.
To learn more, see: https://learn.microsoft.com/dotnet/core/deploying/trimming/trimming-options#trimming-granularity
+118
View File
@@ -9,12 +9,17 @@ using Android.Widget;
namespace Charac.Android;
[Activity(Label = "@string/app_name", MainLauncher = true, Exported = true, LaunchMode = LaunchMode.SingleTop)]
[IntentFilter(new[] { Intent.ActionView }, Categories = new[] { Intent.CategoryDefault, Intent.CategoryBrowsable },
DataScheme = "com.nitload.charac", DataHost = "oauth")]
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 const string AuthUrlExtra = "charac_auth_url";
private const string CallbackPortExtra = "charac_callback_port";
private const string CallbackPortPreference = "oidc_callback_port";
private TextView? _status;
private bool _cliReady;
@@ -28,7 +33,10 @@ public sealed class MainActivity : Activity
FindViewById<Button>(Resource.Id.launch)!.Click += (_, _) => Launch();
ShowPrerequisites();
if (Intent is not null)
{
HandleCheckResult(Intent);
HandleBrokerIntent(Intent);
}
}
protected override void OnResume()
@@ -41,7 +49,117 @@ public sealed class MainActivity : Activity
{
base.OnNewIntent(intent);
if (intent is not null)
{
HandleCheckResult(intent);
HandleBrokerIntent(intent);
}
}
private void HandleBrokerIntent(Intent intent)
{
var brokerUri = intent.Data;
if (brokerUri?.Scheme == "com.nitload.charac" && brokerUri.Host == "oauth" &&
brokerUri.Path == "/start")
{
var brokerAuthorizationUrl = brokerUri.GetQueryParameter("url");
var portText = brokerUri.GetQueryParameter("port");
if (brokerAuthorizationUrl is null || !Uri.TryCreate(brokerAuthorizationUrl, UriKind.Absolute, out var parsedUrl) ||
parsedUrl.Scheme != Uri.UriSchemeHttps || !int.TryParse(portText, out var port) ||
port is <= 0 or > 65535)
{
SetStatus("OIDC 启动参数无效。请从 Termux 重新发起登录。", false);
return;
}
StartBrowserForAuthorization(parsedUrl, port);
return;
}
if (intent.GetStringExtra(AuthUrlExtra) is { Length: > 0 } authorizationUrl)
{
var port = intent.GetIntExtra(CallbackPortExtra, 0);
if (port is <= 0 or > 65535)
{
SetStatus("OIDC 回调端口无效。请从 Termux 重新发起登录。", false);
return;
}
var preferences = GetPreferences(FileCreationMode.Private);
if (preferences is null)
{
SetStatus("无法保存 OIDC 回调状态。请从 Termux 重新发起登录。", false);
return;
}
var editor = preferences.Edit();
if (editor is null)
{
SetStatus("无法保存 OIDC 回调状态。请从 Termux 重新发起登录。", false);
return;
}
var updatedEditor = editor.PutInt(CallbackPortPreference, port);
if (updatedEditor is null)
{
SetStatus("无法保存 OIDC 回调状态。请从 Termux 重新发起登录。", false);
return;
}
updatedEditor.Apply();
if (Uri.TryCreate(authorizationUrl, UriKind.Absolute, out var parsedUrl) &&
parsedUrl.Scheme == Uri.UriSchemeHttps)
StartBrowserForAuthorization(parsedUrl, port);
else
SetStatus("OIDC 授权地址无效。请从 Termux 重新发起登录。", false);
return;
}
var callback = intent.Data;
if (callback is null || callback.Scheme != "com.nitload.charac" ||
callback.Host != "oauth" || callback.Path != "/callback")
return;
var portFromPreferences = GetPreferences(FileCreationMode.Private)!
.GetInt(CallbackPortPreference, 0);
var code = callback.GetQueryParameter("code");
var state = callback.GetQueryParameter("state");
if (portFromPreferences is <= 0 or > 65535 ||
string.IsNullOrWhiteSpace(code) || string.IsNullOrWhiteSpace(state))
{
SetStatus("OIDC 回调缺少必要参数。请从 Termux 重新发起登录。", false);
return;
}
var command = CommandIntent(TermuxBin + "/charac", [
"callback", "--port", portFromPreferences.ToString(),
"--code", code, "--state", state
], true);
if (StartTermux(command))
SetStatus("登录回调已交给 Termux。请返回终端等待完成。", true);
}
private void StartBrowserForAuthorization(Uri authorizationUrl, int port)
{
var preferences = GetPreferences(FileCreationMode.Private);
var editor = preferences?.Edit();
if (editor is null)
{
SetStatus("无法保存 OIDC 回调状态。请从 Termux 重新发起登录。", false);
return;
}
var updatedEditor = editor.PutInt(CallbackPortPreference, port);
if (updatedEditor is null)
{
SetStatus("无法保存 OIDC 回调状态。请从 Termux 重新发起登录。", false);
return;
}
updatedEditor.Apply();
try
{
StartActivity(new Intent(Intent.ActionView, global::Android.Net.Uri.Parse(authorizationUrl.ToString())));
SetStatus("已打开浏览器。完成登录后将自动返回 Termux。", false);
}
catch (Exception exception)
{
SetStatus("无法打开浏览器:" + exception.Message, false);
}
}
private bool HasTermux()
+12 -3
View File
@@ -40,10 +40,19 @@ internal static class ClientConfiguration
}
public static string LoadServer(string? path = null)
{
path = path is null ? (File.Exists(DefaultPath) ? DefaultPath :
File.Exists(LegacyPath) ? LegacyPath : DefaultPath) : Path.GetFullPath(path);
if (path is null)
{
path = File.Exists(DefaultPath) ? DefaultPath :
File.Exists(LegacyPath) ? LegacyPath : DefaultPath;
if (path == DefaultPath)
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
}
else
{
path = Path.GetFullPath(path);
}
if (!File.Exists(path))
throw new InvalidOperationException($"Client configuration not found: {path}. Create [client] server=https://your-server or pass --server.");
throw new InvalidOperationException($"Client configuration not found: {path}. The parent directory has been created; add [client] server=https://your-server or pass --server.");
string? section = null;
string? server = null;
+50 -10
View File
@@ -9,6 +9,8 @@ namespace Charac.Client;
internal static class OidcLogin
{
private const string TermuxBin = "/data/data/com.termux/files/usr/bin";
public static async Task<string> LoginAsync(HttpClient client, string issuer, string clientId,
string discoveryUrl,
CancellationToken cancellationToken = default)
@@ -33,7 +35,10 @@ internal static class OidcLogin
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 termuxBroker = IsTermux();
var redirectUri = termuxBroker
? "com.nitload.charac://oauth/callback"
: $"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));
@@ -53,15 +58,10 @@ internal static class OidcLogin
$"{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.");
}
if (termuxBroker)
StartTermuxBroker(authorizationUri, port);
else
OpenBrowser(authorizationUri);
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(TimeSpan.FromMinutes(3));
@@ -86,6 +86,46 @@ internal static class OidcLogin
return accessToken;
}
private static bool IsTermux() =>
OperatingSystem.IsLinux() &&
(Environment.GetEnvironmentVariable("TERMUX_VERSION") is not null ||
Environment.GetEnvironmentVariable("PREFIX")?.Contains("/com.termux/", StringComparison.Ordinal) == true);
private static void OpenBrowser(Uri 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.");
}
}
private static void StartTermuxBroker(Uri authorizationUri, int port)
{
try
{
var brokerUri = new UriBuilder("com.nitload.charac://oauth/start")
{
Query = $"url={Uri.EscapeDataString(authorizationUri.ToString())}&port={port.ToString(System.Globalization.CultureInfo.InvariantCulture)}"
}.Uri;
var start = new ProcessStartInfo(TermuxBin + "/termux-open-url") { UseShellExecute = false };
start.ArgumentList.Add(brokerUri.ToString());
using var process = Process.Start(start);
process?.WaitForExit(5000);
if (process is null || process.ExitCode != 0)
throw new InvalidOperationException("Termux could not open the Android callback broker.");
}
catch (Exception exception) when (exception is System.ComponentModel.Win32Exception or InvalidOperationException or System.IO.IOException)
{
Console.WriteLine($"Could not start the Android callback broker: {exception.Message}");
Console.WriteLine("Open the URL manually in a browser on this device.");
}
}
private static async Task<string> ReceiveCodeAsync(TcpListener listener, string expectedState,
CancellationToken cancellationToken)
{
+35
View File
@@ -14,6 +14,17 @@ internal static class Program
configPath = args[1];
args = args[2..];
}
if (args.Length >= 2 &&
args[0] is "status" or "login" or "whoami" or "connect" or "run" or "attach" &&
!args.Contains("--server", StringComparer.Ordinal) &&
!args[1].StartsWith("--", StringComparison.Ordinal))
{
var candidate = args[1].Contains("://", StringComparison.Ordinal)
? args[1]
: "https://" + args[1];
if (TryGetServerUri(candidate, out _))
args = [args[0], "--server", candidate, .. args[2..]];
}
if (args.Length == 0 || args is ["--help"] or ["-h"])
{
PrintUsage();
@@ -58,6 +69,9 @@ internal static class Program
return await WorkspaceClient.RunLocalProbeAsync(probeServer);
if (args is ["shell", "--server", var shellServer])
return await WorkspaceClient.RunLocalShellAsync(shellServer);
if (args is ["callback", "--port", var portText, "--code", var code, "--state", var state] &&
int.TryParse(portText, out var callbackPort) && callbackPort is > 0 and <= 65535)
return await SendCallbackAsync(callbackPort, code, state);
PrintUsage(Console.Error);
return 2;
@@ -100,6 +114,26 @@ internal static class Program
}
}
private static async Task<int> SendCallbackAsync(int port, string code, string state)
{
try
{
using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
var callback = new UriBuilder(Uri.UriSchemeHttp, "127.0.0.1", port, "/callback")
{
Query = $"code={Uri.EscapeDataString(code)}&state={Uri.EscapeDataString(state)}"
}.Uri;
using var response = await client.GetAsync(callback);
response.EnsureSuccessStatusCode();
return 0;
}
catch (Exception exception) when (exception is HttpRequestException or TaskCanceledException or UriFormatException)
{
Console.Error.WriteLine($"OIDC callback forwarding failed: {exception.Message}");
return 1;
}
}
internal static bool TryGetServerUri(string value, out Uri serverUri)
{
serverUri = null!;
@@ -131,6 +165,7 @@ internal static class Program
output.WriteLine(" charac attach --server https://access.example.com --id <workspace-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(" charac callback --port <port> --code <code> --state <state>");
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.");
@@ -50,7 +50,7 @@ internal static class WorkspaceAccessConfiguration
{
for (var directory = new DirectoryInfo(start); directory is not null; directory = directory.Parent)
{
if (File.Exists(Path.Combine(directory.FullName, "WorkspaceAccess.slnx")))
if (File.Exists(Path.Combine(directory.FullName, "Charac.slnx")))
return directory.FullName;
}
}
-2
View File
@@ -11,8 +11,6 @@ listen=http://127.0.0.1:5080
; issuer=https://auth.example.com/application/o/workspace-access/
; client_id=<authentik-client-id>
; discovery_url=https://auth.example.com/application/o/workspace-access/.well-known/openid-configuration
; Optional: the OIDC CLI playground uses this URL to call Server /identity.
; server_url=http://127.0.0.1:5080
; Local same-service-account control channel. Enable user disconnection only for an isolated service account.
; [management]