config: Add string replacement for templates, add example config.yml (close #10)

This commit is contained in:
WeebDataHoarder
2025-04-25 17:27:23 +02:00
parent 01df790e30
commit 398675aa3c
12 changed files with 323 additions and 310 deletions

View File

@@ -313,6 +313,10 @@ go-away can take plaintext HTTP/1 and _HTTP/2_ / _h2c_ connections if desired ov
We also support the `autocert` parameter to configure HTTP(s). This will also allow TLS Fingerprinting to be done on incoming clients. This doesn't require any upstream proxies, and we recommend it's exposed directly or via SNI / Layer 4 proxying.
### Config
While most basic configuration can be passed via the command line, we support passing a [config.yml](examples/config.yml) with more advanced setup, including string replacement or custom backends configuration.
### Binary / Go
Requires Go 1.24+. Builds statically without CGo usage.

View File

@@ -103,3 +103,17 @@ footer {
padding: 0.5em 10px;
}
}
body,
html {
height: 100%;
display: flex;
justify-content: center;
align-items: center;
margin-left: auto;
margin-right: auto;
}
.centered-div {
text-align: center;
}

View File

@@ -4,6 +4,7 @@
<title>{{ .Title }}</title>
<link rel="stylesheet" href="{{ .Path }}/assets/static/anubis/style.css?cacheBust={{ .Random }}"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<meta name="referrer" content="origin"/>
{{ range $key, $value := .Meta }}
{{ if eq $key "refresh"}}
<meta http-equiv="{{ $key }}" content="{{ $value }}"/>
@@ -14,132 +15,6 @@
{{ range .HeaderTags }}
{{ . }}
{{ end }}
<style>
body,
html {
height: 100%;
display: flex;
justify-content: center;
align-items: center;
margin-left: auto;
margin-right: auto;
}
.centered-div {
text-align: center;
}
.lds-roller,
.lds-roller div,
.lds-roller div:after {
box-sizing: border-box;
}
.lds-roller {
display: inline-block;
position: relative;
width: 80px;
height: 80px;
}
.lds-roller div {
animation: lds-roller 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;
transform-origin: 40px 40px;
}
.lds-roller div:after {
content: " ";
display: block;
position: absolute;
width: 7.2px;
height: 7.2px;
border-radius: 50%;
background: currentColor;
margin: -3.6px 0 0 -3.6px;
}
.lds-roller div:nth-child(1) {
animation-delay: -0.036s;
}
.lds-roller div:nth-child(1):after {
top: 62.62742px;
left: 62.62742px;
}
.lds-roller div:nth-child(2) {
animation-delay: -0.072s;
}
.lds-roller div:nth-child(2):after {
top: 67.71281px;
left: 56px;
}
.lds-roller div:nth-child(3) {
animation-delay: -0.108s;
}
.lds-roller div:nth-child(3):after {
top: 70.90963px;
left: 48.28221px;
}
.lds-roller div:nth-child(4) {
animation-delay: -0.144s;
}
.lds-roller div:nth-child(4):after {
top: 72px;
left: 40px;
}
.lds-roller div:nth-child(5) {
animation-delay: -0.18s;
}
.lds-roller div:nth-child(5):after {
top: 70.90963px;
left: 31.71779px;
}
.lds-roller div:nth-child(6) {
animation-delay: -0.216s;
}
.lds-roller div:nth-child(6):after {
top: 67.71281px;
left: 24px;
}
.lds-roller div:nth-child(7) {
animation-delay: -0.252s;
}
.lds-roller div:nth-child(7):after {
top: 62.62742px;
left: 17.37258px;
}
.lds-roller div:nth-child(8) {
animation-delay: -0.288s;
}
.lds-roller div:nth-child(8):after {
top: 56px;
left: 12.28719px;
}
@keyframes lds-roller {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>
</head>
<body id="top">
<main>
@@ -154,43 +29,29 @@
src="{{ .Path }}/assets/static/logo.png?cacheBust={{ .Random }}"
/>
{{if .Challenge }}
<p id="status">Loading challenge <em>{{ .Challenge }}</em>...</p>
<p id="status">{{ .Strings.Get "status_loading_challenge" }} <em>{{ .Challenge }}</em>...</p>
{{else if .Error}}
<p id="status">Error: {{ .Error }}</p>
<p id="status">{{ .Strings.Get "status_error" }} {{ .Error }}</p>
{{else}}
<p id="status">Loading...</p>
<p id="status">{{ .Strings.Get "status_loading" }}</p>
{{end}}
{{if not .HideSpinner }}
<div id="spinner" class="lds-roller">
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
</div>
{{end}}
<details style="padding-bottom: 2em;">
<summary>Why am I seeing this?</summary>
<p>You are seeing this because the administrator of this website has set up <a href="https://git.gammaspectra.live/git/go-away">go-away</a> to protect the server against the scourge of <a href="https://thelibre.news/foss-infrastructure-is-under-attack-by-ai-companies/">AI companies aggressively scraping websites</a>. This can and does cause downtime for the websites, which makes their resources inaccessible for everyone.</p>
<p>Please note that some challenges requires the use of modern JavaScript features and some plugins may will disable. Please disable such plugins for this domain (for example, JShelter).</p>
<p>If you have any issues contact the administrator and provide this Request Id: <em>{{ .Id }}</em></p>
<details>
<summary>{{ .Strings.Get "details_title" }}</summary>
{{.Strings.Get "details_text"}}
</details>
<noscript>
<p>
Sadly, you may need to enable JavaScript to get past this challenge. This is required because AI companies have changed
the social contract around how website hosting works.
</p>
</noscript>
{{if .Redirect }}
<a role="button" href="{{ .Redirect }}">Refresh page</a>
<a style="margin-top: 2em; margin-bottom: 2em;" role="button" href="{{ .Redirect }}">{{ .Strings.Get "button_refresh_page" }}</a>
{{end}}
<div id="testarea"></div>
{{if .EndTags }}
<noscript>
{{ .Strings.Get "noscript_warning" }}
</noscript>
{{end}}
<p><small>{{ .Strings.Get "details_contact_admin_with_request_id" }}: <em>{{ .Id }}</em></small></p>
</div>
@@ -198,6 +59,10 @@
<center>
<p>
Protected by <a href="https://git.gammaspectra.live/git/go-away">go-away</a> :: Request Id <em>{{ .Id }}</em>
{{ range .Links }}
:: <a href="{{ .URL }}">{{ .Name }}</a>
{{ end }}
</p>
</center>
</footer>

View File

@@ -8,7 +8,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ .Title }}</title>
<meta name="referrer" content="no-referrer">
<meta name="referrer" content="origin">
{{ range $key, $value := .Meta }}
{{ if eq $key "refresh"}}
@@ -61,36 +61,30 @@
</h2>
{{if .Challenge }}
<h3 id="status">Loading challenge <em>{{ .Challenge }}</em>...</h3>
<h3 id="status">{{ .Strings.Get "status_loading_challenge" }} <em>{{ .Challenge }}</em>...</h3>
{{else if .Error}}
<h3 id="status">Error: {{ .Error }}</h3>
<h3 id="status">{{ .Strings.Get "status_error" }} {{ .Error }}</h3>
{{else}}
<h3 id="status">Loading...</h3>
<h3 id="status">{{ .Strings.Get "status_loading" }}</h3>
{{end}}
<div id="spinner"></div>
<details style="padding-bottom: 2em;">
<summary>Why am I seeing this?</summary>
<p>You are seeing this because the administrator of this website has set up <a href="https://git.gammaspectra.live/git/go-away">go-away</a> to protect the server against the scourge of <a href="https://thelibre.news/foss-infrastructure-is-under-attack-by-ai-companies/">AI companies aggressively scraping websites</a>. This can and does cause downtime for the websites, which makes their resources inaccessible for everyone.</p>
<p>Please note that some challenges requires the use of modern JavaScript features and some plugins may will disable. Please disable such plugins for this domain (for example, JShelter).</p>
<p>If you have any issues contact the administrator and provide the Request Id: <em>{{ .Id }}</em></p>
<details>
<summary>{{ .Strings.Get "details_title" }}</summary>
{{.Strings.Get "details_text"}}
</details>
<noscript>
<p>
Sadly, you may need to enable JavaScript to get past this challenge. This is required because AI companies have changed
the social contract around how website hosting works.
</p>
</noscript>
{{if .Redirect }}
<div class="button-row">
<a role="button" class="ui small primary button" href="{{ .Redirect }}">Refresh page</a>
</div>
<div class="button-row" style="margin-top: 2em; margin-bottom: 2em;" >
<a role="button" class="ui small primary button" href="{{ .Redirect }}">{{ .Strings.Get "button_refresh_page" }}</a>
</div>
{{end}}
<noscript>
{{ .Strings.Get "noscript" }}
</noscript>
<div id="testarea"></div>
<p><small>{{ .Strings.Get "details_contact_admin_with_request_id" }}: <em>{{ .Id }}</em></small></p>
</div>
</div>
</div>
@@ -106,6 +100,9 @@
<footer class="page-footer" role="group" aria-label="">
<div class="left-links" role="contentinfo" aria-label="">
Protected by <a href="https://git.gammaspectra.live/git/go-away">go-away</a> :: Request Id <em>{{ .Id }}</em>
{{ range .Links }}
:: <a href="{{ .URL }}">{{ .Name }}</a>
{{ end }}
</div>
</footer>

101
examples/config.yml Normal file
View File

@@ -0,0 +1,101 @@
# Configuration file
# Parameters that exist both on config and cmdline will have cmdline as preference
bind:
#address: ":8080"
#network: "tcp"
#socket-mode": "0770"
# Enable PROXY mode on this listener, to allow passing origin info. Default false
#proxy: true
# Enable passthrough mode, which will allow traffic onto the backends while rules load. Default false
#passthrough: true
# Enable TLS on this listener and obtain certificates via an ACME directory URL, or letsencrypt
#tls-acme-autocert: "letsencrypt"
# Enable TLS on this listener and obtain certificates via a certificate and key file on disk
# Only set one of tls-acme-autocert or tls-certificate+tls-key
#tls-certificate: ""
#tls-key: ""
# Bind the Go debug port
#bind-debug: ":6060"
# Bind the Prometheus metrics onto /metrics path on this port
#bind-metrics ":9090"
# These links will be shown on the presented challenge or error pages
links:
#- name: Privacy
# url: "/privacy.html"
#- name: Contact
# url: "mailto:admin@example.com"
#- name: Donations
# url: "https://donations.example.com/abcd"
# HTML Template to use for challenge or error pages
# External templates can be included by providing a disk path
# Bundled templates:
# anubis: An Anubis-like template with no configuration parameters
# forgejo: Looks like native Forgejo. Includes logos and resources from your instance. Supports Theme.
#
#challenge-template: "anubis"
# Allows overriding specific settings set on templates. Key-Values will be passed to templates as-is
challenge-template-overrides:
# Set template theme if supported
#Theme: "forgejo-auto"
# Advanced backend configuration
# Backends setup via cmdline will be added here
backends:
# Example HTTP backend and setting client ip header
#"git.example.com":
# url: "http://forgejo:3000"
# ip-header: "X-Client-Ip"
# Example HTTPS backend with host/SNI override, HTTP/2 and no certificate verification
#"ssl.example.com":
# url: "https://127.0.0.1:8443"
# host: ssl.example.com
# http2-enabled: true
# tls-skip-verify: true
# List of strings you can replace to alter the presentation on challenge/error templates
# Can use other languages.
# Note raw HTML is allowed, be careful with it.
# Default strings exist in code, uncomment any to set it
strings:
#title_challenge: "Checking you are not a bot"
#title_error: "Oh no!"
#noscript_warning: "<p>Sadly, you may need to enable JavaScript to get past this challenge. This is required because AI companies have changed the social contract around how website hosting works.</p>"
#details_title: "Why am I seeing this?"
#details_text: >
# <p>
# You are seeing this because the administrator of this website has set up <a href="https://git.gammaspectra.live/git/go-away">go-away</a>
# to protect the server against the scourge of <a href="https://thelibre.news/foss-infrastructure-is-under-attack-by-ai-companies/">AI companies aggressively scraping websites</a>.
# </p>
# <p>
# Mass scraping can and does cause downtime for the websites, which makes their resources inaccessible for everyone.
# </p>
# <p>
# Please note that some challenges requires the use of modern JavaScript features and some plugins may disable these.
# Disable such plugins for this domain (for example, JShelter) if you encounter any issues.
# </p>
#details_contact_admin_with_request_id: "If you have any issues contact the site administrator and provide the following Request Id"
#button_refresh_page: "Refresh page"
#status_loading_challenge: "Loading challenge"
#status_starting_challenge: "Starting challenge"
#status_loading: "Loading..."
#status_calculating: "Calculating..."
#status_challenge_success: "Challenge success!"
#status_challenge_done_took: "Done! Took"
#status_error: "Error:"

View File

@@ -33,6 +33,7 @@ func ServeChallengeScript(w http.ResponseWriter, r *http.Request, reg *Registrat
"Random": utils.CacheBust(),
"Challenge": reg.Name,
"ChallengeScript": script,
"Strings": data.State.Options().Strings,
})
if err != nil {
//TODO: log

View File

@@ -14,9 +14,8 @@ const u = (url = "", params = {}) => {
(async () => {
const status = document.getElementById('status');
const title = document.getElementById('title');
const spinner = document.getElementById('spinner');
status.innerText = 'Starting challenge {{ .Challenge }}...';
status.innerText = '{{ .Strings.Get "status_starting_challenge" }} {{ .Challenge }}...';
try {
const info = await setup({
@@ -25,15 +24,13 @@ const u = (url = "", params = {}) => {
});
if (info != "") {
status.innerText = 'Calculating... ' + info
status.innerText = '{{ .Strings.Get "status_calculating" }} ' + info
} else {
status.innerText = 'Calculating...';
status.innerText = '{{ .Strings.Get "status_calculating" }}';
}
} catch (err) {
title.innerHTML = "Oh no!";
status.innerHTML = `Failed to initialize: ${err.message}`;
spinner.innerHTML = "";
spinner.style.display = "none";
title.innerHTML = '{{ .Strings.Get "title_error" }}';
status.innerHTML = `{{ .Strings.Get "status_error" }} ${err.message}`;
return
}
@@ -44,11 +41,11 @@ const u = (url = "", params = {}) => {
const t1 = Date.now();
console.log({ result, info });
title.innerHTML = "Challenge success!";
title.innerHTML = '{{ .Strings.Get "status_challenge_success" }}';
if (info != "") {
status.innerHTML = `Done! Took ${t1 - t0}ms, ${info}`;
status.innerHTML = `{{ .Strings.Get "status_challenge_done_took" }} ${t1 - t0}ms, ${info}`;
} else {
status.innerHTML = `Done! Took ${t1 - t0}ms`;
status.innerHTML = `{{ .Strings.Get "status_challenge_done_took" }} ${t1 - t0}ms`;
}
setTimeout(() => {
@@ -62,9 +59,7 @@ const u = (url = "", params = {}) => {
});
}, 500);
} catch (err) {
title.innerHTML = "Oh no!";
status.innerHTML = `Failed to challenge: ${err.message}`;
spinner.innerHTML = "";
spinner.style.display = "none";
title.innerHTML = '{{ .Strings.Get "title_error" }}';
status.innerHTML = `{{ .Strings.Get "status_error" }} ${err.message}`;
}
})();

View File

@@ -8,47 +8,11 @@ import (
"git.gammaspectra.live/git/go-away/lib/challenge"
"git.gammaspectra.live/git/go-away/lib/policy"
"git.gammaspectra.live/git/go-away/utils"
"html/template"
"log/slog"
"net/http"
"strings"
)
var templates map[string]*template.Template
func init() {
templates = make(map[string]*template.Template)
dir, err := embed.TemplatesFs.ReadDir(".")
if err != nil {
panic(err)
}
for _, e := range dir {
if e.IsDir() {
continue
}
data, err := embed.TemplatesFs.ReadFile(e.Name())
if err != nil {
panic(err)
}
err = initTemplate(e.Name(), string(data))
if err != nil {
panic(err)
}
}
}
func initTemplate(name, data string) error {
tpl := template.New(name)
_, err := tpl.Parse(data)
if err != nil {
return err
}
templates[name] = tpl
return nil
}
func GetLoggerForRequest(r *http.Request) *slog.Logger {
data := challenge.RequestDataFromContext(r.Context())
args := []any{

View File

@@ -1,7 +1,6 @@
package lib
import (
"bytes"
"crypto/ed25519"
"git.gammaspectra.live/git/go-away/lib/challenge"
"git.gammaspectra.live/git/go-away/lib/policy"
@@ -9,7 +8,6 @@ import (
"git.gammaspectra.live/git/go-away/utils"
"github.com/google/cel-go/cel"
"log/slog"
"maps"
"net/http"
)
@@ -84,77 +82,6 @@ func (state *State) Logger(r *http.Request) *slog.Logger {
return GetLoggerForRequest(r)
}
func (state *State) ChallengePage(w http.ResponseWriter, r *http.Request, status int, reg *challenge.Registration, params map[string]any) {
data := challenge.RequestDataFromContext(r.Context())
input := make(map[string]any)
input["Id"] = data.Id.String()
input["Random"] = utils.CacheBust()
input["Path"] = state.UrlPath()
for k, v := range state.Options().ChallengeTemplateOverrides {
input[k] = v
}
for k, v := range state.Options().Strings {
input["str_"+k] = v
}
if reg != nil {
input["Challenge"] = reg.Name
}
maps.Copy(input, params)
if _, ok := input["Title"]; !ok {
input["Title"] = state.Options().Strings.Get("challenge_are_you_bot")
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
buf := bytes.NewBuffer(make([]byte, 0, 8192))
err := templates["challenge-"+state.Options().ChallengeTemplate+".gohtml"].Execute(buf, input)
if err != nil {
state.ErrorPage(w, r, http.StatusInternalServerError, err, "")
} else {
w.WriteHeader(status)
_, _ = w.Write(buf.Bytes())
}
}
func (state *State) ErrorPage(w http.ResponseWriter, r *http.Request, status int, err error, redirect string) {
data := challenge.RequestDataFromContext(r.Context())
w.Header().Set("Content-Type", "text/html; charset=utf-8")
buf := bytes.NewBuffer(make([]byte, 0, 8192))
input := map[string]any{
"Id": data.Id.String(),
"Random": utils.CacheBust(),
"Error": err.Error(),
"Path": state.UrlPath(),
"Theme": "",
"Title": state.Options().Strings.Get("error") + " " + http.StatusText(status),
"HideSpinner": true,
"Challenge": "",
"Redirect": redirect,
}
for k, v := range state.Options().ChallengeTemplateOverrides {
input[k] = v
}
for k, v := range state.Options().Strings {
input["str_"+k] = v
}
err2 := templates["challenge-"+state.Options().ChallengeTemplate+".gohtml"].Execute(buf, input)
if err2 != nil {
// nested errors!
panic(err2)
} else {
w.WriteHeader(status)
_, _ = w.Write(buf.Bytes())
}
}
func (state *State) GetChallenge(id challenge.Id) (*challenge.Registration, bool) {
reg, ok := state.challenges.Get(id)
return reg, ok

View File

@@ -3,12 +3,12 @@ package settings
import "maps"
type Settings struct {
Bind Bind `json:"bind"`
Bind Bind `yaml:"bind"`
Backends map[string]Backend `json:"backends"`
Backends map[string]Backend `yaml:"backends"`
BindDebug string `json:"bind-debug"`
BindMetrics string `json:"bind-metrics"`
BindDebug string `yaml:"bind-debug"`
BindMetrics string `yaml:"bind-metrics"`
Strings Strings `yaml:"strings"`

View File

@@ -1,12 +1,43 @@
package settings
import "maps"
import (
"html/template"
"maps"
)
type Strings map[string]string
var DefaultStrings = make(Strings).set(map[string]string{
"challenge_are_you_bot": "Checking you are not a bot",
"error": "Oh no!",
"title_challenge": "Checking you are not a bot",
"title_error": "Oh no!",
"noscript_warning": "<p>Sadly, you may need to enable JavaScript to get past this challenge. This is required because AI companies have changed the social contract around how website hosting works.</p>",
"details_title": "Why am I seeing this?",
"details_text": `
<p>
You are seeing this because the administrator of this website has set up <a href="https://git.gammaspectra.live/git/go-away">go-away</a>
to protect the server against the scourge of <a href="https://thelibre.news/foss-infrastructure-is-under-attack-by-ai-companies/">AI companies aggressively scraping websites</a>.
</p>
<p>
Mass scraping can and does cause downtime for the websites, which makes their resources inaccessible for everyone.
</p>
<p>
Please note that some challenges requires the use of modern JavaScript features and some plugins may disable these.
Disable such plugins for this domain (for example, JShelter) if you encounter any issues.
</p>
`,
"details_contact_admin_with_request_id": "If you have any issues contact the site administrator and provide the following Request Id",
"button_refresh_page": "Refresh page",
"status_loading_challenge": "Loading challenge",
"status_starting_challenge": "Starting challenge",
"status_loading": "Loading...",
"status_calculating": "Calculating...",
"status_challenge_success": "Challenge success!",
"status_challenge_done_took": "Done! Took",
"status_error": "Error:",
})
func (s Strings) set(v map[string]string) Strings {
@@ -14,11 +45,11 @@ func (s Strings) set(v map[string]string) Strings {
return s
}
func (s Strings) Get(value string) string {
func (s Strings) Get(value string) template.HTML {
v, ok := (s)[value]
if !ok {
// fallback
return "string:" + value
return template.HTML("string:" + value)
}
return v
return template.HTML(v)
}

114
lib/template.go Normal file
View File

@@ -0,0 +1,114 @@
package lib
import (
"bytes"
"git.gammaspectra.live/git/go-away/embed"
"git.gammaspectra.live/git/go-away/lib/challenge"
"git.gammaspectra.live/git/go-away/utils"
"html/template"
"maps"
"net/http"
)
var templates map[string]*template.Template
func init() {
templates = make(map[string]*template.Template)
dir, err := embed.TemplatesFs.ReadDir(".")
if err != nil {
panic(err)
}
for _, e := range dir {
if e.IsDir() {
continue
}
data, err := embed.TemplatesFs.ReadFile(e.Name())
if err != nil {
panic(err)
}
err = initTemplate(e.Name(), string(data))
if err != nil {
panic(err)
}
}
}
func initTemplate(name, data string) error {
tpl := template.New(name)
_, err := tpl.Parse(data)
if err != nil {
return err
}
templates[name] = tpl
return nil
}
func (state *State) ChallengePage(w http.ResponseWriter, r *http.Request, status int, reg *challenge.Registration, params map[string]any) {
data := challenge.RequestDataFromContext(r.Context())
input := make(map[string]any)
input["Id"] = data.Id.String()
input["Random"] = utils.CacheBust()
input["Path"] = state.UrlPath()
input["Links"] = state.Options().Links
input["Strings"] = state.Options().Strings
for k, v := range state.Options().ChallengeTemplateOverrides {
input[k] = v
}
if reg != nil {
input["Challenge"] = reg.Name
}
maps.Copy(input, params)
if _, ok := input["Title"]; !ok {
input["Title"] = state.Options().Strings.Get("title_challenge")
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
buf := bytes.NewBuffer(make([]byte, 0, 8192))
err := templates["challenge-"+state.Options().ChallengeTemplate+".gohtml"].Execute(buf, input)
if err != nil {
state.ErrorPage(w, r, http.StatusInternalServerError, err, "")
} else {
w.WriteHeader(status)
_, _ = w.Write(buf.Bytes())
}
}
func (state *State) ErrorPage(w http.ResponseWriter, r *http.Request, status int, err error, redirect string) {
data := challenge.RequestDataFromContext(r.Context())
w.Header().Set("Content-Type", "text/html; charset=utf-8")
buf := bytes.NewBuffer(make([]byte, 0, 8192))
input := map[string]any{
"Id": data.Id.String(),
"Random": utils.CacheBust(),
"Error": err.Error(),
"Path": state.UrlPath(),
"Theme": "",
"Title": template.HTML(string(state.Options().Strings.Get("title_error")) + " " + http.StatusText(status)),
"Challenge": "",
"Redirect": redirect,
"Links": state.Options().Links,
"Strings": state.Options().Strings,
}
for k, v := range state.Options().ChallengeTemplateOverrides {
input[k] = v
}
err2 := templates["challenge-"+state.Options().ChallengeTemplate+".gohtml"].Execute(buf, input)
if err2 != nil {
// nested errors!
panic(err2)
} else {
w.WriteHeader(status)
_, _ = w.Write(buf.Bytes())
}
}