myc

Self-custodial remote signer for Radroots apps
git clone https://radroots.dev/git/myc.git
Log | Files | Refs | README | LICENSE

commit b8071bd9c7da8d1f40c9217aea4e4b92ad67b4b1
parent 9d9d291fdc29cfa0d4fe3278fa1440631c7611cf
Author: triesap <tyson@radroots.org>
Date:   Sat, 22 Aug 2026 06:31:16 +0000

operations: bind passive TCP health and metrics

Diffstat:
MAGENTS.md | 4++++
MREADME | 13+++++++++++++
Mcontracts/api_baselines/myc.txt | 41+++++++++++++++++++++++++++++++++++++++++
Mcontracts/services_hardening/config.v1.schema.json | 2+-
Acontracts/services_hardening/tcp_operations.v1.json | 50++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/lib.rs | 6++++++
Asrc/operations_v1.rs | 267+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/status_v1.rs | 121+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
Mtests/package_boundary.rs | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mtests/services_hardening_config_contract.rs | 4++++
Atests/services_hardening_operations.rs | 234+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
11 files changed, 775 insertions(+), 27 deletions(-)

diff --git a/AGENTS.md b/AGENTS.md @@ -241,6 +241,10 @@ - Optional TCP operations expose only cached `/livez`, `/readyz`, and `/metrics`. They must not perform SQLite, provider, relay, credential, DNS, or other active probes. +- Keep the Myc TCP adapter sealed around the source-locked service-host server. + Do not add route registration, raw listener/server access, dependency-owned + public types, or a second independently observed lifecycle cache. Publish + only the fixed cached phase/readiness metric families and closed phase label. - Keep logs as safe structured stderr output. Keep result data on stdout and diagnostics on stderr. Use stable bounded public codes and messages, bounded metric labels, and explicit redaction. diff --git a/README b/README @@ -94,6 +94,19 @@ status remains local to the permissioned Unix-admin surface. Status reason codes use a closed twelve-value vocabulary; arbitrary strings cannot enter the cached response. +The optional TCP operations adapter consumes that same supervisor publication +through a second passive bounded projection and delegates the transport to the +source-locked service-host server. It exposes exactly HTTP/1.1 `GET /livez`, +`GET /readyz`, and `GET /metrics`; every other method, path, or query is +unrouteable. The two Prometheus families are the cached phase and cached +readiness bit with only the closed phase label. Requests perform no SQLite, +filesystem, provider, relay, credential, DNS, clock, or fresh-probe work. +Detailed status, configuration, paths, identities, connection/audit data, and +all mutations remain on the permissioned Unix-admin surface. The listener is +disabled unless the validated configuration explicitly enables and binds it, +and its parser floor, headers, response, concurrency, deadline, and idle bounds +remain enforced by the shared server. + `parse_myc_config_v1` caps original bytes before decoding, checks the schema header before closed contract admission, rejects duplicate, null, unknown, and semantically inconsistent input, and returns an immutable document plus a diff --git a/contracts/api_baselines/myc.txt b/contracts/api_baselines/myc.txt @@ -485,6 +485,15 @@ pub enum myc::MycNip46WorkKind pub myc::MycNip46WorkKind::Connect pub myc::MycNip46WorkKind::Local pub myc::MycNip46WorkKind::Provider +pub enum myc::MycOperationsErrorKind +pub myc::MycOperationsErrorKind::Accept +pub myc::MycOperationsErrorKind::Bind +pub myc::MycOperationsErrorKind::ConnectionTaskPanicked +pub myc::MycOperationsErrorKind::Disabled +pub myc::MycOperationsErrorKind::InvalidConfiguration +pub myc::MycOperationsErrorKind::LocalAddress +impl myc::MycOperationsErrorKind +pub const fn myc::MycOperationsErrorKind::code(self) -> &'static str pub enum myc::MycPersistenceHealthV1 pub myc::MycPersistenceHealthV1::ReadOnly pub myc::MycPersistenceHealthV1::Ready @@ -823,6 +832,12 @@ pub fn myc::MycAuthorizationChallengeUrl::as_str(&self) -> &str pub fn myc::MycAuthorizationChallengeUrl::new(&str) -> core::result::Result<Self, myc::MycConnectionStateError> impl core::fmt::Debug for myc::MycAuthorizationChallengeUrl pub fn myc::MycAuthorizationChallengeUrl::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct myc::MycBoundOperationsServer +impl myc::MycBoundOperationsServer +pub fn myc::MycBoundOperationsServer::local_address(&self) -> core::net::socket_addr::SocketAddr +pub async fn myc::MycBoundOperationsServer::serve(self, myc::MycOperationsCancellationToken) -> core::result::Result<(), myc::MycOperationsError> +impl core::fmt::Debug for myc::MycBoundOperationsServer +pub fn myc::MycBoundOperationsServer::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result pub struct myc::MycBoundedNip46Event impl myc::MycBoundedNip46Event pub fn myc::MycBoundedNip46Event::encrypted_content(&self) -> &str @@ -1408,6 +1423,28 @@ impl myc::MycNormalizedConfigDigest pub const fn myc::MycNormalizedConfigDigest::as_bytes(&self) -> &[u8; 32] impl core::fmt::Debug for myc::MycNormalizedConfigDigest pub fn myc::MycNormalizedConfigDigest::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct myc::MycOperationsCancellationToken +impl myc::MycOperationsCancellationToken +pub fn myc::MycOperationsCancellationToken::cancel(&self) +pub fn myc::MycOperationsCancellationToken::is_cancelled(&self) -> bool +pub fn myc::MycOperationsCancellationToken::new() -> Self +impl core::fmt::Debug for myc::MycOperationsCancellationToken +pub fn myc::MycOperationsCancellationToken::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct myc::MycOperationsError +impl myc::MycOperationsError +pub const fn myc::MycOperationsError::code(self) -> &'static str +pub const fn myc::MycOperationsError::kind(self) -> myc::MycOperationsErrorKind +impl core::error::Error for myc::MycOperationsError +impl core::fmt::Debug for myc::MycOperationsError +pub fn myc::MycOperationsError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::fmt::Display for myc::MycOperationsError +pub fn myc::MycOperationsError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct myc::MycOperationsServer +impl myc::MycOperationsServer +pub async fn myc::MycOperationsServer::bind(self) -> core::result::Result<myc::MycBoundOperationsServer, myc::MycOperationsError> +pub fn myc::MycOperationsServer::new(&myc::MycConfigDocumentV1, &myc::MycStatusReader) -> core::result::Result<Self, myc::MycOperationsError> +impl core::fmt::Debug for myc::MycOperationsServer +pub fn myc::MycOperationsServer::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result pub struct myc::MycOutboxStatusV1 impl myc::MycOutboxStatusV1 pub const fn myc::MycOutboxStatusV1::new(u64, u64, core::option::Option<myc::MycStatusUnixSeconds>) -> Self @@ -1897,8 +1934,10 @@ pub const myc::MYC_DOCTOR_SUMMARY_MAX_UTF8_BYTES: usize pub const myc::MYC_ENCRYPTED_IDENTITY_BACKUP_INCLUDED: bool pub const myc::MYC_ENCRYPTED_IDENTITY_ENVELOPE_CONTRACT_VERSION: u32 pub const myc::MYC_ENCRYPTED_IDENTITY_ENVELOPE_MAX_BYTES: usize +pub const myc::MYC_LIVEZ_PATH: &str pub const myc::MYC_LOCAL_SIGNER_ENDPOINT: &str pub const myc::MYC_LOCAL_SIGNER_TRANSPORT_CONTRACT_VERSION: u32 +pub const myc::MYC_METRICS_PATH: &str pub const myc::MYC_MIGRATION_CATALOG_SHA256: [u8; 32] pub const myc::MYC_NIP05_DOCUMENT_MAX_BYTES: usize pub const myc::MYC_NIP05_PROJECTION_MAX_BYTES: usize @@ -1907,6 +1946,7 @@ pub const myc::MYC_NIP46_EVENT_ID_MAX_BYTES: usize pub const myc::MYC_NIP46_PUBLIC_KEY_MAX_BYTES: usize pub const myc::MYC_NIP46_REQUEST_ID_MAX_UTF8_BYTES: usize pub const myc::MYC_NIP46_SIGNATURE_MAX_BYTES: usize +pub const myc::MYC_OPERATIONS_CONTRACT_VERSION: u32 pub const myc::MYC_OPERATOR_CONTRACT_VERSION: u32 pub const myc::MYC_PROVIDER_CONCURRENCY_MAX: u32 pub const myc::MYC_PROVIDER_CONTRACT_VERSION: u32 @@ -1920,6 +1960,7 @@ pub const myc::MYC_RATE_MAX_TRACKED_SUBJECTS: u32 pub const myc::MYC_RATE_RELAY_ID_MAX_BYTES: usize pub const myc::MYC_RATE_RETENTION_MAX_MS: u64 pub const myc::MYC_RATE_WINDOW_MAX_MS: u64 +pub const myc::MYC_READYZ_PATH: &str pub const myc::MYC_RUNTIME_FOUNDATION_CONTRACT_VERSION: u32 pub const myc::MYC_SIGNER_STATUS_CONTRACT_VERSION: u32 pub const myc::MYC_STATE_APPLICATION_ID: u32 diff --git a/contracts/services_hardening/config.v1.schema.json b/contracts/services_hardening/config.v1.schema.json @@ -129,7 +129,7 @@ "additionalProperties": false, "properties": { "header_count": { "type": "integer", "minimum": 1, "maximum": 64, "default": 32, "x-radroots-default-source": "radroots_service_host" }, - "header_bytes": { "type": "integer", "minimum": 1, "maximum": 32768, "default": 16384, "x-radroots-default-source": "radroots_service_host" }, + "header_bytes": { "type": "integer", "minimum": 8192, "maximum": 32768, "default": 16384, "x-radroots-default-source": "radroots_service_host" }, "response_body_utf8_bytes": { "type": "integer", "minimum": 1, "maximum": 1048576, "default": 1048576, "x-radroots-default-source": "radroots_service_host" }, "concurrent_connections": { "type": "integer", "minimum": 1, "maximum": 64, "default": 32, "x-radroots-default-source": "radroots_service_host" }, "request_deadline_ms": { "type": "integer", "minimum": 1, "maximum": 30000, "default": 15000, "x-radroots-default-source": "radroots_service_host" }, diff --git a/contracts/services_hardening/tcp_operations.v1.json b/contracts/services_hardening/tcp_operations.v1.json @@ -0,0 +1,50 @@ +{ + "schema": "radroots.myc.tcp-operations.v1", + "contract_version": 1, + "step": 155, + "transport": "http_1_1_over_optional_tcp", + "configuration": "validated_myc_config_v1_operations_block", + "route_registration_extension": false, + "routes": [ + { "method": "GET", "path": "/livez", "source": "cached_supervisor_state" }, + { "method": "GET", "path": "/readyz", "source": "cached_readiness_state" }, + { "method": "GET", "path": "/metrics", "source": "cached_bounded_metrics_snapshot" } + ], + "metrics": { + "format": "prometheus_text_0_0_4", + "families": [ + { "name": "radroots_myc_service_phase", "kind": "gauge", "labels": ["phase"] }, + { "name": "radroots_myc_service_ready", "kind": "gauge", "labels": [] } + ], + "high_cardinality_labels": false, + "arbitrary_labels": false + }, + "passive_read_forbidden_authority": [ + "sqlite", + "filesystem", + "provider", + "relay", + "credential", + "dns", + "clock", + "fresh_probe" + ], + "local_only": [ + "detailed_status", + "configuration", + "paths", + "identities", + "connections", + "audit", + "mutations" + ], + "deferrals": [ + "daemon_composition", + "structured_logging_and_exit", + "integration_wave", + "rcld_promotion", + "nix", + "oci", + "terminal_consumer_convergence" + ] +} diff --git a/src/lib.rs b/src/lib.rs @@ -15,6 +15,7 @@ mod nip46_wave_080_a; #[cfg(all(test, any(target_os = "linux", target_os = "macos")))] mod nip46_wave_080_b; mod nip46_work; +mod operations_v1; mod provider_contract; mod provider_credential; mod provider_envelope; @@ -82,6 +83,11 @@ pub use nip46_work::{ MycNip46WorkErrorKind, MycNip46WorkKind, MycPreparedNip46Request, prepare_myc_nip46_decrypt_work, prepare_myc_nip46_request, prepare_myc_nip46_work, }; +pub use operations_v1::{ + MYC_LIVEZ_PATH, MYC_METRICS_PATH, MYC_OPERATIONS_CONTRACT_VERSION, MYC_READYZ_PATH, + MycBoundOperationsServer, MycOperationsCancellationToken, MycOperationsError, + MycOperationsErrorKind, MycOperationsServer, +}; pub use provider_contract::{ MYC_PROVIDER_CONCURRENCY_MAX, MYC_PROVIDER_CONTRACT_VERSION, MYC_PROVIDER_INPUT_MAX_BYTES, MYC_PROVIDER_OUTPUT_MAX_BYTES, MYC_PROVIDER_REQUEST_DEADLINE_MAX_MS, diff --git a/src/operations_v1.rs b/src/operations_v1.rs @@ -0,0 +1,267 @@ +//! Myc-owned adapter for the exact passive TCP operations surface. + +use core::{fmt, time::Duration}; +use std::{error::Error, net::SocketAddr}; + +use radroots_service_host::{ + BoundOperationsServer as HostBoundOperationsServer, CancellationToken as HostCancellationToken, + OperationsBindPolicy as HostOperationsBindPolicy, + OperationsListenAddress as HostOperationsListenAddress, + OperationsListenerConfig as HostOperationsListenerConfig, + OperationsServer as HostOperationsServer, OperationsServerError as HostOperationsServerError, + OperationsTransportLimitValues as HostOperationsTransportLimitValues, + OperationsTransportLimits as HostOperationsTransportLimits, +}; +use serde_json::Value; + +use crate::{MycConfigDocumentV1, MycStatusReader}; + +/// Exact Myc TCP operations contract version. +pub const MYC_OPERATIONS_CONTRACT_VERSION: u32 = 1; + +/// Exact liveness route exposed by the optional TCP listener. +pub const MYC_LIVEZ_PATH: &str = "/livez"; + +/// Exact readiness route exposed by the optional TCP listener. +pub const MYC_READYZ_PATH: &str = "/readyz"; + +/// Exact bounded metrics route exposed by the optional TCP listener. +pub const MYC_METRICS_PATH: &str = "/metrics"; + +/// Cloneable cooperative cancellation owned by the Myc runtime supervisor. +#[derive(Clone, Default)] +pub struct MycOperationsCancellationToken { + inner: HostCancellationToken, +} + +impl MycOperationsCancellationToken { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Requests cancellation. Repeated requests have no additional effect. + pub fn cancel(&self) { + self.inner.cancel(); + } + + #[must_use] + pub fn is_cancelled(&self) -> bool { + self.inner.is_cancelled() + } +} + +impl fmt::Debug for MycOperationsCancellationToken { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MycOperationsCancellationToken") + .field("cancelled", &self.is_cancelled()) + .finish() + } +} + +/// Unbound exact-route Myc TCP operations server. +pub struct MycOperationsServer { + inner: HostOperationsServer, +} + +impl MycOperationsServer { + /// Projects the already-validated Myc configuration and passive status cache. + pub fn new( + config: &MycConfigDocumentV1, + status: &MycStatusReader, + ) -> Result<Self, MycOperationsError> { + let listener = listener_config(config)?; + HostOperationsServer::new(listener, status.operations_cache()) + .map(|inner| Self { inner }) + .map_err(map_server_error) + } + + /// Binds the exact configured address without starting admission. + pub async fn bind(self) -> Result<MycBoundOperationsServer, MycOperationsError> { + self.inner + .bind() + .await + .map(|inner| MycBoundOperationsServer { inner }) + .map_err(map_server_error) + } +} + +impl fmt::Debug for MycOperationsServer { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("MycOperationsServer([sealed])") + } +} + +/// Successfully bound exact-route Myc TCP operations server. +pub struct MycBoundOperationsServer { + inner: HostBoundOperationsServer, +} + +impl MycBoundOperationsServer { + #[must_use] + pub fn local_address(&self) -> SocketAddr { + self.inner.local_address() + } + + /// Serves until explicit supervisor cancellation, then drains owned work. + pub async fn serve( + self, + cancellation: MycOperationsCancellationToken, + ) -> Result<(), MycOperationsError> { + self.inner + .serve(cancellation.inner) + .await + .map_err(map_server_error) + } +} + +impl fmt::Debug for MycBoundOperationsServer { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("MycBoundOperationsServer([sealed])") + } +} + +/// Stable source-free Myc operations failure classification. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MycOperationsErrorKind { + Disabled, + InvalidConfiguration, + Bind, + LocalAddress, + Accept, + ConnectionTaskPanicked, +} + +impl MycOperationsErrorKind { + #[must_use] + pub const fn code(self) -> &'static str { + match self { + Self::Disabled => "operations_disabled", + Self::InvalidConfiguration => "operations_configuration_invalid", + Self::Bind => "operations_bind_failed", + Self::LocalAddress => "operations_local_address_failed", + Self::Accept => "operations_accept_failed", + Self::ConnectionTaskPanicked => "operations_connection_task_panicked", + } + } +} + +/// One redacted source-free Myc operations failure. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct MycOperationsError { + kind: MycOperationsErrorKind, +} + +impl MycOperationsError { + const fn new(kind: MycOperationsErrorKind) -> Self { + Self { kind } + } + + #[must_use] + pub const fn kind(self) -> MycOperationsErrorKind { + self.kind + } + + #[must_use] + pub const fn code(self) -> &'static str { + self.kind.code() + } +} + +impl fmt::Debug for MycOperationsError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MycOperationsError") + .field("kind", &self.kind) + .finish() + } +} + +impl fmt::Display for MycOperationsError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("Myc TCP operations failed") + } +} + +impl Error for MycOperationsError {} + +fn listener_config( + config: &MycConfigDocumentV1, +) -> Result<HostOperationsListenerConfig, MycOperationsError> { + let operations = config + .normalized() + .pointer("/operations") + .ok_or_else(invalid_configuration)?; + if !boolean(operations, "/enabled")? { + return Err(MycOperationsError::new(MycOperationsErrorKind::Disabled)); + } + let listen = string(operations, "/listen")? + .parse::<SocketAddr>() + .map_err(|_| invalid_configuration())?; + let listen = HostOperationsListenAddress::new(listen).map_err(|_| invalid_configuration())?; + let bind_policy = match string(operations, "/bind_policy")? { + "loopback_only" => HostOperationsBindPolicy::LoopbackOnly, + "public" => HostOperationsBindPolicy::Public, + _ => return Err(invalid_configuration()), + }; + let values = HostOperationsTransportLimitValues { + header_count: unsigned_u32(operations, "/limits/header_count")?, + header_bytes: unsigned_u32(operations, "/limits/header_bytes")?, + response_body_utf8_bytes: unsigned_u32(operations, "/limits/response_body_utf8_bytes")?, + concurrent_connections: unsigned_u32(operations, "/limits/concurrent_connections")?, + request_deadline: Duration::from_millis(unsigned( + operations, + "/limits/request_deadline_ms", + )?), + idle_timeout: Duration::from_millis(unsigned(operations, "/limits/idle_timeout_ms")?), + }; + let limits = HostOperationsTransportLimits::new(values).map_err(|_| invalid_configuration())?; + HostOperationsListenerConfig::enabled(listen, bind_policy, limits) + .map_err(|_| invalid_configuration()) +} + +fn boolean(value: &Value, pointer: &str) -> Result<bool, MycOperationsError> { + value + .pointer(pointer) + .and_then(Value::as_bool) + .ok_or_else(invalid_configuration) +} + +fn string<'a>(value: &'a Value, pointer: &str) -> Result<&'a str, MycOperationsError> { + value + .pointer(pointer) + .and_then(Value::as_str) + .ok_or_else(invalid_configuration) +} + +fn unsigned(value: &Value, pointer: &str) -> Result<u64, MycOperationsError> { + value + .pointer(pointer) + .and_then(Value::as_u64) + .ok_or_else(invalid_configuration) +} + +fn unsigned_u32(value: &Value, pointer: &str) -> Result<u32, MycOperationsError> { + u32::try_from(unsigned(value, pointer)?).map_err(|_| invalid_configuration()) +} + +const fn invalid_configuration() -> MycOperationsError { + MycOperationsError::new(MycOperationsErrorKind::InvalidConfiguration) +} + +const fn map_server_error(error: HostOperationsServerError) -> MycOperationsError { + let kind = match error { + HostOperationsServerError::Disabled => MycOperationsErrorKind::Disabled, + HostOperationsServerError::HeaderLimitBelowParserFloor => { + MycOperationsErrorKind::InvalidConfiguration + } + HostOperationsServerError::Bind { .. } => MycOperationsErrorKind::Bind, + HostOperationsServerError::LocalAddress { .. } => MycOperationsErrorKind::LocalAddress, + HostOperationsServerError::Accept { .. } => MycOperationsErrorKind::Accept, + HostOperationsServerError::ConnectionTaskPanicked => { + MycOperationsErrorKind::ConnectionTaskPanicked + } + }; + MycOperationsError::new(kind) +} diff --git a/src/status_v1.rs b/src/status_v1.rs @@ -4,16 +4,19 @@ use core::fmt; use std::{error::Error, sync::Arc, time::Duration}; use radroots_service_host::{ - BuildInfo as HostBuildInfo, BuildInfoEnvironment as HostBuildInfoEnvironment, - BuildMode as HostBuildMode, CachedServiceState, CachedServiceStatePublisher, - CachedServiceStateReader, ConfigurationIdentity as HostConfigurationIdentity, + BoundedMetricsSnapshot, BuildInfo as HostBuildInfo, + BuildInfoEnvironment as HostBuildInfoEnvironment, BuildMode as HostBuildMode, + CachedServiceState, CachedServiceStatePublisher, CachedServiceStateReader, CommonMetricGroup, + ConfigurationIdentity as HostConfigurationIdentity, ConfigurationSource as HostConfigurationSource, ContractVersions as HostContractVersions, - InstanceId, IntegrityState as HostIntegrityState, PersistenceHealth as HostPersistenceHealth, - PersistenceSummary as HostPersistenceSummary, Readiness as HostReadiness, - ReasonCode as HostReasonCode, ReasonCodes as HostReasonCodes, ServiceId, - ServiceOperationalState as HostServiceOperationalState, ServicePhase as HostServicePhase, - ServiceStatus, ServiceStatusDetail, Sha256Digest as HostSha256Digest, StatusContractError, - StatusEncodingError, StatusModelError, UptimeMillis as HostUptimeMillis, cached_service_state, + InstanceId, IntegrityState as HostIntegrityState, MetricDescriptor, MetricKind, MetricLabel, + MetricLabelKey, MetricName, MetricSample, MetricValue, + PersistenceHealth as HostPersistenceHealth, PersistenceSummary as HostPersistenceSummary, + Readiness as HostReadiness, ReasonCode as HostReasonCode, ReasonCodes as HostReasonCodes, + ServiceId, ServiceOperationalState as HostServiceOperationalState, + ServicePhase as HostServicePhase, ServiceStatus, ServiceStatusDetail, + Sha256Digest as HostSha256Digest, StatusContractError, StatusEncodingError, StatusModelError, + UptimeMillis as HostUptimeMillis, cached_service_state, }; use serde::Serialize; @@ -683,11 +686,9 @@ impl MycStatusObservationV1 { } } - fn into_cached( - self, - instance: &InstanceId, - ) -> Result<CachedServiceState<MycCachedStatus>, MycStatusError> { + fn into_cached(self, instance: &InstanceId) -> Result<PreparedMycStatus, MycStatusError> { let operational = self.common.operational.clone(); + let operations_metrics = bounded_operations_metrics(&operational)?; let detail = MycStatusDetailV1 { transport: self.provider.transport.clone(), user: self.provider.user.clone(), @@ -711,12 +712,15 @@ impl MycStatusObservationV1 { ) .map_err(map_model_error)?; let json = status.to_bounded_json().map_err(map_encoding_error)?; - Ok(CachedServiceState::new( - operational, - MycCachedStatus { - json: json.into_boxed_slice(), - }, - )) + Ok(PreparedMycStatus { + detail: CachedServiceState::new( + operational.clone(), + MycCachedStatus { + json: json.into_boxed_slice(), + }, + ), + operations: CachedServiceState::new(operational, operations_metrics), + }) } } @@ -730,6 +734,55 @@ struct MycCachedStatus { json: Box<[u8]>, } +struct PreparedMycStatus { + detail: CachedServiceState<MycCachedStatus>, + operations: CachedServiceState<BoundedMetricsSnapshot>, +} + +fn bounded_operations_metrics( + operational: &HostServiceOperationalState, +) -> Result<BoundedMetricsSnapshot, MycStatusError> { + let phase_name = MetricName::new("radroots_myc_service_phase").map_err(map_metrics_error)?; + let ready_name = MetricName::new("radroots_myc_service_ready").map_err(map_metrics_error)?; + let descriptors = [ + MetricDescriptor::new( + CommonMetricGroup::Phase, + phase_name.clone(), + "Current cached Myc service phase.", + MetricKind::Gauge, + [MetricLabelKey::Phase], + ) + .map_err(map_metrics_error)?, + MetricDescriptor::new( + CommonMetricGroup::Phase, + ready_name.clone(), + "Current cached Myc readiness bit.", + MetricKind::Gauge, + [], + ) + .map_err(map_metrics_error)?, + ]; + let samples = [ + MetricSample::new( + phase_name, + MetricValue::Gauge(1), + [MetricLabel::phase(operational.phase())], + ) + .map_err(map_metrics_error)?, + MetricSample::new( + ready_name, + MetricValue::Gauge(i64::from(operational.readiness().is_ready())), + [], + ) + .map_err(map_metrics_error)?, + ]; + BoundedMetricsSnapshot::new(descriptors, samples).map_err(map_metrics_error) +} + +fn map_metrics_error(_: radroots_service_host::MetricsContractError) -> MycStatusError { + MycStatusError::new(MycStatusErrorKind::InvalidModel) +} + impl fmt::Debug for MycCachedStatus { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter @@ -747,13 +800,18 @@ impl fmt::Debug for MycCachedStatus { pub struct MycStatusPublisher { instance: InstanceId, inner: CachedServiceStatePublisher<MycCachedStatus>, + operations: CachedServiceStatePublisher<BoundedMetricsSnapshot>, } impl MycStatusPublisher { - /// Encodes and atomically publishes one already-observed status value. + /// Encodes one observation, publishes its passive operations projection, + /// and then atomically replaces the detailed-status snapshot. pub fn publish(&mut self, next: MycStatusObservationV1) -> Result<(), MycStatusError> { let next = next.into_cached(&self.instance)?; - self.inner.publish(next).map_err(map_contract_error) + self.operations + .publish(next.operations) + .map_err(map_contract_error)?; + self.inner.publish(next.detail).map_err(map_contract_error) } /// Creates another passive reader without sharing publication authority. @@ -761,6 +819,7 @@ impl MycStatusPublisher { pub fn subscribe(&self) -> MycStatusReader { MycStatusReader { inner: self.inner.subscribe(), + operations: self.operations.subscribe(), } } } @@ -774,12 +833,14 @@ impl fmt::Debug for MycStatusPublisher { /// Cloneable passive reader of the latest Myc lifecycle and detailed status. pub struct MycStatusReader { inner: CachedServiceStateReader<MycCachedStatus>, + operations: CachedServiceStateReader<BoundedMetricsSnapshot>, } impl Clone for MycStatusReader { fn clone(&self) -> Self { Self { inner: self.inner.clone(), + operations: self.operations.clone(), } } } @@ -801,6 +862,10 @@ impl MycStatusReader { .map(|inner| MycStatusSnapshot { inner }) .map_err(|_| MycStatusError::new(MycStatusErrorKind::PublisherDropped)) } + + pub(crate) fn operations_cache(&self) -> CachedServiceStateReader<BoundedMetricsSnapshot> { + self.operations.clone() + } } impl fmt::Debug for MycStatusReader { @@ -849,10 +914,18 @@ pub fn myc_status_cache( initial: MycStatusObservationV1, ) -> Result<(MycStatusPublisher, MycStatusReader), MycStatusError> { let initial = initial.into_cached(&instance)?; - let (inner, reader) = cached_service_state(initial); + let (inner, reader) = cached_service_state(initial.detail); + let (operations, operations_reader) = cached_service_state(initial.operations); Ok(( - MycStatusPublisher { instance, inner }, - MycStatusReader { inner: reader }, + MycStatusPublisher { + instance, + inner, + operations, + }, + MycStatusReader { + inner: reader, + operations: operations_reader, + }, )) } diff --git a/tests/package_boundary.rs b/tests/package_boundary.rs @@ -17,6 +17,9 @@ const DELIVERY_RECOVERY: &str = include_str!("../src/state_recovery.rs"); const DOCTOR_V1: &str = include_str!("../src/doctor_v1.rs"); const STATUS_V1: &str = include_str!("../src/status_v1.rs"); const STATUS_CONTRACT: &str = include_str!("../contracts/services_hardening/status_cache.v1.json"); +const OPERATIONS_V1: &str = include_str!("../src/operations_v1.rs"); +const OPERATIONS_CONTRACT: &str = + include_str!("../contracts/services_hardening/tcp_operations.v1.json"); const DISCOVERY_STATE: &str = include_str!("../src/state_discovery.rs"); const NIP46_VERIFICATION_CONTRACT: &str = include_str!("../contracts/services_hardening/nip46_verification.v1.json"); @@ -44,6 +47,7 @@ const SOURCES: &[&str] = &[ include_str!("../src/nip46_replay.rs"), include_str!("../src/nip46_verification.rs"), include_str!("../src/nip46_work.rs"), + include_str!("../src/operations_v1.rs"), include_str!("../src/provider_contract.rs"), include_str!("../src/provider_credential.rs"), include_str!("../src/provider_envelope.rs"), @@ -86,6 +90,7 @@ fn implementation_modules_are_private_and_rustdoc_uses_the_reviewed_readme() { "nip46_work", "nip46_wave_080_a", "nip46_wave_080_b", + "operations_v1", "provider_contract", "provider_credential", "provider_envelope", @@ -148,6 +153,11 @@ fn reviewed_api_is_root_only_and_exposes_no_implementation_authority() { "pub struct myc::MycStatusCommonV1", "pub struct myc::MycStatusObservationV1", "pub fn myc::myc_status_cache", + "pub struct myc::MycOperationsServer", + "pub struct myc::MycBoundOperationsServer", + "pub struct myc::MycOperationsCancellationToken", + "pub struct myc::MycOperationsError", + "pub enum myc::MycOperationsErrorKind", "pub struct myc::MycAdminRequestDocument", "pub struct myc::MycAdminResponseDocument", "pub enum myc::MycAdminMethod", @@ -228,6 +238,7 @@ fn reviewed_api_is_root_only_and_exposes_no_implementation_authority() { "nip46_work", "nip46_wave_080_a", "nip46_wave_080_b", + "operations_v1", "provider_contract", "provider_credential", "provider_envelope", @@ -291,7 +302,8 @@ fn status_cache_is_passive_latest_value_and_dependency_neutral() { "pub struct MycStatusSnapshot", "pub fn myc_status_cache(", "status.to_bounded_json()", - "self.inner.publish(next)", + ".publish(next.operations)", + ".publish(next.detail)", "self.inner.snapshot()", "connection_counts: MycConnectionCountsV1", "oldest_pending_at_utc: Option<MycStatusUnixSeconds>", @@ -339,6 +351,50 @@ fn status_cache_is_passive_latest_value_and_dependency_neutral() { } #[test] +fn step155_tcp_operations_are_exact_passive_and_dependency_neutral() { + let contract: serde_json::Value = + serde_json::from_str(OPERATIONS_CONTRACT).expect("Step 155 operations contract"); + assert_eq!(contract["schema"], "radroots.myc.tcp-operations.v1"); + assert_eq!(contract["contract_version"], 1); + assert_eq!(contract["step"], 155); + assert_eq!(contract["route_registration_extension"], false); + for required in [ + "HostOperationsServer::new(listener, status.operations_cache())", + "MycOperationsCancellationToken", + "radroots_myc_service_phase", + "radroots_myc_service_ready", + "HostOperationsTransportLimits::new(values)", + "HeaderLimitBelowParserFloor", + ] { + assert!( + OPERATIONS_V1.contains(required) || STATUS_V1.contains(required), + "Step 155 implementation is missing `{required}`" + ); + } + for forbidden in [ + "sqlx::", + "std::fs::", + "tokio::spawn", + "spawn_blocking", + "SystemTime", + "provider.execute", + "relay.connect", + "credential", + "dns", + "route(", + "Router", + ] { + assert!( + !OPERATIONS_V1.contains(forbidden), + "Step 155 adapter gained forbidden authority `{forbidden}`" + ); + } + assert!(README.contains("exactly HTTP/1.1 `GET /livez`")); + assert!(README.contains("Requests perform no SQLite")); + assert!(!PUBLIC_API.contains("radroots_service_host::")); +} + +#[test] fn doctor_boundary_is_closed_bounded_and_dependency_neutral() { for required in [ "MYC_DOCTOR_CHECK_COUNT: usize = 13", @@ -710,7 +766,7 @@ fn public_errors_remain_crate_owned_redacted_and_source_free() { .lines() .filter(|line| line.starts_with("pub struct myc::") && line.ends_with("Error")) .count(); - assert_eq!(public_error_count, 29); + assert_eq!(public_error_count, 30); assert!(PUBLIC_API.contains("pub struct myc::MycDoctorError")); assert!(!PUBLIC_API.contains("pub struct myc::MycRuntimeFoundation {")); assert!(!PUBLIC_API.contains("pub struct myc::MycStateHost {")); diff --git a/tests/services_hardening_config_contract.rs b/tests/services_hardening_config_contract.rs @@ -379,6 +379,10 @@ fn schema_identity_structure_and_machine_policy_are_exact() { #[test] fn lib_derived_limits_and_defaults_are_literal_frozen() { let schema = schema(); + assert_eq!( + schema["$defs"]["operations_limits"]["properties"]["header_bytes"]["minimum"], + 8_192 + ); let exact = [ ("/$defs/operations_limits/properties/header_count", 64, 32), ( diff --git a/tests/services_hardening_operations.rs b/tests/services_hardening_operations.rs @@ -0,0 +1,234 @@ +#![forbid(unsafe_code)] + +use std::error::Error; +use std::net::{Ipv4Addr, SocketAddrV4, TcpListener}; + +use myc::{ + InstanceId, MYC_LIVEZ_PATH, MYC_METRICS_PATH, MYC_OPERATIONS_CONTRACT_VERSION, MYC_READYZ_PATH, + MycConfigProfile, MycConnectionCountsV1, MycIdentityHealthV1, MycIntegrityStateV1, + MycOperationsCancellationToken, MycOperationsErrorKind, MycOperationsServer, MycOutboxStatusV1, + MycPersistenceHealthV1, MycPersistenceStatusV1, MycProviderStatusV1, MycRelayTransportStatusV1, + MycServicePhase, MycStatusBuildInfoV1, MycStatusBuildMode, MycStatusCommonV1, + MycStatusConfigurationIdentityV1, MycStatusConfigurationSource, MycStatusObservationV1, + MycStatusReasonCodes, MycTransportHealthV1, myc_status_cache, parse_myc_config_v1, +}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpStream; + +const CONTRACT: &str = include_str!("../contracts/services_hardening/tcp_operations.v1.json"); +const CONFIG: &str = include_str!("../contracts/services_hardening/config.v1.example.toml"); +const SERVICE_REVISION: &str = "0123456789abcdef0123456789abcdef01234567"; +const LIB_REVISION: &str = "89abcdef0123456789abcdef0123456789abcdef"; + +fn build_info() -> MycStatusBuildInfoV1 { + MycStatusBuildInfoV1::new( + MycStatusBuildMode::Release, + Some("0.1.0"), + Some(SERVICE_REVISION), + Some(LIB_REVISION), + Some("1.97.1"), + Some("x86_64-unknown-linux-gnu"), + Some("service-host"), + ) + .expect("build info") +} + +fn identity(configured: bool, available: bool) -> MycIdentityHealthV1 { + MycIdentityHealthV1::new(configured, available, MycStatusReasonCodes::empty()) + .expect("identity health") +} + +fn observation(phase: MycServicePhase, ready: bool) -> MycStatusObservationV1 { + let configuration = MycStatusConfigurationIdentityV1::new( + "a".repeat(64), + MycStatusConfigurationSource::ExplicitConfig, + ) + .expect("configuration"); + let persistence = MycPersistenceStatusV1::new( + MycPersistenceHealthV1::Ready, + 9, + 42, + MycIntegrityStateV1::Verified, + MycStatusReasonCodes::empty(), + ) + .expect("persistence"); + let provider = MycProviderStatusV1::new( + identity(true, true), + identity(true, true), + identity(false, false), + MycStatusReasonCodes::empty(), + ) + .expect("provider"); + let transport = MycRelayTransportStatusV1::new( + MycTransportHealthV1::Ready, + true, + 2, + MycStatusReasonCodes::empty(), + ) + .expect("transport"); + MycStatusObservationV1::new( + MycStatusCommonV1::new( + phase, + ready, + MycStatusReasonCodes::empty(), + 1, + build_info(), + configuration, + persistence, + ) + .expect("common status"), + provider, + transport, + MycConnectionCountsV1::default(), + MycOutboxStatusV1::new(0, 0, None), + ) +} + +fn available_port() -> u16 { + let listener = + TcpListener::bind(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0)).expect("ephemeral listener"); + listener.local_addr().expect("local address").port() +} + +fn enabled_config(port: u16) -> String { + CONFIG.replacen( + "[operations]\nenabled = false", + &format!( + "[operations]\nenabled = true\nlisten = \"127.0.0.1:{port}\"\nbind_policy = \"loopback_only\"\n\n[operations.limits]\nheader_count = 16\nheader_bytes = 8192\nresponse_body_utf8_bytes = 4096\nconcurrent_connections = 4\nrequest_deadline_ms = 500\nidle_timeout_ms = 500" + ), + 1, + ) +} + +async fn raw_request(address: std::net::SocketAddr, request: &[u8]) -> Vec<u8> { + let mut stream = TcpStream::connect(address).await.expect("connect"); + stream.write_all(request).await.expect("request write"); + let mut response = Vec::new(); + stream + .read_to_end(&mut response) + .await + .expect("response read"); + response +} + +fn response_text(response: &[u8]) -> &str { + std::str::from_utf8(response).expect("response UTF-8") +} + +#[test] +fn machine_contract_freezes_exact_routes_metrics_and_non_authority() { + let contract: serde_json::Value = serde_json::from_str(CONTRACT).expect("contract"); + assert_eq!(contract["schema"], "radroots.myc.tcp-operations.v1"); + assert_eq!( + contract["contract_version"], + MYC_OPERATIONS_CONTRACT_VERSION + ); + assert_eq!(contract["step"], 155); + assert_eq!( + contract["routes"] + .as_array() + .expect("routes") + .iter() + .map(|route| route["path"].as_str().expect("route path")) + .collect::<Vec<_>>(), + [MYC_LIVEZ_PATH, MYC_READYZ_PATH, MYC_METRICS_PATH] + ); + assert_eq!(contract["route_registration_extension"], false); + assert_eq!(contract["metrics"]["high_cardinality_labels"], false); + assert_eq!(contract["metrics"]["arbitrary_labels"], false); +} + +#[tokio::test] +async fn exact_tcp_routes_use_only_latest_cached_lifecycle_and_metrics() { + let config = parse_myc_config_v1( + enabled_config(available_port()).as_bytes(), + MycConfigProfile::Production, + ) + .expect("enabled config"); + let (mut publisher, reader) = myc_status_cache( + InstanceId::new("primary").expect("instance"), + observation(MycServicePhase::Ready, true), + ) + .expect("status cache"); + let detail_pointer = reader.snapshot().detailed_status_json().as_ptr(); + let bound = MycOperationsServer::new(&config, &reader) + .expect("operations server") + .bind() + .await + .expect("bind"); + let address = bound.local_address(); + let cancellation = MycOperationsCancellationToken::new(); + let task = tokio::spawn(bound.serve(cancellation.clone())); + + let live = raw_request(address, b"GET /livez HTTP/1.1\r\nhost: localhost\r\n\r\n").await; + let ready = raw_request(address, b"GET /readyz HTTP/1.1\r\nhost: localhost\r\n\r\n").await; + let metrics = raw_request(address, b"GET /metrics HTTP/1.1\r\nhost: localhost\r\n\r\n").await; + assert!(response_text(&live).starts_with("HTTP/1.1 200 OK\r\n")); + assert!(response_text(&live).ends_with("live\n")); + assert!(response_text(&ready).ends_with("ready\n")); + assert!(response_text(&metrics).contains("# TYPE radroots_myc_service_phase gauge\n")); + assert!(response_text(&metrics).contains("radroots_myc_service_phase{phase=\"ready\"} 1\n")); + assert!(response_text(&metrics).contains("radroots_myc_service_ready 1\n")); + + for request in [ + &b"GET /status HTTP/1.1\r\nhost: localhost\r\n\r\n"[..], + &b"GET /readyz?probe=1 HTTP/1.1\r\nhost: localhost\r\n\r\n"[..], + &b"POST /metrics HTTP/1.1\r\nhost: localhost\r\ncontent-length: 0\r\n\r\n"[..], + &b"GET /v1/status HTTP/1.1\r\nhost: localhost\r\n\r\n"[..], + ] { + let rejected = raw_request(address, request).await; + assert!(response_text(&rejected).starts_with("HTTP/1.1 404 Not Found\r\n")); + } + + publisher + .publish(observation(MycServicePhase::Unready, false)) + .expect("unready publication"); + let unready = raw_request(address, b"GET /readyz HTTP/1.1\r\nhost: localhost\r\n\r\n").await; + let metrics = raw_request(address, b"GET /metrics HTTP/1.1\r\nhost: localhost\r\n\r\n").await; + assert!(response_text(&unready).starts_with("HTTP/1.1 503 Service Unavailable\r\n")); + assert!(response_text(&unready).ends_with("unready\n")); + assert!(response_text(&metrics).contains("radroots_myc_service_phase{phase=\"unready\"} 1\n")); + assert!(response_text(&metrics).contains("radroots_myc_service_ready 0\n")); + assert_ne!( + reader.snapshot().detailed_status_json().as_ptr(), + detail_pointer + ); + + cancellation.cancel(); + assert_eq!(task.await.expect("serve task"), Ok(())); +} + +#[tokio::test] +async fn disabled_invalid_and_bind_failures_are_typed_source_free_and_redacted() { + let disabled = parse_myc_config_v1(CONFIG.as_bytes(), MycConfigProfile::Production) + .expect("disabled config"); + let (_, reader) = myc_status_cache( + InstanceId::new("primary").expect("instance"), + observation(MycServicePhase::Ready, true), + ) + .expect("status cache"); + let disabled_error = + MycOperationsServer::new(&disabled, &reader).expect_err("disabled operations"); + assert_eq!(disabled_error.kind(), MycOperationsErrorKind::Disabled); + assert_eq!(disabled_error.code(), "operations_disabled"); + assert!(Error::source(&disabled_error).is_none()); + + let below_floor = + enabled_config(available_port()).replace("header_bytes = 8192", "header_bytes = 8191"); + assert!(parse_myc_config_v1(below_floor.as_bytes(), MycConfigProfile::Production).is_err()); + + let occupied = + TcpListener::bind(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0)).expect("occupied listener"); + let port = occupied.local_addr().expect("occupied address").port(); + let config = parse_myc_config_v1( + enabled_config(port).as_bytes(), + MycConfigProfile::Production, + ) + .expect("enabled config"); + let server = MycOperationsServer::new(&config, &reader).expect("server"); + assert!(!format!("{server:?}").contains(&port.to_string())); + let bind_error = server.bind().await.expect_err("occupied bind"); + assert_eq!(bind_error.kind(), MycOperationsErrorKind::Bind); + assert!(Error::source(&bind_error).is_none()); + assert!(!format!("{bind_error:?} {bind_error}").contains(&port.to_string())); +}