Anping 中文

Samples

Small programs that get a link for your users. They are in the repository's samples/ folder; copy what you need.

The sample page asks your backend for a link; the backend, a C# Azure Function or the Node.js server, asks Anping with the API key from its settings; the page opens the link. Sample page iframe-page/ Your server C# Azure Function or javascript/server.mjs Anping server /ws/oturl/get POST /api/anping-link {"url": "..."} POST /ws/oturl/get /oturl.html?... ANPING_HOST, ANPING_API_KEY, ANPING_MODEL your sign-in names the user open the link: new tab (index.html) or iframe (embed.html) The sample page asks your backend for a link; the backend, a C# Azure Function or the Node.js server, asks Anping with the API key from its settings; the page opens the link. Sample page iframe-page/ POST /api/anping-link {"url": "..."} Your server C# Azure Function or javascript/server.mjs ANPING_HOST ANPING_API_KEY ANPING_MODEL your sign-in names the user POST /ws/oturl/get /oturl.html?... Anping server /ws/oturl/get open the link: new tab (index.html) or iframe (embed.html)
The secret key stays on your server. The web page only ever gets the link.
Every sampleWhat it does
Calls AnpingAsks Anping from your server (as a form, POST) and returns the whole link. It never writes the secret key or the link to a log.
Moving from ThinfinityYour current request (GET) can stay for a while (when to switch).
LicenceCopy the code into your own programs, changed or not, without naming us.
Technical detailMIT No Attribution (MIT-0): samples/LICENSE, and an SPDX line in each file.

C# Azure Function

samples/csharp-azure-function/: an Azure Function (.NET 10, isolated worker). Your page sends POST /api/anping-link and gets back {"url": "..."}.

TopicHow
App settingsThe three settings in the drawing; in Azure the secret key can come from Key Vault.
Run locallyCopy local.settings.example.json to local.settings.json, fill it in, func start.
Sign-in is requiredIn Azure, only signed-in users get a link; everyone else gets an error (401). Turn on App Service Authentication; the signed-in user becomes apiuser.
Technical detailApp Service Authentication on the Function App sets WEBSITE_AUTH_ENABLED to true and adds the user in X-MS-CLIENT-PRINCIPAL. Turn it on with your identity provider, such as Microsoft Entra ID. To use your own sign-in instead, change WhoIsAsking; never trust a user name the browser sends.
LocallyOn your own computer there is no sign-in: every request counts as the user local-test. Never in Azure.
MoreWith func start and AZURE_FUNCTIONS_ENVIRONMENT=Development, as in the example settings.
Anping client 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.");
}
Functions 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;
    }
}
Startup 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();
Project 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/: needs Node 20 or later, nothing to install.

FileWhat it does
anping.mjscreateLink() asks Anping for a link (as a form, POST).
get-link.mjsPrints one link: node get-link.mjs.
server.mjsA small server for the web page sample, answering POST /api/anping-link. It has no sign-in and only answers on your own computer (127.0.0.1): add your sign-in in whoIsAsking before you use it anywhere else.
ANPING_HOST=https://anping.example.com ANPING_API_KEY=... ANPING_MODEL=sales-desktop \
  node samples/javascript/server.mjs
# open http://localhost:3000/
Client 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
}
Command line 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)
}
Backend 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}/`))

Web page

samples/iframe-page/, a plain web page. index.html opens the desktop in a new tab, right when the user clicks, so the browser doesn't block it. embed.html shows it inside the page; add the page's address to Oturl:FrameAncestors.

Script 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
  }
})
New tab page 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 page 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>