Skip to content

HTTP Skin Templates Enterprise Community

HTTP skins let you control exactly what the honeypot serves to a visitor. Each skin is a folder inside trapster/data/http/ with a config.yaml, optional Jinja2 templates, and optional static files.

Folder structure

trapster/data/http/
└── my_skin/
    ├── config.yaml       # required - route definitions and headers
    ├── templates/        # Jinja2 templates (.html, .j2, etc.)
    │   ├── index.html
    │   ├── 404.html
    │   └── login.j2
    └── files/            # static assets served as-is
        ├── logo.png
        └── styles.css

Point the honeypot at your skin by setting "skin": "my_skin" in trapster.conf.

config.yaml reference

Top-level fields

FieldRequiredDescription
nameyesSkin identifier, must match the folder name
descriptionnoHuman-readable description
headersnoHTTP headers added to every response
endpointsyesList of route definitions
defaultnoCatch-all response when no route matches
errorsnoCustom responses for specific error codes

Global headers

Headers defined at the top level are added to every response. Use them to set a realistic server fingerprint:

yaml
headers:
  Server: Apache/2.4.41 (Ubuntu)
  X-Powered-By: PHP/7.4.3
  X-Frame-Options: SAMEORIGIN

Per-route headers are merged on top of global headers and take precedence.

Endpoint definition

endpoints is an ordered list. The first route whose pattern matches the request URL and method is used.

yaml
endpoints:
  - "/path/or/regex":
    - method: GET
      status_code: 200
      file: page.html       # render a template
      headers:
        Content-Type: text/html

Each route maps a URL pattern to one or more method handlers. A single URL can have different handlers for different HTTP methods:

yaml
endpoints:
  - "/login":
    - method: GET
      status_code: 200
      file: login.html
    - method: POST
      status_code: 302
      headers:
        Location: /dashboard
      content: ''

Route handler fields

FieldDescription
methodHTTP method: GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS
status_codeHTTP response status code
reasonCustom HTTP reason phrase, e.g. Access Denied instead of the default Unauthorized
fileTemplate file to render (from templates/). Supports Jinja2.
contentInline response body. Overrides file if both are set.
aiAI prompt - Trapster generates a dynamic response (see AI responses)
headersPer-route headers, merged with global headers. Values support Jinja2 expressions.
queryQuery parameter matching rules (regex per parameter)

Header values support the same Jinja2 context as templates, including request and uuid(). Only values containing {{ are rendered at request time; others are resolved once at startup.

yaml
headers:
  request-id: "{{ uuid() }}"
  location: '/auth/login?url={{ request.url | quote }}&reason=0'
  www-authenticate: 'Basic realm="{{ request.host }}"'

URL patterns

Routes use regular expressions that must match the full path. Use standard regex syntax:

yaml
endpoints:
  # Exact match
  - "/login":
    - method: GET
      status_code: 200
      file: login.html

  # Any path starting with /api/
  - "/api/(.*)":
    - method: GET
      status_code: 401
      content: '{"error": "unauthorized"}'

  # Regex exclusion - serve index.html for everything except /static/ and /favicon/
  - "/(?!(static|favicon)($|/))(.*)":
    - method: GET
      status_code: 200
      file: index.html

Query parameter matching

Use the query field to match requests with specific query parameters. Values are regex patterns:

yaml
endpoints:
  - "/api/v1/user":
    - method: GET
      query:
        id: '[0-9]+'       # matches /api/v1/user?id=42
      status_code: 200
      file: user.j2
      headers:
        Content-Type: application/json

If the URL has query parameters but no query rule matches, the first handler without a query rule is used as a fallback.

Default response

The default block is returned when no endpoint matches the request path:

yaml
default:
  status_code: 404
  file: 404.html

Custom error pages

Override responses for specific error codes using the errors block:

yaml
errors:
  "401":
    file: 401.html
    headers:
      WWW-Authenticate: 'Basic realm="Restricted Area"'
  "403":
    file: 403.html

If no entry exists for an error code, Trapster looks for a file named <code>.html in templates/ automatically.

Jinja2 templates

Files referenced via file: are rendered through Jinja2 before being sent. The template receives a request object and two built-in functions.

request object

FieldTypeDescription
request.pathstringURL path, e.g. /login
request.methodstringHTTP method
request.headersdictRequest headers
request.bodystring / nullRequest body (POST/PUT/PATCH only)
request.formdictParsed form fields (POST application/x-www-form-urlencoded)
request.query_stringdictParsed query parameters (values are strings)
request.remotestringClient IP address
request.cookiesdictRequest cookies
request.hoststringHost header, including port if non-standard (e.g. 192.168.1.1:8443)
request.schemestringhttp or https
request.urlobjectFull URL object (cast to string for the complete URL)
request.path_qsstringPath with query string

Building redirect URLs

request.host already includes the port, so {{ request.scheme }}😕/{{ request.host }} gives the full origin (e.g. https://192.168.1.1:8443). Use the quote filter to URL-encode values inside query parameters:

yaml
location: '/auth/login?url={{ request.url | quote }}'

Built-in functions

uuid() - generates a random UUID4 per request. Useful for realistic request-id or X-Request-ID headers:

jinja
{{ uuid() }}
{# outputs: 4a128772-e3c9-4f6e-be26-2549681c4d17 #}

random(seed, alphabet, length) - generates a deterministic random string. Useful for creating realistic-looking but reproducible fake data:

jinja
{{ random(seed=request.query_string.get('id', '1'), alphabet='abcdefghijklmnopqrstuvwxyz', length=8) }}
ArgumentDefaultDescription
seedNone (truly random)Seed value for reproducibility
alphabethex charactersCharacters to draw from
length36Output length

get_current_time() - returns the current UTC time formatted as an HTTP date string:

jinja
{{ get_current_time() }}
{# outputs: Mon, 12 Jun 2026 14:23:05 GMT #}

Dynamic status code override

A template can override its own status code by starting with a front matter block. This lets a single route return different codes based on the request content:

jinja
{% if 'settings' not in request.body %}
---
status_code: 400
---
{"error": "missing required field"}
{% else %}
---
status_code: 200
---
{"ok": "settings saved"}
{% endif %}

Example: fake REST API endpoint

templates/user.j2:

jinja
{% set id = request.query_string.get('id', '1') %}
{% set username = random(seed=id, alphabet='abcdefghijklmnopqrstuvwxyz', length=8) %}
{
  "id": "{{ id }}",
  "username": "{{ username }}",
  "email": "{{ username }}@example.com",
  "created_at": "{{ get_current_time() }}"
}

Static files

Any file placed in files/ is served directly without processing. Trapster resolves the request path against this folder before falling through to the default response.

files/
├── favicon.ico
├── logo.png
└── css/
    └── main.css

A request to /logo.png will serve files/logo.png with the correct MIME type, without needing a matching endpoint rule.

AI-powered responses

Set ai: on a route instead of file: or content: to have Trapster generate a response dynamically using an LLM. Provide a prompt describing what the response should look like:

yaml
endpoints:
  - "/api/v1/user/(.*)":
    - method: GET
      status_code: 200
      ai: "Respond with JSON containing realistic user account information"
      headers:
        Content-Type: application/json

  - "/admin/":
    - method: GET
      status_code: 200
      ai: "Respond with an HTML admin login page"
      headers:
        Content-Type: text/html; charset=utf-8

The request path is appended to your prompt automatically so the AI can tailor the response to the specific URL being requested.

AI responses require the optional AI dependencies. If they are not installed, the route returns a 404.

YAML anchors

Large configs with repeated header sets can use YAML anchors to avoid duplication:

yaml
common_headers: &common_headers
  X-Frame-Options: SAMEORIGIN
  X-Content-Type-Options: nosniff
  Cache-Control: no-cache, no-store, must-revalidate

endpoints:
  - "/login":
    - method: GET
      status_code: 200
      file: login.html
      headers:
        <<: *common_headers
        Expires: '0'

  - "/loginError":
    - method: GET
      status_code: 401
      file: loginError.html
      headers:
        <<: *common_headers

Built-in skins

Enterprise Edition Enterprise

Enterprise VMs ship with additional skins for common internal targets (login pages for CI servers, NAS, network appliances, cloud consoles and more). The full list is available in the Skins section of your dashboard.

Community Edition

SkinDescription
default_apacheApache 2.4 default page
default_nginxNginx welcome page
default_iisIIS 10 default page
fortigateFortinet FortiGate login page
demo_apiSample REST API with user and settings endpoints
demo_aiAI-powered API that generates dynamic responses per route

Complete example

A minimal skin that emulates a basic login portal:

config.yaml:

yaml
name: my_portal
description: Custom internal portal honeypot
headers:
  Server: nginx/1.18.0
  X-Frame-Options: DENY

endpoints:
  - "/":
    - method: GET
      status_code: 302
      headers:
        Location: /login

  - "/login":
    - method: GET
      status_code: 200
      file: login.html
    - method: POST
      status_code: 302
      headers:
        Location: /login?error=1

  - "/robots.txt":
    - method: GET
      status_code: 200
      content: "User-agent: *\nDisallow: /"
      headers:
        Content-Type: text/plain

default:
  status_code: 404
  file: 404.html

templates/login.html (simplified):

html
<!DOCTYPE html>
<html>
<head><title>Portal Login</title></head>
<body>
  <form method="POST" action="/login">
    <input type="text" name="username" placeholder="Username">
    <input type="password" name="password" placeholder="Password">
    <button type="submit">Sign in</button>
  </form>
  {% if request.query_string.get('error') %}
  <p>Invalid credentials.</p>
  {% endif %}
</body>
</html>

Any POST to /login is automatically logged with the submitted username and password fields - no extra configuration needed.