Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38ee6d8e41 | ||
|
|
612f2aed66 | ||
|
|
027917c8cf |
+7
-3
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "meta-feeder-sdk"
|
name = "meta-feeder-sdk"
|
||||||
version = "1.6.0"
|
version = "1.8.1"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.85"
|
rust-version = "1.85"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@@ -37,11 +37,15 @@ default = ["feeder"]
|
|||||||
# FeederPlugin trait, hashing, the redb cache, the axum harness. On by default
|
# FeederPlugin trait, hashing, the redb cache, the axum harness. On by default
|
||||||
# so existing consumers build exactly what they always did.
|
# so existing consumers build exactly what they always did.
|
||||||
feeder = [
|
feeder = [
|
||||||
"beacon",
|
"beacon", "filename",
|
||||||
"dep:axum", "dep:hyper", "dep:reqwest", "dep:base64", "dep:futures",
|
"dep:axum", "dep:hyper", "dep:reqwest", "dep:base64", "dep:futures",
|
||||||
"dep:async-trait", "dep:anyhow", "dep:thiserror", "dep:sha2", "dep:bytes",
|
"dep:async-trait", "dep:anyhow", "dep:thiserror", "dep:sha2", "dep:bytes",
|
||||||
"dep:regex", "dep:redb",
|
"dep:redb",
|
||||||
]
|
]
|
||||||
|
# Release-name parsing (`src/filename_meta.rs`: clean_title, season/episode,
|
||||||
|
# quality, codec). Needs only `regex`, so a client (meta-watch) can derive a
|
||||||
|
# display name from a raw release string without the feeder machinery.
|
||||||
|
filename = ["dep:regex"]
|
||||||
# Beacon v2 local discovery (`src/beacon/`). Needs only tokio/serde/tracing/libc,
|
# Beacon v2 local discovery (`src/beacon/`). Needs only tokio/serde/tracing/libc,
|
||||||
# so a meta-* service takes the SDK with `default-features = false,
|
# so a meta-* service takes the SDK with `default-features = false,
|
||||||
# features = ["beacon"]` and pulls in none of the feeder machinery.
|
# features = ["beacon"]` and pulls in none of the feeder machinery.
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
A **service is a `Vec`** — grouping plugins into one binary is a deployment
|
A **service is a `Vec`** — grouping plugins into one binary is a deployment
|
||||||
choice, not an architectural one.
|
choice, not an architectural one.
|
||||||
|
|
||||||
### Discovery (1.6.0)
|
### Discovery (1.6.0, scan 1.7.0)
|
||||||
|
|
||||||
`serve_feeders` and `serve_transport` advertise the process over **beacon v2**
|
`serve_feeders` and `serve_transport` advertise the process over **beacon v2**
|
||||||
(UDP multicast `239.255.99.1:9099`, spec: meta-root
|
(UDP multicast `239.255.99.1:9099`, spec: meta-root
|
||||||
@@ -52,11 +52,19 @@ transport as `metamesh.transport/<id>@<contract>` — with `endpoints.http`,
|
|||||||
| `BEACON_BINDS` | unset | the one consumer instance allowed to list this plugin |
|
| `BEACON_BINDS` | unset | the one consumer instance allowed to list this plugin |
|
||||||
| `BEACON_GROUP` / `BEACON_PORT` / `BEACON_INTERVAL_MS` | `239.255.99.1` / `9099` / `10000` | |
|
| `BEACON_GROUP` / `BEACON_PORT` / `BEACON_INTERVAL_MS` | `239.255.99.1` / `9099` / `10000` | |
|
||||||
|
|
||||||
|
`beacon::scan(node, pattern, configured, wait)` (1.7.0) is what a consumer's
|
||||||
|
**Scan** button runs: probe everyone, wait, then classify every resource
|
||||||
|
matching `pattern` as `configured` / `addable` / `bound-elsewhere` against the
|
||||||
|
consumer's `(name, url)` list — the `ScanReport` JSON the shared
|
||||||
|
`<meta-beacon-scan>` element renders. Transports advertise as
|
||||||
|
`meta-transport-<id>` (`SERVICE_NAME` overrides), feeders as
|
||||||
|
`meta-feeder-<first upstream>`.
|
||||||
|
|
||||||
The `beacon` module is also usable on its own — the meta-* services depend on
|
The `beacon` module is also usable on its own — the meta-* services depend on
|
||||||
the SDK for nothing else:
|
the SDK for nothing else:
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
meta-feeder-sdk = { git = "https://github.com/worph/meta-feeder-sdk", tag = "v1.6.0", default-features = false, features = ["beacon"] }
|
meta-feeder-sdk = { git = "https://github.com/worph/meta-feeder-sdk", tag = "v1.7.0", default-features = false, features = ["beacon"] }
|
||||||
```
|
```
|
||||||
|
|
||||||
Features: `feeder` (default — the whole feeder harness; implies `beacon`),
|
Features: `feeder` (default — the whole feeder harness; implies `beacon`),
|
||||||
|
|||||||
@@ -15,8 +15,10 @@
|
|||||||
|
|
||||||
mod node;
|
mod node;
|
||||||
mod proto;
|
mod proto;
|
||||||
|
mod scan;
|
||||||
|
|
||||||
pub use node::{enabled_from_env, hostname, BeaconNode, Config, SeenNode, SeenResource};
|
pub use node::{enabled_from_env, hostname, BeaconNode, Config, SeenNode, SeenResource};
|
||||||
|
pub use scan::{classify, scan, suggested_name, Candidate, CandidateState, ScanReport, ScanSummary};
|
||||||
pub use proto::{
|
pub use proto::{
|
||||||
cap_matches, rev_of, Message, NodeInfo, Resource, DEFAULT_GROUP, DEFAULT_INTERVAL, DEFAULT_PORT,
|
cap_matches, rev_of, Message, NodeInfo, Resource, DEFAULT_GROUP, DEFAULT_INTERVAL, DEFAULT_PORT,
|
||||||
LIVENESS_FACTOR, MAX_DATAGRAM, PROTO, VERSION,
|
LIVENESS_FACTOR, MAX_DATAGRAM, PROTO, VERSION,
|
||||||
@@ -38,6 +40,10 @@ pub mod caps {
|
|||||||
pub const FEEDER_PREFIX: &str = "metamesh.feeder/";
|
pub const FEEDER_PREFIX: &str = "metamesh.feeder/";
|
||||||
/// Every feeder upstream.
|
/// Every feeder upstream.
|
||||||
pub const ANY_FEEDER: &str = "metamesh.feeder/*";
|
pub const ANY_FEEDER: &str = "metamesh.feeder/*";
|
||||||
|
/// Prefix of `metamesh.enrich/<plugin_id>` (meta-sort enrichment plugins).
|
||||||
|
pub const ENRICH_PREFIX: &str = "metamesh.enrich/";
|
||||||
|
/// Every meta-sort enrichment plugin.
|
||||||
|
pub const ANY_ENRICH: &str = "metamesh.enrich/*";
|
||||||
|
|
||||||
pub fn service(name: &str) -> String {
|
pub fn service(name: &str) -> String {
|
||||||
format!("{SERVICE_PREFIX}{name}")
|
format!("{SERVICE_PREFIX}{name}")
|
||||||
@@ -50,6 +56,10 @@ pub mod caps {
|
|||||||
pub fn feeder(upstream_id: &str) -> String {
|
pub fn feeder(upstream_id: &str) -> String {
|
||||||
format!("{FEEDER_PREFIX}{upstream_id}")
|
format!("{FEEDER_PREFIX}{upstream_id}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn enrich(plugin_id: &str) -> String {
|
||||||
|
format!("{ENRICH_PREFIX}{plugin_id}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The resource every meta-* service advertises: id `service`, cap
|
/// The resource every meta-* service advertises: id `service`, cap
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
//! On-demand discovery scan — what a consumer's "Scan" button runs.
|
||||||
|
//!
|
||||||
|
//! Probe everyone, wait a moment for the replies, then classify every resource
|
||||||
|
//! matching the consumer's capability pattern against what the consumer has
|
||||||
|
//! already configured. The report is the same JSON in every consumer
|
||||||
|
//! (meta-share, meta-gateway, meta-sort), rendered by the shared
|
||||||
|
//! `<meta-beacon-scan>` element.
|
||||||
|
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
use super::node::{BeaconNode, SeenResource};
|
||||||
|
|
||||||
|
/// How a discovered resource relates to this consumer.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||||
|
#[serde(rename_all = "kebab-case")]
|
||||||
|
pub enum CandidateState {
|
||||||
|
/// Its URL is already in the consumer's list.
|
||||||
|
Configured,
|
||||||
|
/// Can be added.
|
||||||
|
Addable,
|
||||||
|
/// `binds` names another consumer; shown but not addable.
|
||||||
|
BoundElsewhere,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct Candidate {
|
||||||
|
pub instance: String,
|
||||||
|
pub name: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub version: Option<String>,
|
||||||
|
pub resource_id: String,
|
||||||
|
pub caps: Vec<String>,
|
||||||
|
/// `endpoints.http` — what the consumer would register.
|
||||||
|
pub url: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub binds: Option<String>,
|
||||||
|
pub state: CandidateState,
|
||||||
|
/// The list entry that already holds this URL (state `configured`).
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub configured_as: Option<String>,
|
||||||
|
/// A list-safe name to register it under.
|
||||||
|
pub suggested_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ScanSummary {
|
||||||
|
/// Every node heard on the segment, whatever it advertises.
|
||||||
|
pub nodes: usize,
|
||||||
|
/// Resources matching the consumer's pattern.
|
||||||
|
pub capable: usize,
|
||||||
|
pub configured: usize,
|
||||||
|
pub bound_elsewhere: usize,
|
||||||
|
pub addable: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ScanReport {
|
||||||
|
pub cap: String,
|
||||||
|
/// This consumer's instance (what `binds` is compared against).
|
||||||
|
#[serde(rename = "self")]
|
||||||
|
pub self_instance: String,
|
||||||
|
pub duration_ms: u64,
|
||||||
|
pub summary: ScanSummary,
|
||||||
|
pub candidates: Vec<Candidate>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn norm(u: &str) -> String {
|
||||||
|
u.trim().trim_end_matches('/').to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lower-case, `[a-z0-9._-]` only — accepted by every consumer's name rules.
|
||||||
|
pub fn suggested_name(instance: &str) -> String {
|
||||||
|
let s: String = instance
|
||||||
|
.trim()
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
.chars()
|
||||||
|
.map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' { c } else { '-' })
|
||||||
|
.collect();
|
||||||
|
if s.is_empty() { "plugin".into() } else { s }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure classification, for tests and for callers that already hold a view.
|
||||||
|
/// `configured` is `(name, url)` of every entry the consumer has.
|
||||||
|
pub fn classify(
|
||||||
|
pattern: &str,
|
||||||
|
self_instance: &str,
|
||||||
|
nodes: usize,
|
||||||
|
resources: Vec<SeenResource>,
|
||||||
|
configured: &[(String, String)],
|
||||||
|
) -> ScanReport {
|
||||||
|
let mut summary = ScanSummary { nodes, ..Default::default() };
|
||||||
|
let mut candidates = Vec::new();
|
||||||
|
for r in resources {
|
||||||
|
// A resource without an http endpoint cannot be registered anywhere.
|
||||||
|
let Some(url) = r.resource.endpoint("http") else { continue };
|
||||||
|
let url = norm(&url);
|
||||||
|
summary.capable += 1;
|
||||||
|
let configured_as = configured.iter().find(|(_, u)| norm(u) == url).map(|(n, _)| n.clone());
|
||||||
|
let state = if configured_as.is_some() {
|
||||||
|
summary.configured += 1;
|
||||||
|
CandidateState::Configured
|
||||||
|
} else if !r.resource.usable_by(self_instance) {
|
||||||
|
summary.bound_elsewhere += 1;
|
||||||
|
CandidateState::BoundElsewhere
|
||||||
|
} else {
|
||||||
|
summary.addable += 1;
|
||||||
|
CandidateState::Addable
|
||||||
|
};
|
||||||
|
candidates.push(Candidate {
|
||||||
|
suggested_name: suggested_name(&r.node.instance),
|
||||||
|
instance: r.node.instance,
|
||||||
|
name: r.node.name,
|
||||||
|
version: r.node.version,
|
||||||
|
resource_id: r.resource.id,
|
||||||
|
caps: r.resource.caps,
|
||||||
|
url,
|
||||||
|
binds: r.resource.binds,
|
||||||
|
state,
|
||||||
|
configured_as,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Addable first (what the operator came for), then configured, then the rest.
|
||||||
|
let rank = |s: CandidateState| match s {
|
||||||
|
CandidateState::Addable => 0,
|
||||||
|
CandidateState::Configured => 1,
|
||||||
|
CandidateState::BoundElsewhere => 2,
|
||||||
|
};
|
||||||
|
candidates.sort_by(|a, b| rank(a.state).cmp(&rank(b.state)).then(a.instance.cmp(&b.instance)));
|
||||||
|
ScanReport {
|
||||||
|
cap: pattern.to_string(),
|
||||||
|
self_instance: self_instance.to_string(),
|
||||||
|
duration_ms: 0,
|
||||||
|
summary,
|
||||||
|
candidates,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Probe every node, wait `wait` for the replies, then classify what matches
|
||||||
|
/// `pattern`. A node that stays silent through the wait but is still inside
|
||||||
|
/// its liveness window is counted too — the view is "who is here", not "who
|
||||||
|
/// answered this probe".
|
||||||
|
pub async fn scan(
|
||||||
|
node: &BeaconNode,
|
||||||
|
pattern: &str,
|
||||||
|
configured: &[(String, String)],
|
||||||
|
wait: Duration,
|
||||||
|
) -> ScanReport {
|
||||||
|
let started = Instant::now();
|
||||||
|
node.probe(&[]).await;
|
||||||
|
tokio::time::sleep(wait).await;
|
||||||
|
let mut report = classify(
|
||||||
|
pattern,
|
||||||
|
&node.instance(),
|
||||||
|
node.nodes().len(),
|
||||||
|
node.resources(pattern),
|
||||||
|
configured,
|
||||||
|
);
|
||||||
|
report.duration_ms = started.elapsed().as_millis() as u64;
|
||||||
|
report
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::beacon::{NodeInfo, Resource};
|
||||||
|
|
||||||
|
fn seen(instance: &str, cap: &str, url: Option<&str>, binds: Option<&str>) -> SeenResource {
|
||||||
|
let mut r = Resource::new("x", [cap]);
|
||||||
|
if let Some(u) = url {
|
||||||
|
r = r.with_endpoint("http", u);
|
||||||
|
}
|
||||||
|
r.binds = binds.map(Into::into);
|
||||||
|
SeenResource {
|
||||||
|
node: NodeInfo { name: "plugin".into(), instance: instance.into(), ..Default::default() },
|
||||||
|
resource: r,
|
||||||
|
addr: "10.0.0.1".into(),
|
||||||
|
last_seen: 0.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn classifies_configured_addable_and_bound_elsewhere() {
|
||||||
|
let resources = vec![
|
||||||
|
seen("metashare-nzb", "metamesh.transport/nzb@1", Some("http://metashare-nzb:3000/"), Some("metashare-app")),
|
||||||
|
seen("qbit", "metamesh.transport/torrent@1", Some("http://qbit:3000"), None),
|
||||||
|
seen("other-ipfs", "metamesh.transport/ipfs@1", Some("http://other-ipfs:3000"), Some("metawatch-share")),
|
||||||
|
seen("no-endpoint", "metamesh.transport/ipfs@1", None, None),
|
||||||
|
];
|
||||||
|
let configured = vec![("nzb".to_string(), "http://metashare-nzb:3000".to_string())];
|
||||||
|
let r = classify("metamesh.transport/*", "metashare-app", 10, resources, &configured);
|
||||||
|
assert_eq!(r.summary.nodes, 10);
|
||||||
|
assert_eq!(r.summary.capable, 3, "a resource without an http endpoint is not a candidate");
|
||||||
|
assert_eq!((r.summary.configured, r.summary.addable, r.summary.bound_elsewhere), (1, 1, 1));
|
||||||
|
assert_eq!(r.candidates[0].instance, "qbit", "addable rows sort first");
|
||||||
|
assert_eq!(r.candidates[1].configured_as.as_deref(), Some("nzb"));
|
||||||
|
assert_eq!(r.candidates[2].state, CandidateState::BoundElsewhere);
|
||||||
|
let json = serde_json::to_value(&r).unwrap();
|
||||||
|
assert_eq!(json["self"], "metashare-app");
|
||||||
|
assert_eq!(json["summary"]["boundElsewhere"], 1);
|
||||||
|
assert_eq!(json["candidates"][2]["state"], "bound-elsewhere");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn suggested_names_are_list_safe() {
|
||||||
|
assert_eq!(suggested_name("MetaShare NZB"), "metashare-nzb");
|
||||||
|
assert_eq!(suggested_name(""), "plugin");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn scan_reports_what_the_node_heard() {
|
||||||
|
use crate::beacon::{BeaconNode, Config, Message};
|
||||||
|
let port = std::net::UdpSocket::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
|
||||||
|
let mut cfg = Config::new("consumer").instance("me");
|
||||||
|
cfg.port = port;
|
||||||
|
let node = BeaconNode::spawn(cfg).unwrap();
|
||||||
|
let ad = Message::advertise(
|
||||||
|
NodeInfo { name: "meta-feeder-tmdb".into(), instance: "tmdb-feeder".into(), ..Default::default() },
|
||||||
|
vec![Resource::new("feeder", ["metamesh.feeder/tmdb"]).with_endpoint("http", "http://tmdb-feeder:8080")],
|
||||||
|
);
|
||||||
|
let s = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
s.send_to(&serde_json::to_vec(&ad).unwrap(), ("127.0.0.1", port)).await.unwrap();
|
||||||
|
let r = scan(&node, "metamesh.feeder/*", &[], Duration::from_millis(200)).await;
|
||||||
|
assert_eq!(r.summary.nodes, 1);
|
||||||
|
assert_eq!(r.summary.addable, 1);
|
||||||
|
assert_eq!(r.candidates[0].url, "http://tmdb-feeder:8080");
|
||||||
|
assert_eq!(r.candidates[0].suggested_name, "tmdb-feeder");
|
||||||
|
assert!(r.duration_ms >= 200);
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -44,7 +44,7 @@ pub mod config;
|
|||||||
pub mod domain;
|
pub mod domain;
|
||||||
#[cfg(feature = "feeder")]
|
#[cfg(feature = "feeder")]
|
||||||
pub mod enrich;
|
pub mod enrich;
|
||||||
#[cfg(feature = "feeder")]
|
#[cfg(feature = "filename")]
|
||||||
pub mod filename_meta;
|
pub mod filename_meta;
|
||||||
#[cfg(feature = "feeder")]
|
#[cfg(feature = "feeder")]
|
||||||
pub mod hash;
|
pub mod hash;
|
||||||
|
|||||||
@@ -156,6 +156,25 @@ impl RemoteTransport {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `PUT /manifests/:cid`: hand a manifest-consuming plugin (contract 2) the
|
||||||
|
/// manifest a pointer resolved to, after it answered `428` +
|
||||||
|
/// [`super::dto::HDR_NEEDS_MANIFEST`].
|
||||||
|
pub async fn push_manifest(&self, cid: &str, manifest: bytes::Bytes) -> Result<(), ApiError> {
|
||||||
|
let r = self
|
||||||
|
.http
|
||||||
|
.put(self.url(&format!("/manifests/{cid}")))
|
||||||
|
.body(manifest)
|
||||||
|
.timeout(self.control_timeout)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| self.unreachable(e))?;
|
||||||
|
if r.status().is_success() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(error_from(r).await)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- typed helpers for extra routes ------------------------------------
|
// ---- typed helpers for extra routes ------------------------------------
|
||||||
|
|
||||||
pub async fn get_json<T: DeserializeOwned>(&self, path: &str) -> Result<T, ApiError> {
|
pub async fn get_json<T: DeserializeOwned>(&self, path: &str) -> Result<T, ApiError> {
|
||||||
|
|||||||
+31
-2
@@ -9,8 +9,37 @@ use serde::{Deserialize, Serialize};
|
|||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
/// Bumped on any breaking change to the common routes below. The hull refuses
|
/// Bumped on any breaking change to the common routes below. The hull refuses
|
||||||
/// a plugin whose `/manifest` reports a different major contract.
|
/// a plugin whose `/manifest` reports a contract outside
|
||||||
pub const CONTRACT_VERSION: u32 = 1;
|
/// [`MIN_CONTRACT`]`..=`[`CONTRACT_VERSION`].
|
||||||
|
///
|
||||||
|
/// - **2**: a plugin that consumes a manifest (nzb) no longer pulls it from the
|
||||||
|
/// hull. It answers `/raw` (and its verify route) with `428` +
|
||||||
|
/// [`HDR_NEEDS_MANIFEST`] until the hull has pushed one with
|
||||||
|
/// `PUT /manifests/:cid`.
|
||||||
|
pub const CONTRACT_VERSION: u32 = 2;
|
||||||
|
/// The oldest contract the hull still drives (contract-1 nzb plugins pull their
|
||||||
|
/// manifest from `/internal/nzb/manifest/:cid`, kept for one release).
|
||||||
|
pub const MIN_CONTRACT: u32 = 1;
|
||||||
|
|
||||||
|
/// `428` marker: this plugin needs the cid's manifest pushed before it can
|
||||||
|
/// answer. The hull resolves the pointer, `PUT`s `/manifests/:cid`, and retries.
|
||||||
|
pub const HDR_NEEDS_MANIFEST: &str = "x-metamesh-needs-manifest";
|
||||||
|
|
||||||
|
/// Why a pointer could not be resolved, on a non-2xx `/ipfs-tier/resolve`
|
||||||
|
/// answer: [`RESOLVE_NO_GATEWAY`], [`RESOLVE_UNCLAIMED`], [`RESOLVE_QUOTA`],
|
||||||
|
/// [`RESOLVE_NOT_FOUND`] or [`RESOLVE_UPSTREAM`].
|
||||||
|
pub const HDR_RESOLVE_ERROR: &str = "x-metamesh-resolve-error";
|
||||||
|
/// No gateway is known at all yet (discovery is eventual): retry.
|
||||||
|
pub const RESOLVE_NO_GATEWAY: &str = "no-gateway";
|
||||||
|
/// Gateways are known, but none claims this pointer's key.
|
||||||
|
pub const RESOLVE_UNCLAIMED: &str = "unclaimed";
|
||||||
|
/// The provider's quota is spent: retry after `Retry-After`.
|
||||||
|
pub const RESOLVE_QUOTA: &str = "quota";
|
||||||
|
/// Every claiming gateway answered not found (or nothing is stored and the
|
||||||
|
/// pointer cannot be redeemed).
|
||||||
|
pub const RESOLVE_NOT_FOUND: &str = "not-found";
|
||||||
|
/// A gateway failed, or answered bytes that did not verify.
|
||||||
|
pub const RESOLVE_UPSTREAM: &str = "upstream";
|
||||||
|
|
||||||
/// `X-MetaMesh-Lane`: the hull's lane decision for a `/raw` request.
|
/// `X-MetaMesh-Lane`: the hull's lane decision for a `/raw` request.
|
||||||
pub const HDR_LANE: &str = "x-metamesh-lane";
|
pub const HDR_LANE: &str = "x-metamesh-lane";
|
||||||
|
|||||||
@@ -39,10 +39,11 @@ use super::dto::Event;
|
|||||||
/// Default hull callback base, matching the dev and store compose service name.
|
/// Default hull callback base, matching the dev and store compose service name.
|
||||||
pub const DEFAULT_HULL_URL: &str = "http://metashare-app:3001";
|
pub const DEFAULT_HULL_URL: &str = "http://metashare-app:3001";
|
||||||
|
|
||||||
/// Where the hull got a manifest: the record's `manifest` pointer, or a
|
/// Where a pointer's bytes came from: `store` (already held here, named by the
|
||||||
/// gateway redeem.
|
/// record's pointer field), `redeem` (a gateway), or `record` (contract-1
|
||||||
|
/// callback naming for `store`).
|
||||||
pub const HDR_MANIFEST_SOURCE: &str = "x-metamesh-manifest-source";
|
pub const HDR_MANIFEST_SOURCE: &str = "x-metamesh-manifest-source";
|
||||||
/// The content cid of a `record`-sourced manifest.
|
/// The content cid the pointer resolved to.
|
||||||
pub const HDR_MANIFEST_CID: &str = "x-metamesh-manifest-cid";
|
pub const HDR_MANIFEST_CID: &str = "x-metamesh-manifest-cid";
|
||||||
|
|
||||||
/// `.nzb` bytes as the hull resolved them.
|
/// `.nzb` bytes as the hull resolved them.
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
//! |---|---|
|
//! |---|---|
|
||||||
//! | `GET peers` → [`PeersInfo`] | `swarm::Command::Peers` |
|
//! | `GET peers` → [`PeersInfo`] | `swarm::Command::Peers` |
|
||||||
//! | `GET directory` → [`Directory`] | `swarm::PeerDirectory` (gateway discovery) |
|
//! | `GET directory` → [`Directory`] | `swarm::PeerDirectory` (gateway discovery) |
|
||||||
|
//! | `GET resolve/:cid?hint=` → bytes | a [pointer](super::pointer)'s bytes: the stored copy `hint` names, else a gateway redeem (see below) |
|
||||||
//! | `GET local/:cid` (Range) → bytes / `404` | `files::bitswap::try_local_block` |
|
//! | `GET local/:cid` (Range) → bytes / `404` | `files::bitswap::try_local_block` |
|
||||||
//! | `GET has/:cid` → [`Present`] | `block_store.get(cid).is_some()` |
|
//! | `GET has/:cid` → [`Present`] | `block_store.get(cid).is_some()` |
|
||||||
//! | `GET complete/:cid` → [`Complete`] | `local_dag_complete` |
|
//! | `GET complete/:cid` → [`Complete`] | `local_dag_complete` |
|
||||||
@@ -40,6 +41,15 @@
|
|||||||
//! ingress job was renamed into `cache/`), and `GET /internal/records/:cid` for
|
//! ingress job was renamed into `cache/`), and `GET /internal/records/:cid` for
|
||||||
//! a display name.
|
//! a display name.
|
||||||
|
|
||||||
|
//! **Resolving a pointer** (`GET resolve/:cid`) is the one route that reaches
|
||||||
|
//! outside the swarm: a pointer is answered only by a gateway holding the key,
|
||||||
|
//! over plain HTTP at the gateway's base URL. Gateways come from config (the
|
||||||
|
//! local one, pinned) and from the swarm directory; the fastest one whose redeem
|
||||||
|
//! claim covers the pointer is asked first. `2xx` carries the bytes plus
|
||||||
|
//! [`HDR_MANIFEST_SOURCE`](super::hull::HDR_MANIFEST_SOURCE) (`store` | `redeem`)
|
||||||
|
//! and [`HDR_MANIFEST_CID`](super::hull::HDR_MANIFEST_CID); a failure carries
|
||||||
|
//! [`HDR_RESOLVE_ERROR`](super::dto::HDR_RESOLVE_ERROR).
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
|
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
//! - [`torrent`]: the torrent plugin's extra routes + magnet helpers.
|
//! - [`torrent`]: the torrent plugin's extra routes + magnet helpers.
|
||||||
//! - [`ipfs`]: the ipfs plugin's extra routes (the hull's blockstore/swarm facade).
|
//! - [`ipfs`]: the ipfs plugin's extra routes (the hull's blockstore/swarm facade).
|
||||||
//! - [`nzb`]: Usenet cid shapes, the usenet plugin's extra routes + settings.
|
//! - [`nzb`]: Usenet cid shapes, the usenet plugin's extra routes + settings.
|
||||||
|
//! - [`pointer`]: which cids are pointers (resolved before their bytes exist).
|
||||||
//! - [`magic`]: content sniffing; [`file`]: range-serving a file off `/data`.
|
//! - [`magic`]: content sniffing; [`file`]: range-serving a file off `/data`.
|
||||||
//! - `testkit` (feature `testkit`): conformance checks for any implementation.
|
//! - `testkit` (feature `testkit`): conformance checks for any implementation.
|
||||||
//!
|
//!
|
||||||
@@ -42,6 +43,7 @@ pub mod ipfs;
|
|||||||
pub mod magic;
|
pub mod magic;
|
||||||
pub mod nzb;
|
pub mod nzb;
|
||||||
pub mod plugin;
|
pub mod plugin;
|
||||||
|
pub mod pointer;
|
||||||
pub mod range;
|
pub mod range;
|
||||||
pub mod serve;
|
pub mod serve;
|
||||||
pub mod torrent;
|
pub mod torrent;
|
||||||
@@ -52,7 +54,7 @@ pub use client::RemoteTransport;
|
|||||||
pub use config::ConfigPlane;
|
pub use config::ConfigPlane;
|
||||||
pub use dto::{
|
pub use dto::{
|
||||||
Capabilities, Deleted, Event, Health, Job, Jobs, Manifest, PromotedFile, ReconcileReport,
|
Capabilities, Deleted, Event, Health, Job, Jobs, Manifest, PromotedFile, ReconcileReport,
|
||||||
ReconcileRequest, CONTRACT_VERSION,
|
ReconcileRequest, CONTRACT_VERSION, MIN_CONTRACT,
|
||||||
};
|
};
|
||||||
pub use error::ApiError;
|
pub use error::ApiError;
|
||||||
pub use focus::{FocusSnapshot, FocusTitle, FocusView, Lane};
|
pub use focus::{FocusSnapshot, FocusTitle, FocusView, Lane};
|
||||||
|
|||||||
@@ -0,0 +1,248 @@
|
|||||||
|
//! **Pointer cids** — cids that name something to fetch rather than hash it.
|
||||||
|
//!
|
||||||
|
//! A pointer's multihash is `identity`: the cid *contains* the address (an
|
||||||
|
//! indexer host + release id, a provider source + file id), not a digest of the
|
||||||
|
//! bytes. Bitswap cannot answer one, because there is no hash to verify against.
|
||||||
|
//! It is resolved instead: by a manifest this peer already stored (the record's
|
||||||
|
//! pointer field names its content cid), or by a **redeem** at a gateway whose
|
||||||
|
//! feeder holds the credential for `key`.
|
||||||
|
//!
|
||||||
|
//! This module is the single place that knows which codecs are pointers, so the
|
||||||
|
//! hull ("resolve it first") and the ipfs plugin ("here is how") agree.
|
||||||
|
//!
|
||||||
|
//! | codec | what it points at | redeem key | record field |
|
||||||
|
//! |---|---|---|---|
|
||||||
|
//! | `0x1005` nzb-release | a `.nzb` at an indexer | indexer host | `manifest` |
|
||||||
|
//! | `0x1003` nzb-posting | a self-scanned `.nzb` | — (record only) | `manifest` |
|
||||||
|
//! | `0x100A` provider-file | a file a provider holds | provider source | `file` |
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
|
||||||
|
use super::cid::{parse_record_cid, read_uvarint};
|
||||||
|
use super::nzb::{
|
||||||
|
decode_nzb_release_cid, normalize_indexer_host, NZB_POSTING_CODEC, NZB_RELEASE_CODEC,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// MetaMesh-private multicodec for a provider-held file (`docs/cid-formats.md` §8).
|
||||||
|
pub const PROVIDER_FILE_CODEC: u64 = 0x100A;
|
||||||
|
|
||||||
|
/// Redeem-claim codec names, as gateways advertise them in
|
||||||
|
/// `GET /api/gateway/plugins` (`redeems[].codec`).
|
||||||
|
pub const REDEEM_NZB_RELEASE: &str = "nzb-release";
|
||||||
|
pub const REDEEM_PROVIDER_FILE: &str = "provider-file";
|
||||||
|
|
||||||
|
/// Record fields a redeem merges onto the pointer's record: the content cid of
|
||||||
|
/// what it resolved to.
|
||||||
|
pub const FIELD_MANIFEST: &str = "manifest";
|
||||||
|
pub const FIELD_FILE: &str = "file";
|
||||||
|
|
||||||
|
/// Identity multihash code: the digest bytes are the payload verbatim.
|
||||||
|
const IDENTITY_MULTIHASH: u64 = 0x00;
|
||||||
|
|
||||||
|
/// A decoded pointer.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct Pointer {
|
||||||
|
/// The redeem-claim codec, or `None` when no gateway can redeem it (a
|
||||||
|
/// self-scanned posting resolves from the record alone).
|
||||||
|
pub redeem: Option<RedeemKey>,
|
||||||
|
/// The record field naming the resolved content cid.
|
||||||
|
pub field: &'static str,
|
||||||
|
/// What the resolved bytes are: a manifest a byte plugin consumes, or the
|
||||||
|
/// file itself.
|
||||||
|
pub kind: PointerKind,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct RedeemKey {
|
||||||
|
/// [`REDEEM_NZB_RELEASE`] or [`REDEEM_PROVIDER_FILE`].
|
||||||
|
pub codec: &'static str,
|
||||||
|
/// The indexer host / provider source a gateway's claim must cover.
|
||||||
|
pub key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum PointerKind {
|
||||||
|
/// A `.nzb`: handed to the nzb plugin, which fetches the bytes.
|
||||||
|
NzbManifest,
|
||||||
|
/// The file itself (a subtitle): served as is.
|
||||||
|
File,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode `cid` as a pointer.
|
||||||
|
///
|
||||||
|
/// - `None` — not a pointer: an ordinary content cid, or unparseable.
|
||||||
|
/// - `Some(Err(_))` — the codec says pointer but the payload is malformed; the
|
||||||
|
/// caller answers `400`.
|
||||||
|
pub fn decode(cid: &str) -> Option<Result<Pointer>> {
|
||||||
|
match codec_of(cid)? {
|
||||||
|
NZB_RELEASE_CODEC => Some(decode_nzb_release(cid)),
|
||||||
|
NZB_POSTING_CODEC => Some(Ok(Pointer {
|
||||||
|
redeem: None,
|
||||||
|
field: FIELD_MANIFEST,
|
||||||
|
kind: PointerKind::NzbManifest,
|
||||||
|
})),
|
||||||
|
PROVIDER_FILE_CODEC => Some(decode_provider_file(cid).map(|(source, _id)| Pointer {
|
||||||
|
redeem: Some(RedeemKey { codec: REDEEM_PROVIDER_FILE, key: source }),
|
||||||
|
field: FIELD_FILE,
|
||||||
|
kind: PointerKind::File,
|
||||||
|
})),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Is `cid` a pointer (well-formed or not)?
|
||||||
|
pub fn is_pointer(cid: &str) -> bool {
|
||||||
|
decode(cid).is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_nzb_release(cid: &str) -> Result<Pointer> {
|
||||||
|
let mscid = parse_record_cid(cid)?;
|
||||||
|
let locator = decode_nzb_release_cid(&mscid)?;
|
||||||
|
Ok(Pointer {
|
||||||
|
redeem: Some(RedeemKey {
|
||||||
|
codec: REDEEM_NZB_RELEASE,
|
||||||
|
key: normalize_indexer_host(locator.authority()),
|
||||||
|
}),
|
||||||
|
field: FIELD_MANIFEST,
|
||||||
|
kind: PointerKind::NzbManifest,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode a `provider-file` cid into `(source, id)`. Errors on a different
|
||||||
|
/// codec or a malformed payload. Works on the raw multibase string because a
|
||||||
|
/// long provider id can exceed the 64-byte multihash a typed cid parse allows.
|
||||||
|
pub fn decode_provider_file(cid: &str) -> Result<(String, String)> {
|
||||||
|
let bytes = raw_cid_bytes(cid).context("provider-file: not a base32 CIDv1")?;
|
||||||
|
let mut at = 0usize;
|
||||||
|
let next = |at: &mut usize, what: &str| -> Result<u64> {
|
||||||
|
let (v, n) = read_uvarint(&bytes[*at..])
|
||||||
|
.with_context(|| format!("provider-file: {what} varint"))?;
|
||||||
|
*at += n;
|
||||||
|
Ok(v)
|
||||||
|
};
|
||||||
|
anyhow::ensure!(next(&mut at, "version")? == 1, "provider-file: not a CIDv1");
|
||||||
|
anyhow::ensure!(next(&mut at, "codec")? == PROVIDER_FILE_CODEC, "not a provider-file cid");
|
||||||
|
let mh = next(&mut at, "multihash-code")?;
|
||||||
|
anyhow::ensure!(
|
||||||
|
mh == IDENTITY_MULTIHASH,
|
||||||
|
"provider-file locator must use the identity multihash (0x00), got 0x{mh:x}"
|
||||||
|
);
|
||||||
|
let len = next(&mut at, "digest-length")?;
|
||||||
|
anyhow::ensure!(
|
||||||
|
(bytes.len() - at) as u64 == len,
|
||||||
|
"provider-file digest length {len} != {} trailing byte(s)",
|
||||||
|
bytes.len() - at
|
||||||
|
);
|
||||||
|
let source_len = next(&mut at, "source-length")?;
|
||||||
|
let rest = &bytes[at..];
|
||||||
|
let source_len = usize::try_from(source_len)
|
||||||
|
.ok()
|
||||||
|
.filter(|n| *n <= rest.len())
|
||||||
|
.context("provider-file: source length overruns the digest")?;
|
||||||
|
let (source, id) = rest.split_at(source_len);
|
||||||
|
let source = std::str::from_utf8(source).context("provider-file source is not utf-8")?;
|
||||||
|
let id = std::str::from_utf8(id).context("provider-file id is not utf-8")?;
|
||||||
|
anyhow::ensure!(
|
||||||
|
!source.is_empty() && !id.is_empty(),
|
||||||
|
"provider-file locator needs both a source and an id"
|
||||||
|
);
|
||||||
|
Ok((source.to_string(), id.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The codec of a base32 CIDv1 string, without the 64-byte multihash limit.
|
||||||
|
fn codec_of(cid: &str) -> Option<u64> {
|
||||||
|
let bytes = raw_cid_bytes(cid)?;
|
||||||
|
let (version, n) = read_uvarint(&bytes).ok()?;
|
||||||
|
if version != 1 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
read_uvarint(&bytes[n..]).ok().map(|(codec, _)| codec)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode a `b`-prefixed lowercase base32 (RFC 4648, no padding) CID string.
|
||||||
|
fn raw_cid_bytes(cid: &str) -> Option<Vec<u8>> {
|
||||||
|
let body = cid.strip_prefix('b')?;
|
||||||
|
let mut out = Vec::with_capacity(body.len() * 5 / 8);
|
||||||
|
let (mut acc, mut bits) = (0u32, 0u32);
|
||||||
|
for c in body.bytes() {
|
||||||
|
let v = match c {
|
||||||
|
b'a'..=b'z' => c - b'a',
|
||||||
|
b'2'..=b'7' => c - b'2' + 26,
|
||||||
|
_ => return None,
|
||||||
|
} as u32;
|
||||||
|
acc = (acc << 5) | v;
|
||||||
|
bits += 5;
|
||||||
|
if bits >= 8 {
|
||||||
|
bits -= 8;
|
||||||
|
out.push((acc >> bits) as u8);
|
||||||
|
acc &= (1 << bits) - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// The Ragna Crimson release from watch.nsl.sh (2026-10-04).
|
||||||
|
const NZBGEEK: &str = "bagcsaabfcrqxa2jonz5gez3fmvvs42lomzxs6ylqngxuch22nhfhl4v4vmyhiegah2ka";
|
||||||
|
|
||||||
|
fn b32(bytes: &[u8]) -> String {
|
||||||
|
const A: &[u8; 32] = b"abcdefghijklmnopqrstuvwxyz234567";
|
||||||
|
let (mut acc, mut bits, mut s) = (0u32, 0u32, String::from("b"));
|
||||||
|
for &b in bytes {
|
||||||
|
acc = (acc << 8) | b as u32;
|
||||||
|
bits += 8;
|
||||||
|
while bits >= 5 {
|
||||||
|
bits -= 5;
|
||||||
|
s.push(A[((acc >> bits) & 31) as usize] as char);
|
||||||
|
}
|
||||||
|
acc &= (1 << bits) - 1;
|
||||||
|
}
|
||||||
|
if bits > 0 {
|
||||||
|
s.push(A[((acc << (5 - bits)) & 31) as usize] as char);
|
||||||
|
}
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
fn provider_file(source: &str, id: &str) -> String {
|
||||||
|
let mut digest = vec![source.len() as u8];
|
||||||
|
digest.extend_from_slice(source.as_bytes());
|
||||||
|
digest.extend_from_slice(id.as_bytes());
|
||||||
|
// version 1, codec 0x100A (varint 0x8a 0x20), identity, length
|
||||||
|
let mut wire = vec![0x01, 0x8a, 0x20, 0x00, digest.len() as u8];
|
||||||
|
wire.extend_from_slice(&digest);
|
||||||
|
b32(&wire)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_nzb_release_is_a_pointer_keyed_by_its_indexer_host() {
|
||||||
|
let p = decode(NZBGEEK).unwrap().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
p.redeem,
|
||||||
|
Some(RedeemKey { codec: REDEEM_NZB_RELEASE, key: "api.nzbgeek.info".into() })
|
||||||
|
);
|
||||||
|
assert_eq!(p.field, FIELD_MANIFEST);
|
||||||
|
assert_eq!(p.kind, PointerKind::NzbManifest);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_provider_file_is_a_pointer_keyed_by_its_source() {
|
||||||
|
let cid = provider_file("opensubtitles", "file:7061834");
|
||||||
|
assert_eq!(
|
||||||
|
decode_provider_file(&cid).unwrap(),
|
||||||
|
("opensubtitles".into(), "file:7061834".into())
|
||||||
|
);
|
||||||
|
let p = decode(&cid).unwrap().unwrap();
|
||||||
|
assert_eq!(p.redeem.unwrap().key, "opensubtitles");
|
||||||
|
assert_eq!(p.field, FIELD_FILE);
|
||||||
|
assert_eq!(p.kind, PointerKind::File);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_content_cid_is_not_a_pointer() {
|
||||||
|
assert!(!is_pointer("bafkreigh2akiscaildcqabsyg3dfr6chu3fgpregiymsck7e7aqa4s52zy"));
|
||||||
|
assert!(!is_pointer("not a cid"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -66,7 +66,13 @@ pub async fn serve_transport<P: TransportPlugin>(
|
|||||||
// Beacon v2: advertise once bound; say `bye` on clean shutdown so the hull
|
// Beacon v2: advertise once bound; say `bye` on clean shutdown so the hull
|
||||||
// drops us at once instead of after the liveness window.
|
// drops us at once instead of after the liveness window.
|
||||||
let port = listen.port();
|
let port = listen.port();
|
||||||
let beacon = crate::beacon::advertise_plugin(&manifest.implementation, &manifest.version, move || {
|
// `node.name` is a short machine name; the human description stays in the
|
||||||
|
// manifest (`implementation`), which a consumer fetches over HTTP.
|
||||||
|
let beacon_name = std::env::var("SERVICE_NAME")
|
||||||
|
.ok()
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.unwrap_or_else(|| format!("meta-transport-{}", manifest.id));
|
||||||
|
let beacon = crate::beacon::advertise_plugin(&beacon_name, &manifest.version, move || {
|
||||||
beacon_resource(advertised.as_ref(), port)
|
beacon_resource(advertised.as_ref(), port)
|
||||||
});
|
});
|
||||||
axum::serve(listener, app)
|
axum::serve(listener, app)
|
||||||
|
|||||||
Reference in New Issue
Block a user