範例
替你的使用者申請連結的小程式,放在 repository 的 samples/ 資料夾,需要什麼就複製。
| 每個範例 | 做什麼 |
|---|---|
| 呼叫 Anping | 從你的伺服器向 Anping 申請(用表單方式,POST),回傳完整連結。秘密金鑰和連結都不會寫進記錄。 |
| 從 Thinfinity 移過來 | 你現在的請求(GET)可以先保留(什麼時候改)。 |
| 授權 | 程式碼可以複製到你自己的程式裡,改不改都行,也不必標示出處。技術細節MIT No Attribution(MIT-0):samples/LICENSE,每個檔案也有一行 SPDX。 |
C# Azure Function
samples/csharp-azure-function/:一個 Azure Function(.NET 10,isolated worker)。你的網頁送出 POST /api/anping-link,拿回 {"url": "..."}。
| 項目 | 怎麼做 |
|---|---|
| 應用程式設定 | 圖裡那三個設定;在 Azure 上,秘密金鑰可以放在 Key Vault。 |
| 在本機執行 | 把 local.settings.example.json 複製成 local.settings.json,填好,func start。 |
| 一定要登入 | 在 Azure 上,只有登入的使用者拿得到連結,其他人會得到錯誤(401)。請開啟 App Service 驗證;登入的使用者就是 apiuser。技術細節在 Function App 開啟 App Service 驗證後,WEBSITE_AUTH_ENABLED 為 true,使用者放在 X-MS-CLIENT-PRINCIPAL 裡。請用你的身分識別提供者開啟它,例如 Microsoft Entra ID。要改用你自己的登入,請修改 WhoIsAsking;永遠不要相信瀏覽器送來的使用者名稱。 |
| 本機 | 在你自己的電腦上沒有登入:每個請求都當成使用者 local-test。在 Azure 上永遠不會這樣。更多用func start 並設定 AZURE_FUNCTIONS_ENVIRONMENT=Development 時,範例設定就是這樣。 |
Anping 用戶端 samples/csharp-azure-function/AnpingClient.cs
// SPDX-License-Identifier: MIT-0
using Microsoft.Extensions.Configuration;
namespace AnpingLink;
/// <summary>A full link (Url), or why Anping refused (Status and Anping's reason, which never repeats a value).</summary>
public sealed record AnpingResult(string? Url, int Status, string? Reason);
/// <summary>
/// Asks Anping for a one-time link: POST /ws/oturl/get with a form body.
/// Host, API key and model come from app settings. Never log the API key or the returned link:
/// the link's key and pass open a desktop.
/// </summary>
public sealed class AnpingClient(HttpClient http, IConfiguration config)
{
public async Task<AnpingResult> CreateLinkAsync(string apiUser, CancellationToken cancellationToken)
{
var host = Setting("ANPING_HOST").TrimEnd('/');
var fields = new Dictionary<string, string>
{
["apikey"] = Setting("ANPING_API_KEY"),
["apiuser"] = apiUser,
["model"] = Setting("ANPING_MODEL"),
// Any other OTURL parameter goes here, set by your code (never by the browser),
// such as ["expires"] = "5".
};
// POST keeps the API key out of URLs, so out of request logs and telemetry on both sides.
using var request = new HttpRequestMessage(HttpMethod.Post, $"{host}/ws/oturl/get") { Content = new FormUrlEncodedContent(fields) };
using var response = await http.SendAsync(request, cancellationToken);
var body = (await response.Content.ReadAsStringAsync(cancellationToken)).Trim();
var status = (int)response.StatusCode;
if (!response.IsSuccessStatusCode)
{
return new(null, status, body.Length > 200 ? body[..200] : body);
}
// The answer is a path: append it to the server address, as with Thinfinity.
return body.StartsWith("/oturl.html?", StringComparison.Ordinal)
? new(host + body, status, null)
: new(null, status, "Unexpected answer.");
}
private string Setting(string name) =>
config[name] is { Length: > 0 } value ? value : throw new InvalidOperationException($"App setting {name} is missing.");
}Function samples/csharp-azure-function/AnpingLinkFunctions.cs
// SPDX-License-Identifier: MIT-0
using System.Text.Json;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Extensions.Logging;
namespace AnpingLink;
/// <summary>
/// POST /api/anping-link returns {"url": "https://<anping host>/oturl.html?key=..&pass=.."}
/// for your page to open in a new tab or an iframe. Only users signed in through App Service
/// Authentication get one; every other request gets 401.
/// </summary>
public sealed class AnpingLinkFunctions(AnpingClient anping, ILogger<AnpingLinkFunctions> logger)
{
[Function("AnpingLink")]
public async Task<IActionResult> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "anping-link")] HttpRequest request,
CancellationToken cancellationToken)
{
request.HttpContext.Response.Headers.CacheControl = "no-store";
if (WhoIsAsking(request) is not { } user)
{
return new UnauthorizedResult();
}
var result = await anping.CreateLinkAsync(user, cancellationToken);
if (result.Url is null)
{
// Status and Anping's reason only: never the API key or a link.
logger.LogWarning("Anping refused a link: {Status} {Reason}", result.Status, result.Reason);
return new ObjectResult(new { error = "Could not get a desktop link." }) { StatusCode = StatusCodes.Status502BadGateway };
}
logger.LogInformation("Anping link created for {ApiUser}", user);
return new OkObjectResult(new { url = result.Url });
}
// Your program decides who may open a desktop. This sample fails closed: it needs App Service
// Authentication turned on for the Function App, which signs users in, sets WEBSITE_AUTH_ENABLED
// and adds X-MS-CLIENT-PRINCIPAL to their requests (clients can't set that header themselves
// then). Without it every request gets 401. Put your own sign-in check here if you have one and
// return the user's name or e-mail (Anping keeps it as apiuser).
private static string? WhoIsAsking(HttpRequest request)
{
if (IsLocalRun())
{
return "local-test";
}
if (!bool.TryParse(Environment.GetEnvironmentVariable("WEBSITE_AUTH_ENABLED"), out var authEnabled) || !authEnabled)
{
return null;
}
return SignedInName(request.Headers["X-MS-CLIENT-PRINCIPAL"].ToString());
}
// func start with local.settings.json (AZURE_FUNCTIONS_ENVIRONMENT=Development) has no sign-in.
// Azure sets WEBSITE_INSTANCE_ID, so this never applies there.
private static bool IsLocalRun() =>
Environment.GetEnvironmentVariable("AZURE_FUNCTIONS_ENVIRONMENT") == "Development"
&& string.IsNullOrEmpty(Environment.GetEnvironmentVariable("WEBSITE_INSTANCE_ID"));
// X-MS-CLIENT-PRINCIPAL is base64 JSON: {"auth_typ", "claims": [{"typ", "val"}], "name_typ", "role_typ"}.
// The user's name is the claim whose typ is name_typ.
private static string? SignedInName(string header)
{
if (header.Length is 0 or > 32_768)
{
return null;
}
try
{
using var principal = JsonDocument.Parse(Convert.FromBase64String(header));
var nameType = principal.RootElement.GetProperty("name_typ").GetString();
foreach (var claim in principal.RootElement.GetProperty("claims").EnumerateArray())
{
if (claim.GetProperty("typ").GetString() == nameType && claim.GetProperty("val").GetString() is { } name && !string.IsNullOrWhiteSpace(name))
{
return name;
}
}
}
catch (Exception e) when (e is FormatException or JsonException or InvalidOperationException or KeyNotFoundException)
{
// Not a client principal: nobody is signed in.
}
return null;
}
}啟動 samples/csharp-azure-function/Program.cs
// SPDX-License-Identifier: MIT-0
using AnpingLink;
using Microsoft.Azure.Functions.Worker.Builder;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
var builder = FunctionsApplication.CreateBuilder(args);
builder.ConfigureFunctionsWebApplication();
builder.Services.AddHttpClient<AnpingClient>(http => http.Timeout = TimeSpan.FromSeconds(10));
builder.Build().Run();專案檔 samples/csharp-azure-function/AnpingLink.csproj
<!-- SPDX-License-Identifier: MIT-0 -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<AzureFunctionsVersion>v4</AzureFunctionsVersion>
<OutputType>Exe</OutputType>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Azure.Functions.Worker" Version="2.51.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore" Version="2.1.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Sdk" Version="2.0.7" />
</ItemGroup>
<ItemGroup>
<None Update="host.json" CopyToOutputDirectory="PreserveNewest" />
<None Update="local.settings.json" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="Never" />
</ItemGroup>
</Project>Node.js
samples/javascript/:需要 Node 20 以上,不必另外安裝任何東西。
| 檔案 | 作用 |
|---|---|
anping.mjs | createLink() 向 Anping 申請連結(用表單方式,POST)。 |
get-link.mjs | 印出一個連結:node get-link.mjs。 |
server.mjs | 給網頁範例用的小伺服器,回應 POST /api/anping-link。它沒有登入,只在你自己的電腦上回應(127.0.0.1):要在別的地方用之前,先在 whoIsAsking 加上你的登入。 |
ANPING_HOST=https://anping.example.com ANPING_API_KEY=... ANPING_MODEL=sales-desktop \
node samples/javascript/server.mjs
# open http://localhost:3000/用戶端 samples/javascript/anping.mjs
// SPDX-License-Identifier: MIT-0
// Asks Anping for a one-time link (POST /ws/oturl/get with a form body).
// Node 20+ (built-in fetch), no packages. Never log apiKey or the returned link: the link's
// key and pass open a desktop.
/** Anping refused the request. `reason` is Anping's short text, which never repeats a value. */
export class AnpingError extends Error {
constructor(status, reason) {
super(`Anping answered ${status}: ${reason}`)
this.status = status
this.reason = reason
}
}
/** A message for your logs. Never log error.message of a failed fetch: it may hold the request URL. */
export function describeError(error) {
if (error instanceof AnpingError) return error.message
const cause = error?.cause
return `Could not reach Anping (${cause?.code ?? cause?.errors?.[0]?.code ?? error?.name}).`
}
/**
* Returns the full link (https://<host>/oturl.html?key=..&pass=..).
* host: Anping's address, such as https://anping.example.com
* params: more OTURL parameters, set by your server (never by the browser), such as { expires: 5 }
* POST keeps the API key out of URLs, so out of request logs and telemetry on both sides.
*/
export async function createLink({ host, apiKey, apiUser, model, params = {}, timeoutMs = 10_000 }) {
const base = String(host).replace(/\/+$/, '')
const fields = new URLSearchParams({ apikey: apiKey, apiuser: apiUser, ...(model ? { model } : {}) })
for (const [name, value] of Object.entries(params)) fields.set(name, String(value))
const response = await fetch(`${base}/ws/oturl/get`, { method: 'POST', body: fields, signal: AbortSignal.timeout(timeoutMs) })
const text = (await response.text()).trim()
if (!response.ok) throw new AnpingError(response.status, text.slice(0, 200))
// The answer is a path: append it to the server address, as with Thinfinity.
if (!text.startsWith('/oturl.html?')) throw new AnpingError(response.status, 'Unexpected answer.')
return base + text
}命令列 samples/javascript/get-link.mjs
// SPDX-License-Identifier: MIT-0
// Prints one full Anping link. Treat it like a password until it is opened.
// ANPING_HOST=https://anping.example.com ANPING_API_KEY=... ANPING_MODEL=... node get-link.mjs
import { createLink, describeError } from './anping.mjs'
const { ANPING_HOST, ANPING_API_KEY, ANPING_MODEL, ANPING_API_USER = 'sample-user' } = process.env
if (!ANPING_HOST || !ANPING_API_KEY || !ANPING_MODEL) {
console.error('Set ANPING_HOST, ANPING_API_KEY and ANPING_MODEL.')
process.exit(2)
}
try {
const url = await createLink({
host: ANPING_HOST,
apiKey: ANPING_API_KEY,
apiUser: ANPING_API_USER,
model: ANPING_MODEL,
})
console.log(url)
} catch (error) {
console.error(describeError(error))
process.exit(1)
}後端 samples/javascript/server.mjs
// SPDX-License-Identifier: MIT-0
// A small backend for the iframe-page sample: serves ../iframe-page and answers
// POST /api/anping-link with {"url": "<full Anping link>"}. Node 20+, no packages.
// ANPING_HOST=... ANPING_API_KEY=... ANPING_MODEL=... PORT=3000 node server.mjs
import { createServer } from 'node:http'
import { readFile } from 'node:fs/promises'
import { extname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { createLink, describeError } from './anping.mjs'
const { ANPING_HOST, ANPING_API_KEY, ANPING_MODEL, PORT = '3000' } = process.env
if (!ANPING_HOST || !ANPING_API_KEY || !ANPING_MODEL) {
console.error('Set ANPING_HOST, ANPING_API_KEY and ANPING_MODEL.')
process.exit(2)
}
const pages = fileURLToPath(new URL('../iframe-page/', import.meta.url))
const files = { '/': 'index.html', '/index.html': 'index.html', '/embed.html': 'embed.html', '/app.js': 'app.js', '/style.css': 'style.css' }
const types = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8' }
// Your program decides who may open a desktop: put your own sign-in check here and return the
// signed-in user's name or e-mail (Anping keeps it as apiuser). This sample has no sign-in.
function whoIsAsking(/* req: your session cookie or token */) {
return process.env.ANPING_API_USER ?? 'sample-user'
}
function send(res, status, type, body) {
res.writeHead(status, { 'Content-Type': type, 'Cache-Control': 'no-store' })
res.end(body)
}
const server = createServer(async (req, res) => {
const path = new URL(req.url, 'http://localhost').pathname
if (path === '/api/anping-link') {
if (req.method !== 'POST') return send(res, 405, 'text/plain', 'Use POST.')
const user = whoIsAsking(req)
if (!user) return send(res, 401, 'text/plain', 'Sign in first.')
try {
const url = await createLink({ host: ANPING_HOST, apiKey: ANPING_API_KEY, apiUser: user, model: ANPING_MODEL })
return send(res, 200, 'application/json', JSON.stringify({ url }))
} catch (error) {
// Log the status and Anping's reason only; never the key or the link.
console.error(describeError(error))
return send(res, 502, 'application/json', JSON.stringify({ error: 'Could not get a desktop link.' }))
}
}
const file = files[path]
if (req.method !== 'GET' || !file) return send(res, 404, 'text/plain', 'Not found.')
send(res, 200, types[extname(file)], await readFile(join(pages, file)))
})
// Local only: this sample has no sign-in.
server.listen(Number(PORT), '127.0.0.1', () => console.log(`Sample page: http://localhost:${PORT}/`))網頁
samples/iframe-page/,一個普通的網頁。index.html 在使用者點擊的當下用新分頁打開桌面,瀏覽器才不會擋。embed.html 把桌面放在網頁裡;請把這個網頁的網址加進 Oturl:FrameAncestors。
程式 samples/iframe-page/app.js
// SPDX-License-Identifier: MIT-0
// Asks your backend for a one-time Anping link, then opens it in a new tab or an iframe.
// The backend (samples/javascript/server.mjs or the Azure Function) holds the API key; this
// page never sees it. Another origin than this page's needs CORS on the backend.
const LINK_ENDPOINT = '/api/anping-link'
const status = document.getElementById('status')
async function getLink() {
const response = await fetch(LINK_ENDPOINT, { method: 'POST', credentials: 'same-origin', cache: 'no-store' })
if (!response.ok) throw new Error(`The backend answered ${response.status}.`)
const { url } = await response.json()
return url
}
// New tab. Open it during the click, before waiting for the backend: popup blockers allow a new
// window only right after a click. A blocked popup gets a link to click instead.
document.getElementById('open-tab')?.addEventListener('click', async () => {
const tab = window.open('', '_blank')
if (tab) tab.opener = null
status.textContent = 'Getting a link...'
try {
const url = await getLink()
if (tab) {
tab.location.replace(url)
status.textContent = 'The desktop opened in a new tab.'
} else {
offerLink(url)
}
} catch (error) {
tab?.close()
status.textContent = error.message
}
})
function offerLink(url) {
const a = document.createElement('a')
a.href = url
a.target = '_blank'
a.rel = 'noopener noreferrer'
a.textContent = 'Open the desktop'
// A link works once: take it away after the click.
a.addEventListener('click', () => setTimeout(() => a.remove()))
status.replaceChildren('The browser blocked the new tab. ', a)
}
// Iframe. Anping must list this page's origin in Oturl:FrameAncestors.
document.getElementById('embed')?.addEventListener('click', async () => {
status.textContent = 'Getting a link...'
try {
const url = await getLink()
const frame = document.createElement('iframe')
frame.title = 'Remote desktop'
// fullscreen, clipboard and sound (autoplay) inside the frame. Keyboard Lock never works in a
// frame: Anping's Keys menu sends Win, Alt+Tab and Ctrl+Alt+Del instead.
frame.allow = 'fullscreen; clipboard-read; clipboard-write; autoplay'
frame.referrerPolicy = 'no-referrer'
frame.src = url
document.getElementById('desktop').replaceChildren(frame)
status.textContent = ''
} catch (error) {
status.textContent = error.message
}
})新分頁網頁 samples/iframe-page/index.html
<!doctype html>
<!-- SPDX-License-Identifier: MIT-0 -->
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Anping sample: new tab</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main>
<h1>Remote desktop</h1>
<p>Your page asks your backend for a one-time link and opens it in a new tab.</p>
<p><button type="button" id="open-tab">Open the remote desktop</button></p>
<p id="status" role="status"></p>
<p><a href="embed.html">Iframe variant</a></p>
</main>
<script src="app.js"></script>
</body>
</html>iframe 網頁 samples/iframe-page/embed.html
<!doctype html>
<!-- SPDX-License-Identifier: MIT-0 -->
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Anping sample: iframe</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main>
<h1>Remote desktop</h1>
<p>Your page asks your backend for a one-time link and shows it in an iframe.</p>
<p><button type="button" id="embed">Show the remote desktop</button></p>
<p id="status" role="status"></p>
<p><a href="index.html">New tab variant</a></p>
</main>
<div id="desktop" class="desktop"></div>
<script src="app.js"></script>
</body>
</html>