Can't run wc.aspx? Use the ASP.NET Core relay
- wc.aspx needs classic ASP.NET (.NET Framework). On an ASP.NET Core site, or any IIS app pool set to "No Managed Code", IIS will not run it.
- This relay does the same job as a small ASP.NET Core app. Same address (
/wc.aspx?zr=...), same responses, so the counter snippet does not change. - Pick one of two ways to mount it: as its own IIS application at
/wc.aspx, or as a route inside your existing ASP.NET Core app.
What you need
- Your site key and relay secret. Sign in, open My sites, choose the site, and look under Blocker-proof install. The relay secret is shown there; keep it private.
- The .NET 9 runtime and the ASP.NET Core Hosting Bundle on the server (you already have these if your site runs on ASP.NET Core 9).
- The .NET 9 SDK on the machine where you build.
The relay code
Two files. This is the exact code we run on our own sites. One change for this page: the fallback site key is shown as YOUR_SITE_KEY. You never edit the code to configure it - the key and secret come from configuration.
Program.cs
// WebCounter first-party relay - ASP.NET Core port of C:\Clients\Temp\wc.aspx (task 838).
// Mounted as the IIS application /wc.aspx under calcsa.co.za, so the snippet's "/wc.aspx?zr=" works unchanged
// on a No Managed Code site. Same query params (zr=js|css|n|h|m), same responses, same fail-silent 204.
// RelaySecret lives in appsettings.Production.json on the server only (WebCounter:RelaySecret).
using System.Net;
using System.Text;
using Microsoft.Extensions.Caching.Memory;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddMemoryCache();
builder.Services.AddHttpClient("wc", c => c.Timeout = TimeSpan.FromMilliseconds(TimeoutMs))
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler { AllowAutoRedirect = false, UseCookies = false });
var app = builder.Build();
var siteKey = app.Configuration["WebCounter:SiteKey"] ?? "YOUR_SITE_KEY";
var relaySecret = app.Configuration["WebCounter:RelaySecret"] ?? "";
var gif = Convert.FromBase64String("R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7");
app.Run(async ctx =>
{
var res = ctx.Response;
// X-Robots-Tag: noindex comes from the parent site's web.config customHeaders; setting it here doubled it.
var http = ctx.RequestServices.GetRequiredService<IHttpClientFactory>().CreateClient("wc");
var cache = ctx.RequestServices.GetRequiredService<IMemoryCache>();
try
{
switch (ctx.Request.Query["zr"].ToString())
{
case "js": await Asset("/w/v1/counter.js", "application/javascript; charset=utf-8"); break;
case "css": await Asset("/w/v1/counter.css", "text/css; charset=utf-8"); break;
case "n": await Pass(HttpMethod.Get, "/count/" + Uri.EscapeDataString(siteKey), null); break;
case "h": await Pass(HttpMethod.Post, "/hit", await Body()); break;
case "m":
try { await Call(HttpMethod.Get, "/hit.gif?k=" + Uri.EscapeDataString(siteKey), null); } catch { }
res.ContentType = "image/gif";
res.Headers.CacheControl = "no-store";
await res.Body.WriteAsync(gif);
break;
default: res.StatusCode = 204; break;
}
}
catch
{
if (!res.HasStarted) { res.Clear(); res.StatusCode = 204; }
}
// Script and stylesheet: cached for a few hours (a stale copy is kept for a week) so WebCounter being slow
// or down never delays the page.
async Task Asset(string path, string type)
{
string fresh = "wcr:" + path, stale = "wcr-stale:" + path;
if (!cache.TryGetValue(fresh, out string? body))
{
try
{
var r = await Call(HttpMethod.Get, path, null);
if (r.Status == 200)
{
body = Encoding.UTF8.GetString(r.Body);
cache.Set(fresh, body, TimeSpan.FromMinutes(ScriptCacheMinutes));
cache.Set(stale, body, TimeSpan.FromDays(7));
}
}
catch { }
if (body == null) cache.TryGetValue(stale, out body);
}
if (body == null) { res.StatusCode = 204; return; }
res.ContentType = type;
res.Headers.CacheControl = "public, max-age=" + (ScriptCacheMinutes * 60);
await res.WriteAsync(body);
}
async Task Pass(HttpMethod method, string path, byte[]? body)
{
var r = await Call(method, path, body);
res.StatusCode = r.Status;
res.ContentType = r.Type ?? "application/json";
res.Headers.CacheControl = "no-store";
await res.Body.WriteAsync(r.Body);
}
async Task<byte[]> Body()
{
var buf = new byte[4096];
int n = 0, read;
while (n < buf.Length && (read = await ctx.Request.Body.ReadAsync(buf.AsMemory(n, buf.Length - n))) > 0) n += read;
if (n > 2048) throw new InvalidOperationException("too large");
return buf[..n];
}
async Task<(int Status, string? Type, byte[] Body)> Call(HttpMethod method, string path, byte[]? body)
{
var req = new HttpRequestMessage(method, Upstream + path);
var h = ctx.Request.Headers;
req.Headers.TryAddWithoutValidation("User-Agent", h.UserAgent.ToString());
if (Uri.TryCreate(h.Referer.ToString(), UriKind.Absolute, out var referer)) req.Headers.Referrer = referer;
foreach (var name in new[] { "Accept-Language", "Origin" })
if (!string.IsNullOrEmpty(h[name])) req.Headers.TryAddWithoutValidation(name, h[name].ToString());
req.Headers.TryAddWithoutValidation("X-Relay-Key", relaySecret);
req.Headers.TryAddWithoutValidation("X-Relay-For", VisitorIp());
var country = h["CF-IPCountry"].ToString();
if (!string.IsNullOrEmpty(country)) req.Headers.TryAddWithoutValidation("X-Relay-Country", country);
if (body != null)
{
req.Content = new ByteArrayContent(body);
req.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("text/plain");
}
using var r = await http.SendAsync(req, ctx.RequestAborted);
return ((int)r.StatusCode, r.Content.Headers.ContentType?.ToString(), await r.Content.ReadAsByteArrayAsync());
}
// Behind Cloudflare the visitor is in CF-Connecting-IP; otherwise the socket address.
string VisitorIp()
{
var cf = ctx.Request.Headers["CF-Connecting-IP"].ToString();
if (!string.IsNullOrEmpty(cf) && IPAddress.TryParse(cf.Trim(), out var ip)) return ip.ToString();
return ctx.Connection.RemoteIpAddress?.ToString() ?? "";
}
});
app.Run();
partial class Program
{
const string Upstream = "https://webcounter.co.za";
const int TimeoutMs = 2000;
const int ScriptCacheMinutes = 180;
}
WcRelay.csproj
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<InvariantGlobalization>false</InvariantGlobalization>
</PropertyGroup>
</Project>
appsettings.Production.json
Put this next to the app on the server only, with your own values. Do not commit the secret to source control.
{
"WebCounter": {
"SiteKey": "YOUR_SITE_KEY",
"RelaySecret": "YOUR_RELAY_SECRET"
}
}
Option 1: its own IIS application at /wc.aspx
Best when you don't want to touch your site's code. This is how we run it on our own sites.
- Build it: put the two files in a folder and run
dotnet publish -c Release -o C:\inetpub\yoursite-wc. - Add
appsettings.Production.json(above) to that folder. - In IIS Manager, create an application pool (for example
yoursite-wc) with .NET CLR version: No Managed Code. - Right-click your site > Add Application. Alias:
wc.aspx. Application pool: the one you just made. Physical path: the publish folder.
The publish step writes this web.config for you:
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<location path="." inheritInChildApplications="false">
<system.webServer>
<handlers>
<add name="aspNetCore" path="*" verb="*" modules="AspNetCoreModuleV2" resourceType="Unspecified" />
</handlers>
<aspNetCore processPath="dotnet" arguments=".\WcRelay.dll" stdoutLogEnabled="false" stdoutLogFile=".\logs\stdout" hostingModel="inprocess" />
</system.webServer>
</location>
</configuration>
Option 2: a route inside your existing ASP.NET Core app
Best when you already deploy an ASP.NET Core app and would rather not run a second one.
- Register the same two services in your
Program.cs, beforebuilder.Build(). - Copy the
siteKey,relaySecretandgiflines, and the three constants (Upstream,TimeoutMs,ScriptCacheMinutes). - Map
/wc.aspxand paste in the body of the relay'sapp.Run(async ctx => { ... })handler unchanged. - Put the
WebCountersection in your ownappsettings.Production.json.
// Program.cs of your existing app
const string Upstream = "https://webcounter.co.za";
const int TimeoutMs = 2000;
const int ScriptCacheMinutes = 180;
builder.Services.AddMemoryCache();
builder.Services.AddHttpClient("wc", c => c.Timeout = TimeSpan.FromMilliseconds(TimeoutMs))
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler { AllowAutoRedirect = false, UseCookies = false });
var app = builder.Build();
var siteKey = app.Configuration["WebCounter:SiteKey"] ?? "YOUR_SITE_KEY";
var relaySecret = app.Configuration["WebCounter:RelaySecret"] ?? "";
var gif = Convert.FromBase64String("R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7");
app.Map("/wc.aspx", wc => wc.Run(async ctx =>
{
// ... the whole body of the relay's app.Run(async ctx => { ... }) handler, unchanged ...
}));
// ... your existing middleware and endpoints ...
Put the counter on your page
Use this instead of the standard counter code, not as well, or visitors are counted twice. Replace YOUR_SITE_KEY with your site key.
<div class="webcounter" data-key="YOUR_SITE_KEY"></div>
<script async src="/wc.aspx?zr=js" data-key="YOUR_SITE_KEY" data-relay="/wc.aspx?zr="></script>
<noscript><img src="/wc.aspx?zr=m" alt="" width="1" height="1"></noscript>
Test it
Open these three addresses on your own site. Each should load with status 200:
https://yoursite/wc.aspx?zr=js- the counter script.https://yoursite/wc.aspx?zr=css- the counter stylesheet.https://yoursite/wc.aspx?zr=m- a 1x1 transparent image.
- A blank page (status 204) means the relay is running but could not reach WebCounter. Check the server can make outgoing HTTPS requests to webcounter.co.za.
- A 404 or 500 means IIS is not running the relay. Check the application alias is exactly
wc.aspxand the app pool is No Managed Code. zr=nandzr=hare used by the counter itself and are not meant to be opened in a browser.
Your relay can have another name: change wc.aspx in the alias (or route) and in all three places in the snippet.