fix(sirius-press): the exporter could never reach its own site in Docker

Deployed to a real VPS for the first time. The stack came up, the patched
wizard served, the plugins activated and the 40-check auth suite passed over
the public internet — and then every single export failed:

  cURL error 28: Failed to connect to <public ip> port 8081 after 10001 ms

Not a bug in the export. A container cannot reach the host's own published
port on most Docker hosts; there is simply no route back in. The exporter
renders each page by asking the site for it over HTTP, so the one thing it
depends on is the one thing a container cannot do. This would have hit every
user of the documented install path on their first publish, and the error
says nothing about the cause.

SIRIUS_PRESS_LOOPBACK_URL gives the exporter a second address — in the
bundled stack, the nginx service on the compose network — while the request
still carries the site's real Host header. That matters twice: WordPress
renders exactly the page a visitor gets rather than redirecting to a
canonical URL the container cannot follow, and the internal hostname never
appears in the exported HTML. Verified on the VPS: the page builds, the
content is right, no absolute self-links, no `//web/` leaking out.

Also documents the question underneath it — what WP_HOME should be when the
site's public address is a BCNR name. Not the name: whatever the server is
actually reachable at. The exported links are document-relative, so they work
under the name regardless.
This commit is contained in:
Silent Mode 2026-09-22 22:43:30 +02:00
parent 8f8c83478e
commit 17c60b9e11
8 changed files with 121 additions and 17 deletions

View file

@ -104,12 +104,22 @@ doing it: a Windows path-subtraction that produced an absolute `--prefix`, and
git-subtree's refusal to run in a repository with any uncommitted file. The
merge now runs in a scratch worktree and a failed run can be retried.
### Fixed after deploying to a real VPS
- **Static export could never work in the bundled Docker stack.** The exporter
fetches each page over HTTP, and a container cannot reach the host's own
published port on most Docker hosts — every export died with a cURL
timeout while the site itself was plainly up. `SIRIUS_PRESS_LOOPBACK_URL`
now points the exporter at the nginx service on the compose network, with
the site's real `Host` header so the rendered page is unchanged and the
internal address never reaches the output. The stack sets it by default.
### Known gaps
- `install.sh` and the Docker stack are written and syntax-checked but have
never been run — not on a clean Ubuntu box, and not locally either, since
Docker would not start on the machine this was built on. Largest untested
surface in the project.
- No byte has yet been written to the real gateway under a registered name.
The signing is verified against a transcription of the gateway's own
verifier, and the live instance reads the correct on-chain owner from
`navigate.st`, but the upload itself is untested.
- Manual-mode publishing (browser signs and uploads) has not been clicked
through; the server-signing path has.
- Publishing is verified against a transcription of the gateway's own

View file

@ -189,9 +189,13 @@ On WordPress 7.1.1, against a live instance:
- Taking WordPress **7.1.2** through `tools/update-wordpress.sh`: imported,
three-way merged into the subtree, clean, tests still green.
Not yet run: `install.sh` on a clean Ubuntu box — or the Docker stack at all,
anywhere — and an upload to the real gateway with a registered name.
[docs/testing.md](docs/testing.md) says how to do both and what each proves.
- **`install.sh` on a real Ubuntu 24.04 VPS**, start to finish: Docker stack
built, MariaDB healthy, the patched wizard served, plugins activated, and
the 40-check auth suite run against it over the public internet.
Not yet run: an upload to the real gateway under a registered name — the
signing is verified, the byte has not been written.
[docs/testing.md](docs/testing.md) says how to do it and what it proves.
---

View file

@ -56,6 +56,9 @@ services:
WORDPRESS_SITE_URL: ${SITE_URL:-}
WORDPRESS_DEBUG: ${WP_DEBUG:-false}
SIRIUS_PRESS_KEY: ${SIRIUS_PRESS_KEY:-}
# nginx on this compose network. The exporter fetches pages here because
# a container cannot reach the host's own published port.
SIRIUS_PRESS_LOOPBACK_URL: ${SIRIUS_PRESS_LOOPBACK_URL:-http://web}
volumes:
- config:/var/www/config
# The document root is a volume so nginx can read the same files. It is

View file

@ -135,6 +135,13 @@ if [ ! -f "$CONFIG_STORE" ]; then
echo "}"
echo "define( 'SIRIUS_PRESS_TRUST_PROXY', true );"
echo
echo "// Where the static exporter fetches this site's own pages."
echo "// A container cannot reach the host's public address on most Docker"
echo "// hosts — the request times out instead of arriving at nginx — so the"
echo "// exporter goes to the web container directly and carries the site's"
echo "// real Host header, which keeps the rendered page identical."
echo "define( 'SIRIUS_PRESS_LOOPBACK_URL', '${SIRIUS_PRESS_LOOPBACK_URL:-http://web}' );"
echo
if [ -n "${WORDPRESS_SITE_URL:-}" ]; then
echo "define( 'WP_HOME', '${WORDPRESS_SITE_URL}' );"
echo "define( 'WP_SITEURL', '${WORDPRESS_SITE_URL}' );"

View file

@ -127,6 +127,40 @@ to sign in manual mode.
---
## When the site cannot reach itself
The exporter renders each page by asking the site for it over HTTP. That
normally just works — and then you put it in a container.
On most Docker hosts a container cannot reach the host's own published port:
the PHP container asking for `http://203.0.113.10:8081/` times out, even
though that is exactly where visitors arrive. Nothing is misconfigured; there
is simply no route back in. The symptom is every export failing with
`cURL error 28 ... Timeout was reached`, while the site itself is plainly up.
The fix is a second address for the exporter to use:
```php
define( 'SIRIUS_PRESS_LOOPBACK_URL', 'http://web' );
```
The bundled Docker stack sets this for you — `web` is the nginx service on the
compose network. Elsewhere, point it at whatever PHP can reach:
`http://127.0.0.1`, `http://localhost:8080`, a socket-backed vhost.
The request still carries the site's real `Host` header, so WordPress renders
exactly the page a visitor would get and does not redirect the exporter to a
canonical URL it cannot follow. The internal address never appears in the
output.
**A related question that trips people up:** what should `WP_HOME` be when the
site's public address is a BCNR name? Not the name. Set it to whatever the
server is actually reachable at — an IP and port is fine. The exported pages
use document-relative links, so they work under the name regardless of what
`WP_HOME` said when they were rendered.
---
## What the exported pages look like
Pages are fetched over a loopback HTTP request, not rendered in-process. That

View file

@ -175,12 +175,9 @@ text in the box matches what your wallet shows you.
## What is not covered
- **A fresh VPS install.** `install.sh` and the Docker stack are written and
syntax-checked but have never been run — not on a clean Ubuntu box and not
locally, since Docker would not start on the machine this was built on.
This is the largest untested surface in the project.
- **A real gateway upload.** Verified against the transcribed verifier above,
not against `navigate.st` with a registered name.
- **A real gateway upload.** The signing is verified against the transcribed
verifier above, and against the real gateway's answer about who owns a name,
but no byte has yet been written to `navigate.st` under a registered name.
- **Manual-mode publishing in a browser.** The server-signing path is
exercised end to end; the browser-signs-and-uploads path in
`assets/export.js` has not been clicked through.

View file

@ -36,6 +36,7 @@ final class SP_Settings {
const OPT_AUTOPUB = 'sirius_press_auto_publish';
const OPT_OPEN_REG = 'sirius_press_open_registration';
const OPT_STUB_MAIL = 'sirius_press_stub_email_domain';
const OPT_LOOPBACK = 'sirius_press_loopback';
const DEFAULT_GATEWAY = 'https://navigate.st';
@ -97,6 +98,31 @@ final class SP_Settings {
return ( '' === $name ? 'sirius-press' : $name ) . '.invalid';
}
/**
* Where the exporter should fetch this site's own pages from.
*
* Normally empty, meaning "the site's own URL". It exists because a
* container cannot always reach the address the outside world uses: on a
* Docker host without NAT hairpinning, the PHP container asking for
* `http://203.0.113.10:8081/` simply times out, even though that is
* exactly where visitors arrive.
*
* Set this to something reachable from where PHP runs in the bundled
* stack, `http://web`, the nginx service on the compose network. The
* request still carries the site's real Host header, so WordPress renders
* the same page it would for a visitor and does not bounce the exporter
* to its canonical URL.
*
* @return string '' to fetch the public URL directly.
*/
public static function loopback_base() {
$configured = trim( (string) get_option( self::OPT_LOOPBACK, '' ) );
if ( '' === $configured && defined( 'SIRIUS_PRESS_LOOPBACK_URL' ) ) {
$configured = trim( (string) SIRIUS_PRESS_LOOPBACK_URL );
}
return '' === $configured ? '' : untrailingslashit( $configured );
}
/** True when the site knows its name and where to publish. */
public static function is_configured() {
return '' !== self::name() && '' !== self::gateway();

View file

@ -36,6 +36,32 @@ final class SPE_Renderer {
*/
public static function fetch( $url ) {
$request_url = add_query_arg( self::FLAG, self::token(), $url );
$headers = array(
'user-agent' => 'SiriusPress/' . SIRIUS_PRESS_EXPORT_VERSION . ' (static export)',
'accept-encoding' => 'identity',
);
/*
* Send the request somewhere PHP can actually reach, while still
* claiming the site's real hostname.
*
* Needed whenever the address visitors use is not routable from where
* PHP runs the normal case in Docker, where a container asking for
* the host's own public IP gets a timeout rather than its own nginx.
* The Host header is what keeps this honest: WordPress builds the
* page exactly as it would for a visitor, and does not redirect the
* exporter to a canonical URL it cannot follow.
*/
$loopback = SP_Settings::loopback_base();
if ( '' !== $loopback ) {
$parts = wp_parse_url( $request_url );
if ( ! empty( $parts['host'] ) ) {
$headers['host'] = $parts['host'] . ( empty( $parts['port'] ) ? '' : ':' . $parts['port'] );
$request_url = $loopback
. ( isset( $parts['path'] ) ? $parts['path'] : '/' )
. ( isset( $parts['query'] ) ? '?' . $parts['query'] : '' );
}
}
$response = wp_remote_get(
$request_url,
@ -46,10 +72,7 @@ final class SPE_Renderer {
// sees, not what the admin who triggered it sees.
'cookies' => array(),
'sslverify' => apply_filters( 'sirius_press_export_sslverify', true ),
'headers' => array(
'user-agent' => 'SiriusPress/' . SIRIUS_PRESS_EXPORT_VERSION . ' (static export)',
'accept-encoding' => 'identity',
),
'headers' => $headers,
)
);