Samples
Small programs that get a link for your users. They are in the repository's samples/ folder; copy what you need.
| Every sample | What it does |
|---|---|
| Calls Anping | Asks 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 Thinfinity | Your current request (GET) can stay for a while (when to switch). |
| Licence | Copy 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": "..."}.
| Topic | How |
|---|---|
| App settings | The three settings in the drawing; in Azure the secret key can come from Key Vault. |
| Run locally | Copy local.settings.example.json to local.settings.json, fill it in, func start. |
| Sign-in is required | In 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 setsWEBSITE_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. |
| Locally | On your own computer there is no sign-in: every request counts as the user local-test. Never in Azure.MoreWithfunc 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://<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;
}
}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.
| File | What it does |
|---|---|
anping.mjs | createLink() asks Anping for a link (as a form, POST). |
get-link.mjs | Prints one link: node get-link.mjs. |
server.mjs | A 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>