7 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
11 changed files with 291 additions and 26 deletions
+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>
+5
View File
@@ -1,5 +1,10 @@
# 身份认证与授权
桌面 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 和操作系统建立。不同外部身份即使映射到同一个系统账户,工作区归属仍独立。
+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 安装级设备凭据仍独立存于用户本地应用数据目录,不能复制到多台设备共用。
+57 -3
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)) {
@@ -63,7 +116,7 @@ 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: 2')
$lines.Add('arch: aarch64')
@@ -86,9 +139,10 @@ if ($Target -eq 'ClientTermuxDeb') {
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}-2_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
}
+2 -2
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="*">
+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.");