Anping English

範例

替你的使用者申請連結的小程式,放在 repository 的 samples/ 資料夾,需要什麼就複製。

範例頁面向你的後端要連結;後端(C# Azure Function 或 Node.js 伺服器)用設定裡的 API 金鑰向 Anping 申請;頁面再開啟連結。 範例頁面 iframe-page/ 你的伺服器 C# Azure Function 或 javascript/server.mjs Anping 伺服器 /ws/oturl/get POST /api/anping-link {"url": "..."} POST /ws/oturl/get /oturl.html?... ANPING_HOST、ANPING_API_KEY、ANPING_MODEL 你的登入決定使用者 開啟連結:新分頁(index.html)或 iframe(embed.html) 範例頁面向你的後端要連結;後端(C# Azure Function 或 Node.js 伺服器)用設定裡的 API 金鑰向 Anping 申請;頁面再開啟連結。 範例頁面 iframe-page/ POST /api/anping-link {"url": "..."} 你的伺服器 C# Azure Function 或 javascript/server.mjs ANPING_HOST ANPING_API_KEY ANPING_MODEL 你的登入決定使用者 POST /ws/oturl/get /oturl.html?... Anping 伺服器 /ws/oturl/get 開啟連結: 新分頁(index.html)或 iframe(embed.html)
秘密金鑰留在你的伺服器上,網頁只會拿到連結。
每個範例做什麼
呼叫 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://&lt;anping host&gt;/oturl.html?key=..&amp;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.mjscreateLink() 向 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>