sirius-press/plugins/sirius-press-core/includes/class-sp-settings.php
Silent Mode 17c60b9e11 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.
2026-09-22 22:43:30 +02:00

237 lines
8.3 KiB
PHP

<?php
/**
* Everything Sirius Press needs to know about the site it is running.
*
* Two things live here that ordinary WordPress has no concept of: which BCNR
* name this installation publishes under, and the key it publishes with.
*
* The key is the uncomfortable part and the docs say so plainly. The BNS
* gateway only accepts a write signed by the name's current on-chain owner,
* so unattended publishing — a post going live from wp-cron at 3am — means
* the server holds the owning key. There is no clever way around that: a key
* that can publish for the name is the key that owns the name. Sites that
* will not accept the risk run in `manual` mode instead, where the export
* queue drains from an admin's browser and the server stores no secret.
*
* At rest the phrase is AES-256-GCM encrypted under a key derived from
* SIRIUS_PRESS_KEY (or, failing that, the site's own auth salts). That
* protects against a leaked database dump, not against someone who can read
* wp-config.php — and the settings screen says exactly that rather than
* implying more.
*
* @package SiriusPress
*/
defined( 'ABSPATH' ) || exit;
final class SP_Settings {
const OPT_NAME = 'sirius_press_name';
const OPT_NETWORK = 'sirius_press_network';
const OPT_GATEWAY = 'sirius_press_gateway';
const OPT_MODE = 'sirius_press_publish_mode';
const OPT_PHRASE = 'sirius_press_phrase';
const OPT_PATH = 'sirius_press_derivation_path';
const OPT_ADDRESS = 'sirius_press_publish_address';
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';
/** Publishing modes. */
const MODE_SERVER = 'server'; // Server holds the phrase and signs by itself.
const MODE_MANUAL = 'manual'; // Nothing stored; an admin's browser signs.
public static function name() {
return (string) get_option( self::OPT_NAME, '' );
}
public static function network() {
$n = (string) get_option( self::OPT_NETWORK, 'mainnet' );
return 'chipnet' === $n ? 'chipnet' : 'mainnet';
}
/** The CashAddress prefix this site's accounts and keys use. */
public static function prefix() {
return 'chipnet' === self::network() ? 'bchtest' : 'bitcoincash';
}
public static function gateway() {
$g = trim( (string) get_option( self::OPT_GATEWAY, self::DEFAULT_GATEWAY ) );
return rtrim( '' === $g ? self::DEFAULT_GATEWAY : $g, '/' );
}
public static function mode() {
return self::MODE_SERVER === get_option( self::OPT_MODE, self::MODE_MANUAL )
? self::MODE_SERVER
: self::MODE_MANUAL;
}
public static function derivation_path() {
$p = trim( (string) get_option( self::OPT_PATH, '' ) );
return '' === $p ? SP_HD::DEFAULT_PATH : $p;
}
/** Whether publishing a post should trigger a static export. */
public static function auto_publish() {
return (bool) get_option( self::OPT_AUTOPUB, true );
}
/** Whether strangers may create accounts by proving an address. */
public static function open_registration() {
return (bool) get_option( self::OPT_OPEN_REG, (bool) get_option( 'users_can_register', false ) );
}
/**
* The domain used to mint placeholder addresses for plugins that insist on
* an email column. `.invalid` is reserved by RFC 2606 precisely so that it
* can never route anywhere.
*/
public static function stub_email_domain() {
$d = trim( (string) get_option( self::OPT_STUB_MAIL, '' ) );
if ( '' !== $d ) {
return $d;
}
$name = self::name();
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();
}
// ------------------------------------------------------- the publishing key
/** True when a phrase is stored, whatever mode the site is in. */
public static function has_phrase() {
return '' !== (string) get_option( self::OPT_PHRASE, '' );
}
/**
* Store (or clear) the publishing phrase, and cache the address it derives.
*
* @param string $mnemonic Pass '' to forget it.
* @return string '' on success, otherwise a human-readable error.
*/
public static function set_phrase( $mnemonic ) {
$mnemonic = SP_HD::normalize_mnemonic( $mnemonic );
if ( '' === $mnemonic ) {
delete_option( self::OPT_PHRASE );
delete_option( self::OPT_ADDRESS );
return '';
}
$problem = SP_HD::phrase_problem( $mnemonic );
if ( '' !== $problem ) {
return $problem;
}
try {
$key = SP_HD::publishing_key( $mnemonic, self::prefix(), self::derivation_path() );
} catch ( Exception $e ) {
return 'Could not derive a key from that phrase: ' . $e->getMessage();
}
$sealed = self::seal( $mnemonic );
if ( '' === $sealed ) {
return 'This server has no working encryption (openssl), so the phrase cannot be stored safely.';
}
update_option( self::OPT_PHRASE, $sealed, false );
update_option( self::OPT_ADDRESS, $key['address'], false );
return '';
}
/** The address the stored phrase publishes from, or ''. */
public static function publishing_address() {
return (string) get_option( self::OPT_ADDRESS, '' );
}
/**
* The private key for signing gateway writes.
*
* @return string 32 raw bytes, or '' when nothing is stored.
* @throws Exception When the stored phrase cannot be unsealed or derived.
*/
public static function publishing_private_key() {
$sealed = (string) get_option( self::OPT_PHRASE, '' );
if ( '' === $sealed ) {
return '';
}
$mnemonic = self::unseal( $sealed );
if ( '' === $mnemonic ) {
throw new Exception( 'The stored recovery phrase could not be decrypted. If SIRIUS_PRESS_KEY or the site salts changed, re-enter the phrase in Sirius Press settings.' );
}
$key = SP_HD::publishing_key( $mnemonic, self::prefix(), self::derivation_path() );
return $key['private'];
}
// ------------------------------------------------------------ encryption
/** 32-byte key from the site's configured secret. */
private static function secret() {
$material = defined( 'SIRIUS_PRESS_KEY' ) && SIRIUS_PRESS_KEY
? SIRIUS_PRESS_KEY
: ( ( defined( 'AUTH_KEY' ) ? AUTH_KEY : '' ) . ( defined( 'SECURE_AUTH_SALT' ) ? SECURE_AUTH_SALT : '' ) );
return hash( 'sha256', 'sirius-press/v1/' . $material, true );
}
/** @return string base64 of iv|tag|ciphertext, or '' if unavailable. */
private static function seal( $plain ) {
if ( ! function_exists( 'openssl_encrypt' ) ) {
return '';
}
$iv = random_bytes( 12 );
$tag = '';
$ct = openssl_encrypt( $plain, 'aes-256-gcm', self::secret(), OPENSSL_RAW_DATA, $iv, $tag, 'sirius-press', 16 );
if ( false === $ct ) {
return '';
}
return base64_encode( $iv . $tag . $ct );
}
/** @return string '' when the blob cannot be authenticated. */
private static function unseal( $sealed ) {
if ( ! function_exists( 'openssl_decrypt' ) ) {
return '';
}
$raw = base64_decode( $sealed, true );
if ( false === $raw || strlen( $raw ) < 29 ) {
return '';
}
$plain = openssl_decrypt(
substr( $raw, 28 ),
'aes-256-gcm',
self::secret(),
OPENSSL_RAW_DATA,
substr( $raw, 0, 12 ),
substr( $raw, 12, 16 ),
'sirius-press'
);
return false === $plain ? '' : $plain;
}
}