Compare commits
9
Commits
7b2a63a4c3
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4b82aeea7 | ||
|
|
3f3fd613db | ||
|
|
6d072b3718 | ||
|
|
f9effc890b | ||
|
|
111029fde3 | ||
|
|
63b37047e1 | ||
|
|
d7bab46552 | ||
|
|
8c0edfd546
|
||
|
|
9fa9f29259 |
@@ -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,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>
|
||||
|
||||
@@ -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.
|
||||
@@ -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)。
|
||||
|
||||
## 构建与运行
|
||||
|
||||
@@ -39,3 +39,11 @@ deploy/ Windows Service 与 systemd 模板
|
||||
```
|
||||
|
||||
阅读[文档库](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
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
@@ -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>]");
|
||||
}
|
||||
}
|
||||
@@ -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)。
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"version": 2,
|
||||
"dependencies": {
|
||||
"net10.0": {}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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。
|
||||
|
||||
## 信任与生命周期
|
||||
|
||||
|
||||
@@ -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
@@ -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 目标 | 行为 |
|
||||
| --- | --- |
|
||||
|
||||
@@ -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)。
|
||||
|
||||
## 待实现
|
||||
|
||||
|
||||
@@ -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 中完成后,创建入口:
|
||||
|
||||
|
||||
@@ -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
@@ -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('\', '/')
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user