Compare commits

..
7 changed files with 386 additions and 35 deletions
+1 -1
View File
@@ -18,7 +18,7 @@ app:
network: archy-net
resources:
cpu_limit: 1
memory_limit: 256Mi
memory_limit: 256m
disk_limit: 128Mi
security:
capabilities: []
@@ -685,23 +685,100 @@ impl Runtime for Podman {
Ok(())
}
async fn healthy(&self, id: &str) -> Result<bool> {
// Missing healthcheck is not fabricated health. Running-only containers
// can pass runtime readiness; healthchecked members must report healthy.
for _ in 0..30 {
wait_for_container_health(|| async {
let raw = Self::command(&["inspect", id]).await?;
let rows: Vec<serde_json::Value> = serde_json::from_str(&raw)?;
let row = rows.first().context("Missing updated container")?;
if row.pointer("/State/Status").and_then(|v| v.as_str()) != Some("running") {
return Ok(false);
rows.into_iter().next().context("Missing updated container")
})
.await
}
}
// A healthcheck may not run until its interval elapses. The former 30 samples
// could reject a service immediately before its first successful scheduled check.
const HEALTH_MAX_WAIT: std::time::Duration = std::time::Duration::from_secs(300);
const HEALTH_POLL: std::time::Duration = std::time::Duration::from_secs(1);
fn health_wait_budget(row: &serde_json::Value) -> std::time::Duration {
let config = row.pointer("/Config/Healthcheck");
let positive = |field: &str, default: u64| {
config
.and_then(|v| v.get(field))
.and_then(|v| v.as_u64())
.filter(|v| *v > 0)
.unwrap_or(default) as u128
};
// Zero/missing interval and timeout use conservative 30-second defaults;
// zero/missing retries uses three. Never let malformed/huge metadata extend
// the global ceiling, and never extend the deadline on subsequent polls.
let interval = positive("Interval", 30_000_000_000);
let timeout = positive("Timeout", 30_000_000_000);
let retries = positive("Retries", 3);
let start = config
.and_then(|v| v.get("StartPeriod"))
.and_then(|v| v.as_u64())
.unwrap_or(0) as u128;
let nanos = start
.saturating_add(retries.saturating_mul(interval.saturating_add(timeout)))
.saturating_add(HEALTH_POLL.as_nanos())
.clamp(30_000_000_000, HEALTH_MAX_WAIT.as_nanos());
std::time::Duration::from_nanos(nanos as u64)
}
fn container_health(row: &serde_json::Value) -> Option<bool> {
if row.pointer("/State/Status").and_then(|v| v.as_str()) != Some("running") {
return Some(false);
}
match row.pointer("/State/Health/Status").and_then(|v| v.as_str()) {
Some("healthy") => Some(true),
Some("unhealthy") => Some(false),
None | Some("") => {
let configured = match row.pointer("/Config/Healthcheck") {
None | Some(serde_json::Value::Null) => false,
Some(config) => match config.get("Test").and_then(|v| v.as_array()) {
Some(test) => {
!test.is_empty() && test.first().and_then(|v| v.as_str()) != Some("NONE")
}
None => true, // malformed/incomplete check config is not proof of no check
},
};
if configured {
None
} else {
Some(true)
}
match row.pointer("/State/Health/Status").and_then(|v| v.as_str()) {
None | Some("") | Some("healthy") => return Ok(true),
Some("unhealthy") => return Ok(false),
_ => {}
}
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
Ok(false)
_ => None,
}
}
async fn wait_for_container_health<F, Fut>(mut inspect: F) -> Result<bool>
where
F: FnMut() -> Fut,
Fut: Future<Output = Result<serde_json::Value>>,
{
let started = tokio::time::Instant::now();
let mut row = match tokio::time::timeout_at(started + HEALTH_MAX_WAIT, inspect()).await {
Ok(result) => result?,
Err(_) => return Ok(false),
};
let deadline = started + health_wait_budget(&row);
loop {
if tokio::time::Instant::now() > deadline {
return Ok(false);
}
if let Some(healthy) = container_health(&row) {
return Ok(healthy);
}
let now = tokio::time::Instant::now();
if now >= deadline {
return Ok(false);
}
tokio::time::sleep_until((now + HEALTH_POLL).min(deadline)).await;
row = match tokio::time::timeout_at(deadline, inspect()).await {
Ok(result) => result?,
Err(_) => return Ok(false),
};
}
}
@@ -1136,3 +1213,128 @@ mod tests {
assert!(Guard::acquire(root.path()).is_ok());
}
}
#[cfg(test)]
mod health_readiness_tests {
use super::*;
use serde_json::json;
use std::time::Duration;
fn checked(status: &str) -> serde_json::Value {
json!({"State":{"Status":"running","Health":{"Status":status}},
"Config":{"Healthcheck":{"Test":["CMD","probe"],"Interval":30_000_000_000u64,
"Timeout":5_000_000_000u64,"Retries":5}}})
}
#[tokio::test(start_paused = true)]
async fn scheduled_health_at_31_seconds_is_not_rejected_at_30() {
let started = tokio::time::Instant::now();
let result = wait_for_container_health(|| async {
Ok(checked(if started.elapsed() >= Duration::from_secs(31) {
"healthy"
} else {
"starting"
}))
})
.await
.unwrap();
assert!(result);
assert_eq!(started.elapsed(), Duration::from_secs(31));
}
#[tokio::test(start_paused = true)]
async fn unhealthy_and_exited_fail_immediately() {
for row in [checked("unhealthy"), json!({"State":{"Status":"exited"}})] {
let started = tokio::time::Instant::now();
assert!(
!wait_for_container_health(|| std::future::ready(Ok(row.clone())))
.await
.unwrap()
);
assert_eq!(started.elapsed(), Duration::ZERO);
}
}
#[tokio::test(start_paused = true)]
async fn starting_is_bounded_by_initial_config_even_if_later_config_grows() {
let started = tokio::time::Instant::now();
let mut first = true;
assert!(!wait_for_container_health(|| {
let mut row = checked("starting");
if first {
first = false;
} else {
row["Config"]["Healthcheck"]["StartPeriod"] = json!(u64::MAX);
}
std::future::ready(Ok(row))
})
.await
.unwrap());
assert_eq!(started.elapsed(), Duration::from_secs(176));
}
#[tokio::test(start_paused = true)]
async fn blocked_initial_inspect_cannot_exceed_global_ceiling() {
let started = tokio::time::Instant::now();
assert!(!wait_for_container_health(|| std::future::pending())
.await
.unwrap());
assert_eq!(started.elapsed(), HEALTH_MAX_WAIT);
}
#[tokio::test(start_paused = true)]
async fn blocked_later_inspect_cannot_exceed_initial_deadline() {
let started = tokio::time::Instant::now();
let mut calls = 0;
assert!(!wait_for_container_health(|| {
calls += 1;
let call = calls;
async move {
if call == 1 {
Ok(checked("starting"))
} else {
std::future::pending().await
}
}
})
.await
.unwrap());
assert_eq!(started.elapsed(), Duration::from_secs(176));
}
#[tokio::test(start_paused = true)]
async fn missing_status_is_not_success_for_configured_healthcheck() {
let mut row = checked("starting");
row["State"].as_object_mut().unwrap().remove("Health");
let started = tokio::time::Instant::now();
assert!(
!wait_for_container_health(|| std::future::ready(Ok(row.clone())))
.await
.unwrap()
);
assert_eq!(started.elapsed(), Duration::from_secs(176));
assert!(wait_for_container_health(|| std::future::ready(Ok(
json!({"State":{"Status":"running"}})
)))
.await
.unwrap());
}
#[test]
fn health_budget_defaults_start_period_and_extreme_values_are_bounded() {
assert_eq!(
health_wait_budget(&checked("starting")),
Duration::from_secs(176)
);
let mut row = checked("starting");
row["Config"]["Healthcheck"]["StartPeriod"] = json!(20_000_000_000u64);
assert_eq!(health_wait_budget(&row), Duration::from_secs(196));
for value in [json!(0), json!("bad"), json!(-1)] {
row["Config"]["Healthcheck"] =
json!({"Interval":value,"Timeout":value,"Retries":value});
assert_eq!(health_wait_budget(&row), Duration::from_secs(181));
}
row["Config"]["Healthcheck"] = json!({"StartPeriod":u64::MAX,"Interval":u64::MAX,"Timeout":u64::MAX,"Retries":u64::MAX});
assert_eq!(health_wait_budget(&row), HEALTH_MAX_WAIT);
}
}
+56 -19
View File
@@ -6,6 +6,8 @@ import mimetypes
import os
import secrets
import socket
import ssl
import http.client
import sqlite3
import subprocess
import threading
@@ -128,31 +130,66 @@ def screen_tips(npub, lightning_address):
name, host = lightning_address.split('@')
if not name or not host or any(char in host for char in '/?#:@'):
raise ValueError('invalid Lightning address')
endpoint = assert_public_https(f'https://{host}/.well-known/lnurlp/{quote(name, safe="")}')
class NoRedirect(HTTPRedirectHandler):
def redirect_request(self, *args, **kwargs):
raise ValueError('LNURL redirect is not permitted for screen metadata')
with build_opener(NoRedirect).open(Request(endpoint, headers={'User-Agent': 'JustWorks-Screen/1'}), timeout=4) as response:
raw = response.read(65537)
if len(raw) > 65536:
raise ValueError('LNURL metadata too large')
pay = json.loads(raw)
endpoint = f'https://{host}/.well-known/lnurlp/{quote(name, safe="")}'
pay = fetch_lnurl_json(endpoint, timeout=4)
provider = pay.get('nostrPubkey')
if pay.get('tag') != 'payRequest' or pay.get('allowsNostr') is not True or not isinstance(provider, str) or len(provider) != 64 or any(c not in '0123456789abcdef' for c in provider):
raise ValueError('No verified Nostr receipt source')
return helper({'action': 'screen-tips', 'npub': npub, 'provider': provider, 'relays': PAYMENT_RELAYS})['tips']
def assert_public_https(url):
def resolve_public_https(url):
parsed = urlparse(url)
if parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password:
if parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password or parsed.fragment:
raise ValueError("Lightning provider returned an unsafe URL")
addresses = socket.getaddrinfo(parsed.hostname, parsed.port or 443, type=socket.SOCK_STREAM)
if not addresses or any(not ipaddress.ip_address(item[4][0]).is_global for item in addresses):
raise ValueError("Lightning provider must use a public host")
return url
return parsed, addresses
class PinnedLnurlConnection(http.client.HTTPSConnection):
def __init__(self, parsed, addresses, timeout):
# Direct sockets intentionally ignore ambient HTTP(S)_PROXY settings.
super().__init__(parsed.hostname, parsed.port or 443, timeout=timeout,
context=ssl.create_default_context())
self.addresses = addresses
def connect(self):
last_error = None
for family, kind, protocol, _canonical, address in self.addresses:
connection = socket.socket(family, kind, protocol)
try:
connection.settimeout(self.timeout)
# Numeric sockaddr from the validated lookup: no second DNS lookup.
connection.connect(address)
self.sock = self._context.wrap_socket(connection, server_hostname=self.host)
return
except OSError as error:
connection.close()
last_error = error
raise last_error or OSError("Lightning provider connection unavailable")
def fetch_lnurl_json(url, timeout=10):
parsed, addresses = resolve_public_https(url)
connection = PinnedLnurlConnection(parsed, addresses, timeout)
try:
target = parsed.path or "/"
if parsed.query:
target += "?" + parsed.query
connection.request("GET", target, headers={"User-Agent": "JustWorks-Business/0.1"})
response = connection.getresponse()
if 300 <= response.status < 400:
raise ValueError("Lightning provider redirects are not permitted")
if response.status >= 400:
raise HTTPError(url, response.status, response.reason, response.headers, None)
raw = response.read(65537)
if len(raw) > 65536:
raise ValueError("Lightning provider response too large")
return json.loads(raw)
finally:
connection.close()
def lightning_invoice(lightning_address, amount_msat, comment=""):
@@ -161,14 +198,14 @@ def lightning_invoice(lightning_address, amount_msat, comment=""):
name, host = lightning_address.rsplit("@", 1)
if not name or not host or any(char in host for char in "/?#"):
raise ValueError("Merchant Lightning address is invalid")
endpoint = assert_public_https(f"https://{host}/.well-known/lnurlp/{name}")
pay = fetch_json(endpoint)
endpoint = f"https://{host}/.well-known/lnurlp/{quote(name, safe="")}"
pay = fetch_lnurl_json(endpoint)
if pay.get("tag") != "payRequest" or not pay.get("callback"):
raise ValueError("Lightning address does not support payments")
minimum, maximum = int(pay.get("minSendable", 0)), int(pay.get("maxSendable", 0))
if amount_msat < minimum or (maximum and amount_msat > maximum):
raise ValueError(f"Amount must be between {max(1, minimum // 1000)} and {maximum // 1000} sats")
callback = assert_public_https(str(pay["callback"]))
callback = str(pay["callback"])
# Some LNURL providers reject otherwise valid NIP-57 requests when their
# signed content contains a literal percent sign. Keep the human meaning
# while using a provider-safe comment for both the zap and callback.
@@ -186,7 +223,7 @@ def lightning_invoice(lightning_address, amount_msat, comment=""):
params["comment"] = safe_comment[:int(pay["commentAllowed"])]
separator = "&" if "?" in callback else "?"
try:
invoice = fetch_json(f"{callback}{separator}{urlencode(params)}")
invoice = fetch_lnurl_json(f"{callback}{separator}{urlencode(params)}")
except HTTPError:
if not proof:
raise
@@ -195,7 +232,7 @@ def lightning_invoice(lightning_address, amount_msat, comment=""):
# the result non-verifiable instead of blocking the customer.
proof = None
fallback = {key: value for key, value in params.items() if key not in {"nostr", "lnurl"}}
invoice = fetch_json(f"{callback}{separator}{urlencode(fallback)}")
invoice = fetch_lnurl_json(f"{callback}{separator}{urlencode(fallback)}")
if invoice.get("status") == "ERROR" or not invoice.get("pr"):
raise ValueError(invoice.get("reason", "Lightning invoice unavailable"))
return {"bolt11": invoice["pr"], "zap_pubkey": proof["pubkey"] if proof else None,
@@ -0,0 +1,71 @@
"""No-network checks using real HTTP request/response parsing over fake sockets."""
import io
import socket
import unittest
from unittest.mock import Mock, patch
from urllib.error import HTTPError
import server
PUBLIC = [(socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP, '', ('93.184.216.34', 443))]
PRIVATE = [(socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP, '', ('127.0.0.1', 443))]
class FixtureSocket:
def __init__(self, body=b'{}', redirect=None, status=302):
headers = f'Location: {redirect}\r\n' if redirect else ''
self.response = (f'HTTP/1.1 {status if redirect else 200} Fixture\r\n{headers}Content-Length: {len(body)}\r\nConnection: close\r\n\r\n').encode() + body
self.connected, self.sent, self.closed, self.timeout = [], b'', False, None
def settimeout(self, value): self.timeout = value
def connect(self, address): self.connected.append(address)
def sendall(self, body): self.sent += body
def makefile(self, *args): return io.BytesIO(self.response)
def close(self): self.closed = True
class LnurlTransportTests(unittest.TestCase):
def transport(self, fixture, url='https://provider.example/lnurl', timeout=10):
context = Mock()
context.wrap_socket.return_value = fixture
with patch('server.socket.getaddrinfo', side_effect=[PUBLIC, PRIVATE]) as lookup, patch('server.socket.socket', return_value=fixture), patch('server.ssl.create_default_context', return_value=context), patch.dict('os.environ', {'HTTPS_PROXY':'http://127.0.0.1:9999'}):
try: return server.fetch_lnurl_json(url, timeout=timeout)
finally:
self.assertEqual(lookup.call_count, 1)
self.assertEqual(fixture.connected, [('93.184.216.34', 443)])
context.wrap_socket.assert_called_once_with(fixture, server_hostname='provider.example')
self.assertIn(b'Host: provider.example\r\n', fixture.sent)
self.assertTrue(fixture.closed)
def test_redirects_never_connect_to_another_destination(self):
for destination in ['http://127.0.0.1/admin', 'https://10.0.0.1/private', 'https://other.example/callback']:
for status in [301, 302, 303, 307, 308]:
with self.subTest(destination=destination, status=status), self.assertRaisesRegex(ValueError, 'redirects are not permitted'):
self.transport(FixtureSocket(redirect=destination, status=status))
def test_dns_rebinding_cannot_trigger_second_lookup_and_tls_keeps_hostname(self):
fixture = FixtureSocket(body=b'{"tag":"payRequest"}')
self.assertEqual(self.transport(fixture), {'tag':'payRequest'})
self.assertEqual(fixture.timeout, 10)
def test_response_cap_precedes_json_parsing(self):
with self.assertRaisesRegex(ValueError, 'response too large'): self.transport(FixtureSocket(body=b' ' * 65537))
def test_private_initial_resolution_never_opens_socket(self):
with patch('server.socket.getaddrinfo', return_value=PRIVATE), patch('server.socket.socket') as connect:
with self.assertRaisesRegex(ValueError, 'public host'): server.fetch_lnurl_json('https://provider.example/lnurl')
connect.assert_not_called()
def test_non_https_and_userinfo_never_resolve(self):
for url in ['http://provider.example', 'https://owner:secret@provider.example', 'https://provider.example/#fragment']:
with self.subTest(url=url), patch('server.socket.getaddrinfo') as lookup:
with self.assertRaises(ValueError): server.fetch_lnurl_json(url)
lookup.assert_not_called()
def test_screen_metadata_retains_four_second_timeout_and_shared_transport(self):
with patch('server.fetch_lnurl_json', return_value={'tag':'payRequest','allowsNostr':True,'nostrPubkey':'a'*64}) as fetch, patch('server.helper', return_value={'tips':[]}):
self.assertEqual(server.screen_tips('fixture-npub','merchant@provider.example'), [])
fetch.assert_called_once_with('https://provider.example/.well-known/lnurlp/merchant', timeout=4)
fixture=FixtureSocket();self.transport(fixture,timeout=4);self.assertEqual(fixture.timeout,4)
def test_metadata_and_invoice_use_restricted_transport(self):
pay={'tag':'payRequest','callback':'https://provider.example/invoice','minSendable':1,'maxSendable':5000}
with patch('server.fetch_lnurl_json', side_effect=[pay,{'pr':'fixture-invoice'}]) as fetch, patch('server.fetch_json') as unrestricted:
result=server.lightning_invoice('merchant@provider.example',1000)
self.assertEqual(result['bolt11'],'fixture-invoice');self.assertEqual(fetch.call_count,2);unrestricted.assert_not_called()
def test_zap_fallback_uses_restricted_transport(self):
pay={'tag':'payRequest','callback':'https://provider.example/invoice','allowsNostr':True,'nostrPubkey':'a'*64}
with patch('server.helper',return_value={'event':{},'pubkey':'b'*64,'lnurl':'fixture-lnurl'}), patch('server.fetch_lnurl_json',side_effect=[pay,HTTPError('',400,'fixture',{},None),{'pr':'fixture-invoice'}]) as fetch, patch('server.fetch_json') as unrestricted:
result=server.lightning_invoice('merchant@provider.example',1000)
self.assertFalse(result['verifiable']);self.assertEqual(fetch.call_count,3);unrestricted.assert_not_called()
if __name__=='__main__': unittest.main()
+2 -2
View File
@@ -37,8 +37,8 @@ consent. No node wallet key is sent to hosted Core.
Ingest into the next normal deployment
1. Review/merge once on ngit, then mirror the exact accepted commits to Gitea.
The explicitly requested Gitea PR is a cross-linked review mirror; do not
independently squash or merge it to produce a different history.
PRs belong on ngit only. Gitea mirrors accepted code; do not open a duplicate
PR or independently squash/merge to produce a different history.
2. Build the current combined platform source. Do not deploy an old demo backend
or replace newer work with the binary recorded below. Include the Just Works
FIPS port mapping and existing native-import RPCs.
+40
View File
@@ -1558,3 +1558,43 @@ announcements remain unrecovered from the sources checked. This releases the
all-project-discovery publication hold, not a claim that recovery passed. Track
recovery separately and retain the limitation in release notes. Other artifact,
upgrade, security and publication checks remain required.
## 2026-10-08: final IndeeHub update restored at health-check boundary
Native operation `851483a2` automatically restored during target startup, before
the final frontend was started. This is **not successful final delivery**.
The MinIO target started at 18:24:31.489 UTC. Its immediate probe was still
`starting`; MinIO reported API readiness at 18:24:32, and the next scheduled
health check reported `healthy` at 18:25:02.851. The updater's previous 30 samples,
one second apart with no final sample after sleeping, could expire before that
healthy result. The actual health configuration uses a 30-second interval,
five-second timeout and five retries. PostgreSQL, Redis and MinIO were the only
target members started; relay, API, worker and final frontend were not started.
Independent post-restoration verification passed: all 31 running containers,
24 unrelated runtime identities preserved, the exact current 110-migration
history and original database commitments preserved, and restoration-image
proof matched. Retain the operation journal and backup history; do not treat a
restored transaction as a successful update or erase the first failed attempt.
The shared native provider (`b77…`) is deployed. The final frontend image
(`8db…`) remains pending; paired cached-account-A to chosen-identity-B acceptance
has not run. Prior clean native login success does not prove this reported
cached-account transition is fixed on the deployed frontend.
Source correction `7fe63355` replaces the sample-count deadline with a bounded
monotonic readiness deadline derived once from the first inspected health
configuration. It includes the configured start period, intervals, timeouts and
retries, with a 30-second minimum and five-minute ceiling; every inspect is
bounded too. `starting` never qualifies as healthy, configured-but-missing health
status stays pending, and unhealthy or exited containers fail immediately.
Running-only readiness remains allowed only for containers without a configured
health check. The isolated runner checkout bind is separately committed as
`1b0b119a` and preserves all existing isolation properties.
Validation at this checkpoint: source formatting and independent source review
passed; seven deterministic paused-time regressions are compiling through the
isolated runner. The full isolated suite, production backend build, another
explicit native update, post-update runtime/data proofs, and paired browser
acceptance remain pending. No wallet, payment, personal-media or profile changes
are part of this readiness correction.
+1
View File
@@ -42,6 +42,7 @@ PY
unit="archy-isolated-tests-$(date +%s)-$$"
sudo -n systemd-run --unit="$unit" --wait --pipe --collect \
--property="WorkingDirectory=$REPO/core" \
--property="BindReadOnlyPaths=$REPO" \
--property=PrivateNetwork=yes --property=PrivateTmp=yes --property=PrivateDevices=yes \
--property=ProtectSystem=strict --property=ProtectHome=read-only \
--property=NoNewPrivileges=yes \