Skip to content

Phase 1 — Build

Phase 1 creates one API-owned F5 Distributed Cloud application path: a namespace, a protected domain, an optional healthcheck, one origin pool, and one unsuffixed HTTPS load balancer. The load balancer uses automatic certificate management, redirects HTTP to HTTPS, advertises on the public default VIP, routes to one origin pool, and injects Client-Side Defense on all pages.

API mode requires an independently owned and operated origin. Supply either its public DNS hostname or its public IP address; this workflow does not read an origin endpoint from terraform/aws state. Complete the environment preflight before making changes.

Step 0: Validate Inputs and Record Ownership

Section titled “Step 0: Validate Inputs and Record Ownership”

Require numeric ports and DNS-label resource names before the first request:

Terminal window
case "${XCSH_API_URL:-}" in
https://*) ;;
*) echo "STOP: XCSH_API_URL must be an HTTPS URL"; exit 1 ;;
esac
while [ "${XCSH_API_URL%/}" != "$XCSH_API_URL" ]; do
XCSH_API_URL=${XCSH_API_URL%/}
done
export XCSH_API_URL
XCSH_TENANT_HOSTNAME=${XCSH_API_URL#https://}
case "$XCSH_TENANT_HOSTNAME" in
''|*/*|*:*|*[!A-Za-z0-9.-]*)
echo "STOP: XCSH_API_URL must contain only an HTTPS tenant hostname"
exit 1
;;
esac
XCSH_TENANT_IDENTITY=${XCSH_TENANT_HOSTNAME%%.*}
[ -n "$XCSH_TENANT_IDENTITY" ] || {
echo "STOP: tenant identity could not be derived from XCSH_API_URL"
exit 1
}
export XCSH_TENANT_HOSTNAME XCSH_TENANT_IDENTITY
dns_label='^[a-z]([a-z0-9-]{0,62}[a-z0-9])?$'
for value in "$XCSH_NAMESPACE" "$XCSH_LB_NAME" "$XCSH_ORIGIN_POOL"; do
printf '%s' "$value" | grep -Eq "$dns_label" || {
echo "STOP: invalid DNS-label resource name: $value"
exit 1
}
done
if [ -n "${XCSH_HC_NAME:-}" ]; then
printf '%s' "$XCSH_HC_NAME" | grep -Eq "$dns_label" || {
echo "STOP: invalid healthcheck DNS-label name"
exit 1
}
fi
case "${XCSH_ORIGIN_PORT:-}" in
''|*[!0-9]*) echo "STOP: XCSH_ORIGIN_PORT must be an integer"; exit 1 ;;
esac
[ "$XCSH_ORIGIN_PORT" -ge 1 ] && [ "$XCSH_ORIGIN_PORT" -le 65535 ] || {
echo "STOP: XCSH_ORIGIN_PORT must be between 1 and 65535"
exit 1
}
case "${XCSH_ORIGIN_KIND:-}" in
public_name)
[ -n "${XCSH_ORIGIN_HOSTNAME:-}" ] && [ -z "${XCSH_ORIGIN_IP:-}" ] || exit 1
;;
public_ip)
[ -n "${XCSH_ORIGIN_IP:-}" ] && [ -z "${XCSH_ORIGIN_HOSTNAME:-}" ] || exit 1
;;
*) echo "STOP: XCSH_ORIGIN_KIND must be public_name or public_ip"; exit 1 ;;
esac

Create a local, secret-free session ledger only when the configured path is absent. If a ledger already exists, never replace, truncate, or reinitialize it: validate it completely and resume only that exact matching API session. Phase 4 uses resources as its only deletion allowlist. outcomes is an append-only request journal; it never grants deletion authority.

Terminal window
export CSD_API_SESSION_FILE="${CSD_API_SESSION_FILE:-.csd-api-session.json}"
case "$CSD_API_SESSION_FILE" in
/*) ;;
*) CSD_API_SESSION_FILE="$PWD/$CSD_API_SESSION_FILE" ;;
esac
CSD_API_SESSION_DIR=$(dirname -- "$CSD_API_SESSION_FILE")
CSD_API_SESSION_NAME=$(basename -- "$CSD_API_SESSION_FILE")
[ -d "$CSD_API_SESSION_DIR" ] && [ "$CSD_API_SESSION_NAME" != . ] && [ "$CSD_API_SESSION_NAME" != .. ] || {
echo "STOP: CSD_API_SESSION_FILE must be a valid absolute or relative file path"
exit 1
}
CSD_API_SESSION_DIR=$(cd "$CSD_API_SESSION_DIR" && pwd -P) || exit 1
CSD_API_SESSION_FILE="$CSD_API_SESSION_DIR/$CSD_API_SESSION_NAME"
export CSD_API_SESSION_FILE CSD_API_SESSION_DIR
umask 077
validate_api_ledger() {
jq -e --arg api_url "$XCSH_API_URL" --arg tenant_hostname "$XCSH_TENANT_HOSTNAME" \
--arg tenant_identity "$XCSH_TENANT_IDENTITY" --arg ns "$XCSH_NAMESPACE" \
--arg domain "$XCSH_DOMAINNAME" --arg protected_name "$XCSH_LB_NAME" \
--arg pool_name "$XCSH_ORIGIN_POOL" --arg lb_name "$XCSH_LB_NAME" \
--arg hc_name "${XCSH_HC_NAME:-}" '
(keys | sort) == ["environment","outcomes","ownership_mode","resources","schema_version","session_id"] and
.schema_version == 1 and .ownership_mode == "api" and
(.session_id | type == "string" and test("^[0-9]{8}T[0-9]{6}Z$")) and
.environment == {
api_url: $api_url, tenant_hostname: $tenant_hostname, tenant_identity: $tenant_identity,
namespace: $ns, domain: $domain
} and
(.resources | type == "array") and
(all(.resources[];
(keys | sort) == ["kind","name","namespace","spec","status"] and
(.kind | IN("namespace","protected_domain","healthcheck","origin_pool","http_loadbalancer","mitigated_domain")) and
.namespace == $ns and
(.name | type == "string" and test("^[a-z]([a-z0-9-]{0,62}[a-z0-9])?$")) and
(.status | IN("created","pre-existing","unknown")) and
(
(.kind == "namespace" and .name == $ns) or
(.kind == "protected_domain" and .name == $protected_name) or
(.kind == "origin_pool" and .name == $pool_name) or
(.kind == "http_loadbalancer" and .name == $lb_name) or
(.kind == "healthcheck" and $hc_name != "" and .name == $hc_name) or
(.kind == "mitigated_domain")
) and
(.spec | type == "object")
)) and
([.resources[] | [.kind,.namespace,.name] | join("\u0000")] | length) ==
([.resources[] | [.kind,.namespace,.name] | join("\u0000")] | unique | length) and
([.resources[] | select(.status == "unknown")] | length == 0) and
(.outcomes | type == "array") and
(all(.outcomes[];
(keys | sort) == ["kind","name","namespace","operation","result"] and
(.operation | IN("GET","POST","PUT")) and
(.kind | IN("namespace","protected_domain","healthcheck","origin_pool","http_loadbalancer","mitigated_domain")) and
.namespace == $ns and
(.name | type == "string" and test("^[a-z]([a-z0-9-]{0,62}[a-z0-9])?$")) and
(
(.kind == "namespace" and .name == $ns) or
(.kind == "protected_domain" and .name == $protected_name) or
(.kind == "origin_pool" and .name == $pool_name) or
(.kind == "http_loadbalancer" and .name == $lb_name) or
(.kind == "healthcheck" and $hc_name != "" and .name == $hc_name) or
(.kind == "mitigated_domain")
) and
(.result | IN("match","absent","created","replaced","conflict","unknown"))
)) and
([.outcomes[] | select(.result == "unknown")] | length == 0)
' "$CSD_API_SESSION_FILE" >/dev/null
}
if [ -e "$CSD_API_SESSION_FILE" ] || [ -L "$CSD_API_SESSION_FILE" ]; then
[ -f "$CSD_API_SESSION_FILE" ] && validate_api_ledger || {
echo "STOP: existing ledger is corrupt, mismatched, duplicated, or has unknown ownership"
exit 1
}
echo "Resuming matching API session $(jq -r .session_id "$CSD_API_SESSION_FILE")"
else
ledger_tmp=$(mktemp "${CSD_API_SESSION_FILE}.XXXXXX") || exit 1
jq -n \
--arg session_id "$(date -u +%Y%m%dT%H%M%SZ)" \
--arg api_url "$XCSH_API_URL" \
--arg tenant_hostname "$XCSH_TENANT_HOSTNAME" \
--arg tenant_identity "$XCSH_TENANT_IDENTITY" \
--arg namespace "$XCSH_NAMESPACE" \
--arg domain "$XCSH_DOMAINNAME" \
'{
schema_version: 1,
ownership_mode: "api",
session_id: $session_id,
environment: {
api_url: $api_url,
tenant_hostname: $tenant_hostname,
tenant_identity: $tenant_identity,
namespace: $namespace,
domain: $domain
},
resources: [],
outcomes: []
}' > "$ledger_tmp" &&
ln "$ledger_tmp" "$CSD_API_SESSION_FILE" &&
rm -f "$ledger_tmp" || {
rm -f "$ledger_tmp"
echo "STOP: ledger creation failed; an existing ledger was not overwritten"
exit 1
}
fi
ledger_outcome() {
operation=$1 kind=$2 namespace=$3 name=$4 result=$5
tmp=$(mktemp "${CSD_API_SESSION_FILE}.XXXXXX") || return 1
jq -e --arg operation "$operation" --arg kind "$kind" --arg namespace "$namespace" \
--arg name "$name" --arg result "$result" '
.schema_version == 1 and .ownership_mode == "api" and
($operation | IN("GET","POST","PUT")) and
($result | IN("match","absent","created","replaced","conflict","unknown"))
| if . then
input | .outcomes += [{operation:$operation,kind:$kind,namespace:$namespace,name:$name,result:$result}]
else error("invalid outcome") end
' "$CSD_API_SESSION_FILE" "$CSD_API_SESSION_FILE" > "$tmp" &&
mv "$tmp" "$CSD_API_SESSION_FILE" || { rm -f "$tmp"; return 1; }
}
ledger_record() {
kind=$1 namespace=$2 name=$3 status=$4 spec=${5:-'{}'}
case "$kind" in
namespace|protected_domain|healthcheck|origin_pool|http_loadbalancer|mitigated_domain) ;;
*) return 1 ;;
esac
case "$status" in created|pre-existing|unknown) ;; *) return 1 ;; esac
printf '%s' "$name" | grep -Eq '^[a-z]([a-z0-9-]{0,62}[a-z0-9])?$' || return 1
printf '%s' "$spec" | jq -e 'type == "object"' >/dev/null || return 1
tmp=$(mktemp "${CSD_API_SESSION_FILE}.XXXXXX") || return 1
jq -e --arg kind "$kind" --arg namespace "$namespace" --arg name "$name" \
--arg status "$status" --argjson spec "$spec" '
.schema_version == 1 and .ownership_mode == "api" and
([.resources[] | select(.kind == $kind and .namespace == $namespace and .name == $name)] | length) == 0
| if . then
input | .resources += [{kind:$kind,namespace:$namespace,name:$name,status:$status,spec:$spec}]
else error("duplicate or invalid resource identity") end
' "$CSD_API_SESSION_FILE" "$CSD_API_SESSION_FILE" > "$tmp" &&
mv "$tmp" "$CSD_API_SESSION_FILE" || { rm -f "$tmp"; return 1; }
}

Call ledger_outcome immediately after every GET, POST, or PUT result, before another API request. Record unknown for transport errors, malformed bodies, ambiguous 5xx responses, or any response whose identity cannot be proven, then stop. Call ledger_record exactly once per resource: pre-existing after an exact matching GET or 409, created after a successful create, and unknown after a conflicting result. On resume, process resources in dependency order. For an existing resource entry, require its exact live object to match and do not call ledger_record again.

If a successful POST was journaled as created but its resource entry was interrupted before append, require an exact matching GET and restore that resource entry as created. Without that same-session successful POST outcome, an existing object is pre-existing, never created. Never overwrite, relabel, or infer ownership for a resource entry. A ledger containing any unknown resource or outcome must be reconciled outside this workflow before it can resume.

All requests below are native xcsh_api operations. Substitute current values for placeholders. Successful POST and PUT response bodies are mutation evidence; later reads prove settled state.

GET the exact namespace:

{
"method": "GET",
"path": "/api/web/namespaces/{namespace}",
"params": { "namespace": "<XCSH_NAMESPACE>" }
}

After a matching response, call ledger_outcome GET namespace "$XCSH_NAMESPACE" "$XCSH_NAMESPACE" match and record it as pre-existing. After 404, call the same helper with absent, then create it:

{
"method": "POST",
"path": "/api/web/namespaces",
"payload": {
"metadata": { "name": "<XCSH_NAMESPACE>" },
"spec": {}
}
}

Immediately journal the POST and record ownership. A successful create is created; 409 is pre-existing; any other ambiguous or conflicting result is unknown and stops the workflow. Re-read the exact namespace, journal that GET, and require the expected name.

Step 2: Read or Create the Protected Domain

Section titled “Step 2: Read or Create the Protected Domain”

The DNS-label resource name is client-side-defense; the protected value is the eTLD+1 root in XCSH_ROOT_DOMAIN. These are different identifiers.

First list the collection:

{
"method": "GET",
"path": "/api/shape/csd/namespaces/{namespace}/protected_domains",
"params": { "namespace": "<XCSH_NAMESPACE>" }
}

Journal the list GET. Select only the item whose top-level name is exactly client-side-defense. If found, perform the individual GET before comparing its full spec:

{
"method": "GET",
"path": "/api/shape/csd/namespaces/{namespace}/protected_domains/{name}",
"params": {
"namespace": "<XCSH_NAMESPACE>",
"name": "client-side-defense"
}
}

Journal the exact GET. If its metadata.name, namespace, and spec.protected_domain match, record pre-existing and continue without modifying it. A conflict is unknown and blocks the build. If the list contained no exact name, create it:

{
"method": "POST",
"path": "/api/shape/csd/namespaces/{namespace}/protected_domains",
"params": { "namespace": "<XCSH_NAMESPACE>" },
"payload": {
"metadata": {
"name": "client-side-defense",
"namespace": "<XCSH_NAMESPACE>"
},
"spec": { "protected_domain": "<XCSH_ROOT_DOMAIN>" }
}
}

Journal the POST immediately. Record created only after a successful create; record 409 as pre-existing; record any uncertain result as unknown and stop. Then run and journal the individual GET and require the exact name and protected root.

If XCSH_HC_NAME is empty, create no healthcheck, add no healthcheck ledger resource, and omit spec.healthcheck from every origin-pool body. Otherwise GET /api/config/namespaces/{namespace}/healthchecks/{name}, journal its outcome, and create only after 404:

{
"method": "POST",
"path": "/api/config/namespaces/{namespace}/healthchecks",
"params": { "namespace": "<XCSH_NAMESPACE>" },
"payload": {
"metadata": {
"name": "<XCSH_HC_NAME>",
"namespace": "<XCSH_NAMESPACE>"
},
"spec": {
"http_health_check": {
"path": "/",
"use_origin_server_name": {}
},
"interval": 15,
"timeout": 3,
"unhealthy_threshold": 1,
"healthy_threshold": 3
}
}
}

Journal the POST, then record its ownership exactly once. Read and journal the exact object; require the expected name, path, and thresholds. If quota is exhausted, do not delete unrelated healthchecks: leave XCSH_HC_NAME empty and continue only if no healthcheck resource was created or recorded.

Use public_name for an independently owned public DNS origin, or public_ip for an independently owned public IP origin. Never place an IP in public_name.dns_name or a hostname in public_ip.ip.

GET /api/config/namespaces/{namespace}/origin_pools/{name} and journal the outcome. An exact match is pre-existing and stops this build; a conflict is unknown and stops; only 404 permits POST.

Choose exactly one clean create body. The port value is a JSON number, not a quoted string.

{
"method": "POST",
"path": "/api/config/namespaces/{namespace}/origin_pools",
"params": { "namespace": "<XCSH_NAMESPACE>" },
"payload": {
"metadata": {
"name": "<XCSH_ORIGIN_POOL>",
"namespace": "<XCSH_NAMESPACE>"
},
"spec": {
"origin_servers": [
{ "public_name": { "dns_name": "<XCSH_ORIGIN_HOSTNAME>" } }
],
"port": 80
}
}
}
{
"method": "POST",
"path": "/api/config/namespaces/{namespace}/origin_pools",
"params": { "namespace": "<XCSH_NAMESPACE>" },
"payload": {
"metadata": {
"name": "<XCSH_ORIGIN_POOL>",
"namespace": "<XCSH_NAMESPACE>"
},
"spec": {
"origin_servers": [
{ "public_ip": { "ip": "<XCSH_ORIGIN_IP>" } }
],
"port": 80
}
}
}

Replace 80 with the validated numeric value of XCSH_ORIGIN_PORT without quotes. When XCSH_HC_NAME is non-empty, add this sibling to the selected spec; otherwise omit it entirely:

"healthcheck": [
{
"namespace": "<XCSH_NAMESPACE>",
"name": "<XCSH_HC_NAME>"
}
]

Journal the POST immediately and record created only for a successful create. Record 409 as pre-existing, never created; an ambiguous response is unknown and stops. Read and journal the exact pool. Require one server of the selected kind, the exact endpoint, a numeric matching port, and either the one expected healthcheck reference or no healthcheck field.

Only a pool recorded as created by this session may exercise PUT. Build the replacement from a clean request body, not from a GET response. Select exactly one branch.

{
"method": "PUT",
"path": "/api/config/namespaces/{namespace}/origin_pools/{name}",
"params": {
"namespace": "<XCSH_NAMESPACE>",
"name": "<XCSH_ORIGIN_POOL>"
},
"payload": {
"metadata": {
"name": "<XCSH_ORIGIN_POOL>",
"namespace": "<XCSH_NAMESPACE>"
},
"spec": {
"origin_servers": [
{ "public_name": { "dns_name": "<XCSH_ORIGIN_HOSTNAME>" } }
],
"port": 80
}
}
}
{
"method": "PUT",
"path": "/api/config/namespaces/{namespace}/origin_pools/{name}",
"params": {
"namespace": "<XCSH_NAMESPACE>",
"name": "<XCSH_ORIGIN_POOL>"
},
"payload": {
"metadata": {
"name": "<XCSH_ORIGIN_POOL>",
"namespace": "<XCSH_NAMESPACE>"
},
"spec": {
"origin_servers": [
{ "public_ip": { "ip": "<XCSH_ORIGIN_IP>" } }
],
"port": 80
}
}
}

In the selected body, replace 80 with the validated numeric XCSH_ORIGIN_PORT without quotes. When XCSH_HC_NAME is non-empty, add the healthcheck sibling shown above; otherwise omit it entirely. Journal the PUT as replaced, then read and journal the exact pool and repeat all checks. Never copy system_metadata, status, resource_version, referring_objects, or other response-managed fields into PUT.

The linked Phase 4 API teardown deletes this pool only when its unique ledger resource has status: "created", after deleting the load balancer, and verifies the pool returns 404 before deleting an optional healthcheck.

Step 5: Create and Replace One HTTPS Load Balancer

Section titled “Step 5: Create and Replace One HTTPS Load Balancer”

The unsuffixed load balancer name is XCSH_LB_NAME; the reference value is client-side-defense. Do not create -http and -https variants.

GET /api/config/namespaces/{namespace}/http_loadbalancers/{name} and journal the result. An exact existing object is pre-existing and stops this build; a conflict is unknown and stops; only 404 permits POST.

The catalog requires metadata.name, metadata.namespace, and spec.domains. The body below adds only the requested HTTPS, advertisement, route, and CSD choices; it omits server-default TLS and load-balancing fields.

{
"method": "POST",
"path": "/api/config/namespaces/{namespace}/http_loadbalancers",
"params": { "namespace": "<XCSH_NAMESPACE>" },
"payload": {
"metadata": {
"name": "<XCSH_LB_NAME>",
"namespace": "<XCSH_NAMESPACE>"
},
"spec": {
"domains": ["<XCSH_DOMAINNAME>"],
"https_auto_cert": { "http_redirect": true },
"advertise_on_public_default_vip": {},
"default_route_pools": [
{
"pool": {
"namespace": "<XCSH_NAMESPACE>",
"name": "<XCSH_ORIGIN_POOL>"
},
"weight": 1,
"priority": 1
}
],
"client_side_defense": {
"policy": { "js_insert_all_pages": {} }
}
}
}
}

Journal the POST immediately. Record created only for a successful create, with spec: {"domain":"<XCSH_DOMAINNAME>","origin_pool":"<XCSH_ORIGIN_POOL>"}. Record 409 as pre-existing; record ambiguity as unknown and stop.

GET and journal the exact load balancer. Verify the unsuffixed name, the single expected domain, HTTPS auto-cert with redirect enabled, public-default-VIP advertisement, one expected origin-pool reference with weight and priority 1, and all-pages CSD injection.

Require the ledger to identify this exact load balancer as created. Exercise replace with a fresh body containing only the same metadata and spec used for POST:

{
"method": "PUT",
"path": "/api/config/namespaces/{namespace}/http_loadbalancers/{name}",
"params": {
"namespace": "<XCSH_NAMESPACE>",
"name": "<XCSH_LB_NAME>"
},
"payload": {
"metadata": {
"name": "<XCSH_LB_NAME>",
"namespace": "<XCSH_NAMESPACE>"
},
"spec": {
"domains": ["<XCSH_DOMAINNAME>"],
"https_auto_cert": { "http_redirect": true },
"advertise_on_public_default_vip": {},
"default_route_pools": [
{
"pool": {
"namespace": "<XCSH_NAMESPACE>",
"name": "<XCSH_ORIGIN_POOL>"
},
"weight": 1,
"priority": 1
}
],
"client_side_defense": {
"policy": { "js_insert_all_pages": {} }
}
}
}
}

Journal the PUT as replaced. Do not PUT a server response wholesale. GET and journal the exact load balancer again, then repeat all six checks. Phase 4 deletes only its ledger-created entry and verifies 404 before deleting the pool.

Step 6: DNS, Certificate, and Origin Recovery

Section titled “Step 6: DNS, Certificate, and Origin Recovery”

API acceptance is not end-to-end proof. Resolve failures without changing ownership or creating duplicate objects:

  1. DNS: Verify the application A record and _acme-challenge record. For F5 Distributed Cloud authoritative DNS, confirm the existing zone permits HTTP-load-balancer-managed records. For external DNS, publish the values reported by the load balancer. Do not replace or delete a shared zone.
  2. Negative caching: After an earlier NXDOMAIN, query the authoritative nameserver before changing configuration.
  3. Certificate: Poll the same load balancer until cert_state is CertificateValid. A rate-limit state blocks this HTTPS-only design; do not create a second HTTP load balancer.
  4. Origin: Prove the independently owned DNS or IP endpoint is serving the configured port, then prove F5 Distributed Cloud can reach it.
  5. Transient 503: Wait for propagation and repeat GET and traffic checks. Do not POST duplicates.

A DNS-zone update is a separate shared-object change requiring operator approval. It is outside this session’s deletion allowlist.

All checks are required before Phase 2:

OrderEvidenceRequired result
1Origin serviceIndependently owned origin is healthy
2Origin targetDNS or IP origin responds on numeric XCSH_ORIGIN_PORT
3F5 virtual hostThe single load balancer reports VIRTUAL_HOST_READY
4Certificatecert_state is CertificateValid for XCSH_DOMAINNAME
5Redirectcurl -I "http://$XCSH_DOMAINNAME/" returns 301 with an HTTPS Location
6HTTPS applicationcurl -fsS -o /dev/null -w '%{http_code}\n' "https://$XCSH_DOMAINNAME/" returns 200
7Rendered applicationA browser renders the Juice Shop application over HTTPS
8CSD injectionReturned HTML or the browser DOM contains __imp_apg__
9CSD beaconBrowser network evidence shows a request to the CSD dip endpoint

Finally GET and journal the load balancer, origin pool, optional healthcheck, and protected-domain list. For the protected domain, follow a matching list item with the exact individual GET and journal both. Preserve those responses and .csd-api-session.json as teardown evidence.


Phase 1 complete. Keep .csd-api-session.json for the linked Phase 4 API teardown, which deletes ledger-created objects in reverse dependency order and verifies each exact object is absent. Proceed to Phase 2 — Attack only after the full proof chain passes.