Network-wide announcements signed by the bootstrap anchor posting id
New primitive: `VisibilityIntent::Announcement`, a public post whose
author MUST be the hardcoded bootstrap anchor posting identity
(`DEFAULT_ANCHOR_POSTING_ID`) and whose content carries an ed25519
signature by that key. Forged announcements (any other author, or
bad signature) are rejected by `control::receive_post` before storage
— they never enter the DB and never propagate via neighbor-manifest
diffs. Only the real anchor can publish announcements, and it does so
sparingly as part of the release deploy flow.
Uses release announcements to drive an in-app upgrade banner:
- Anchor publishes a signed `{category:release, version, channel,
download_url, ...}` post during every deploy.
- Clients receive it via the normal CDN; `apply_announcement_if_applicable`
stores the latest-per-category/channel in the settings kv, keyed
e.g. `announcement:release:stable`.
- Welcome screen checks storage on startup; if the stored release
version > CARGO_PKG_VERSION on the user's selected channel, a banner
appears with a Download button that opens the system browser.
- Settings gets "Updates" section with Stable / Beta radio + Check-now
button + current status line.
Core:
- `DEFAULT_ANCHOR_POSTING_ID: NodeId` constant (32 bytes, the anchor's
current posting id — `17af141956ae...`).
- New `VisibilityIntent::Announcement` variant; feed filters in all 6
`get_feed*` / `list_posts*` query sites updated to also exclude the
new intent AND the pre-existing `GroupKeyDistribute` intent.
- `types::AnnouncementContent` + `ReleaseAnnouncement` structs.
- `crypto::{sign,verify}_announcement` — length-prefixed field digest
with a "has release" 1-byte flag.
- New `announcement` module with `verify_announcement_post`,
`apply_announcement_if_applicable`, `latest_release`,
`build_announcement_post`, and a `StoredAnnouncement` envelope saved
to settings so the UI can render without a full post scan.
- `Node::publish_announcement` refuses to run unless the default posting
id equals the anchor constant — accidental use on client installs
fails loud.
Wire / receive:
- `control::receive_post` verifies announcement signatures upfront
alongside Control and Profile. Same pattern; same guarantees.
CLI one-shots (no daemon):
- `itsgoin <data_dir> --print-identity` — prints network_id +
default_posting_id, exits.
- `itsgoin <data_dir> --announce --ann-category release
--ann-channel stable --ann-version X --ann-title ... --ann-body ...
--ann-url https://itsgoin.com/download.html` — builds + stores +
propagates the signed post, exits.
deploy.sh:
- Now runs the announce one-shot inside the anchor-restart window
(after binary swap, before start). The DB is free during that gap,
so the one-shot can write without conflicting with the running
daemon. The restarted daemon loads all storage on boot and serves
the new announcement to pulling peers.
Tauri IPC:
- `check_release_announcement(channel)` → Option<ReleaseAnnouncementDto>
— returns None when up-to-date.
- `get_update_channel` / `set_update_channel(channel)` — persists in
settings kv key `ui_update_channel`; defaults to stable.
- `open_url_external(url)` — desktop-only (xdg-open / open / cmd start);
refuses non-http(s) URLs. Android needs the opener plugin — TODO.
Frontend:
- Upgrade banner on the welcome screen, populated by
`loadUpgradeBanner()`. Hidden when no newer release is known.
- Settings → Updates section with Stable/Beta radio + Check-now button
+ current status line.
Tests: announcement signature roundtrip; non-anchor author rejection;
non-announcement intent is a no-op. 124 / 124 core tests pass.
This commit is contained in:
parent
67d9367eec
commit
481e1c8435
13 changed files with 728 additions and 15 deletions
|
|
@ -25,6 +25,14 @@ async fn main() -> anyhow::Result<()> {
|
|||
let mut daemon = false;
|
||||
let mut mobile = false;
|
||||
let mut web_port: Option<u16> = None;
|
||||
let mut print_identity = false;
|
||||
let mut announce = false;
|
||||
let mut ann_category: Option<String> = None;
|
||||
let mut ann_title: Option<String> = None;
|
||||
let mut ann_body: Option<String> = None;
|
||||
let mut ann_channel: Option<String> = None;
|
||||
let mut ann_version: Option<String> = None;
|
||||
let mut ann_url: Option<String> = None;
|
||||
let mut i = 1;
|
||||
while i < args.len() {
|
||||
match args[i].as_str() {
|
||||
|
|
@ -66,6 +74,14 @@ async fn main() -> anyhow::Result<()> {
|
|||
}));
|
||||
i += 2;
|
||||
}
|
||||
"--print-identity" => { print_identity = true; i += 1; }
|
||||
"--announce" => { announce = true; i += 1; }
|
||||
"--ann-category" => { ann_category = args.get(i + 1).cloned(); i += 2; }
|
||||
"--ann-title" => { ann_title = args.get(i + 1).cloned(); i += 2; }
|
||||
"--ann-body" => { ann_body = args.get(i + 1).cloned(); i += 2; }
|
||||
"--ann-channel" => { ann_channel = args.get(i + 1).cloned(); i += 2; }
|
||||
"--ann-version" => { ann_version = args.get(i + 1).cloned(); i += 2; }
|
||||
"--ann-url" => { ann_url = args.get(i + 1).cloned(); i += 2; }
|
||||
_ => {
|
||||
if data_dir == "./itsgoin-data" {
|
||||
data_dir = args[i].clone();
|
||||
|
|
@ -88,6 +104,54 @@ async fn main() -> anyhow::Result<()> {
|
|||
}
|
||||
|
||||
let profile = if mobile { DeviceProfile::Mobile } else { DeviceProfile::Desktop };
|
||||
|
||||
// One-shot: --print-identity opens the node briefly, prints the
|
||||
// network + default-posting ids in full hex, and exits. Used by
|
||||
// deploy/inspection tooling.
|
||||
if print_identity {
|
||||
let node = Node::open_with_bind(&data_dir, bind_addr, profile).await?;
|
||||
println!("network_id: {}", hex::encode(node.node_id));
|
||||
println!("default_posting_id: {}", hex::encode(node.default_posting_id));
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// One-shot: --announce builds, stores, and propagates a signed
|
||||
// announcement post authored by this node's default posting identity.
|
||||
// Only works when this node is the bootstrap anchor (see
|
||||
// `Node::publish_announcement`). Used by the release deploy flow.
|
||||
if announce {
|
||||
let category = ann_category.clone().unwrap_or_else(|| "release".to_string());
|
||||
let title = ann_title.clone().unwrap_or_else(|| {
|
||||
match (&ann_version, &ann_channel) {
|
||||
(Some(v), Some(c)) => format!("ItsGoin v{} ({}) available", v, c),
|
||||
(Some(v), None) => format!("ItsGoin v{} available", v),
|
||||
_ => "ItsGoin announcement".to_string(),
|
||||
}
|
||||
});
|
||||
let body = ann_body.clone().unwrap_or_default();
|
||||
let release = match (ann_channel.as_deref(), ann_version.as_deref()) {
|
||||
(Some(ch), Some(ver)) => Some(itsgoin_core::types::ReleaseAnnouncement {
|
||||
channel: ch.to_string(),
|
||||
version: ver.to_string(),
|
||||
download_url: ann_url.clone().unwrap_or_else(|| "https://itsgoin.com/download.html".to_string()),
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
let node = Node::open_with_bind(&data_dir, bind_addr, profile).await?;
|
||||
match node.publish_announcement(category.clone(), title.clone(), body, release).await {
|
||||
Ok(post_id) => {
|
||||
println!("announcement_post_id: {}", hex::encode(post_id));
|
||||
println!("category: {}", category);
|
||||
println!("title: {}", title);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to publish announcement: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!("Starting ItsGoin node (data: {}, profile: {:?})...", data_dir, profile);
|
||||
let node = Arc::new(Node::open_with_bind(&data_dir, bind_addr, profile).await?);
|
||||
|
||||
|
|
|
|||
220
crates/core/src/announcement.rs
Normal file
220
crates/core/src/announcement.rs
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
//! Network-wide announcements, authored only by the bootstrap anchor's
|
||||
//! posting identity (see `crate::DEFAULT_ANCHOR_POSTING_ID`).
|
||||
//!
|
||||
//! Shape: a standard public post with `VisibilityIntent::Announcement`
|
||||
//! whose content is a JSON `AnnouncementContent` carrying a signed
|
||||
//! payload. Propagation uses the normal CDN path; there is no direct
|
||||
//! push. Because the signature check is against a single hardcoded key,
|
||||
//! any peer can try to publish an announcement but only the real anchor's
|
||||
//! posts verify — forgeries are rejected before storage in
|
||||
//! `control::receive_post`, and thus never re-propagate via neighbor
|
||||
//! manifest diffs.
|
||||
//!
|
||||
//! Use cases: release notices (triggers welcome-screen upgrade banner),
|
||||
//! protocol notices, security-relevant admin broadcasts. Intended to be
|
||||
//! used sparingly.
|
||||
|
||||
use crate::crypto;
|
||||
use crate::storage::Storage;
|
||||
use crate::types::{
|
||||
AnnouncementContent, NodeId, Post, PostId, PostVisibility, ReleaseAnnouncement,
|
||||
VisibilityIntent,
|
||||
};
|
||||
|
||||
/// Verify that a candidate post is a valid announcement: its author must
|
||||
/// be `DEFAULT_ANCHOR_POSTING_ID` and its signature must verify. Returns
|
||||
/// the decoded `AnnouncementContent` on success.
|
||||
pub fn verify_announcement_post(post: &Post) -> anyhow::Result<AnnouncementContent> {
|
||||
if post.author != crate::DEFAULT_ANCHOR_POSTING_ID {
|
||||
anyhow::bail!("announcement author is not the bootstrap anchor");
|
||||
}
|
||||
let content: AnnouncementContent = serde_json::from_str(&post.content).map_err(|e| {
|
||||
anyhow::anyhow!("announcement content is not a valid AnnouncementContent: {}", e)
|
||||
})?;
|
||||
if !crypto::verify_announcement(
|
||||
&post.author,
|
||||
&content.category,
|
||||
&content.title,
|
||||
&content.body,
|
||||
content.timestamp_ms,
|
||||
&content.release,
|
||||
&content.signature,
|
||||
) {
|
||||
anyhow::bail!("invalid announcement signature");
|
||||
}
|
||||
Ok(content)
|
||||
}
|
||||
|
||||
/// If the post is an announcement, verify + apply by upserting into the
|
||||
/// `settings`-backed latest-announcement slot(s). Only applied if newer
|
||||
/// than the stored row for the same category/channel (last-writer-wins
|
||||
/// by timestamp).
|
||||
pub fn apply_announcement_if_applicable(
|
||||
s: &Storage,
|
||||
post: &Post,
|
||||
intent: Option<&VisibilityIntent>,
|
||||
) -> anyhow::Result<()> {
|
||||
if !matches!(intent, Some(VisibilityIntent::Announcement)) {
|
||||
return Ok(());
|
||||
}
|
||||
let content = verify_announcement_post(post)?;
|
||||
|
||||
// Serialize the full content + a post_id pointer so the UI can render
|
||||
// the banner without re-fetching the post. Storage is settings-kv
|
||||
// keyed by "announcement:<category>:<channel>".
|
||||
let post_id = crate::content::compute_post_id(post);
|
||||
let key = announcement_key(&content.category, content.release.as_ref().map(|r| r.channel.as_str()));
|
||||
|
||||
if let Some(existing_ts) = s.get_setting(&format!("{}:ts", key))? {
|
||||
if let Ok(prev) = existing_ts.parse::<u64>() {
|
||||
if prev >= content.timestamp_ms {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let envelope = StoredAnnouncement {
|
||||
post_id,
|
||||
content: content.clone(),
|
||||
};
|
||||
let json = serde_json::to_string(&envelope)?;
|
||||
s.set_setting(&key, &json)?;
|
||||
s.set_setting(&format!("{}:ts", key), &content.timestamp_ms.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn announcement_key(category: &str, channel: Option<&str>) -> String {
|
||||
match channel {
|
||||
Some(ch) => format!("announcement:{}:{}", category, ch),
|
||||
None => format!("announcement:{}", category),
|
||||
}
|
||||
}
|
||||
|
||||
/// Envelope stored in settings so the UI can fetch the latest announcement
|
||||
/// without a full post scan. Includes the post_id so the UI can jump to
|
||||
/// the full post if needed.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct StoredAnnouncement {
|
||||
pub post_id: PostId,
|
||||
pub content: AnnouncementContent,
|
||||
}
|
||||
|
||||
/// Read the latest release announcement for a channel, if any.
|
||||
pub fn latest_release(s: &Storage, channel: &str) -> anyhow::Result<Option<StoredAnnouncement>> {
|
||||
let key = announcement_key("release", Some(channel));
|
||||
match s.get_setting(&key)? {
|
||||
Some(json) => Ok(serde_json::from_str(&json).ok()),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a signed announcement post. Must be called with the anchor's
|
||||
/// posting keypair (its seed matching `DEFAULT_ANCHOR_POSTING_ID`).
|
||||
pub fn build_announcement_post(
|
||||
author: &NodeId,
|
||||
author_secret: &[u8; 32],
|
||||
category: &str,
|
||||
title: &str,
|
||||
body: &str,
|
||||
release: Option<ReleaseAnnouncement>,
|
||||
) -> Post {
|
||||
let timestamp_ms = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0);
|
||||
let signature = crypto::sign_announcement(
|
||||
author_secret,
|
||||
category,
|
||||
title,
|
||||
body,
|
||||
timestamp_ms,
|
||||
&release,
|
||||
);
|
||||
let content = AnnouncementContent {
|
||||
category: category.to_string(),
|
||||
title: title.to_string(),
|
||||
body: body.to_string(),
|
||||
timestamp_ms,
|
||||
release,
|
||||
signature,
|
||||
};
|
||||
Post {
|
||||
author: *author,
|
||||
content: serde_json::to_string(&content).unwrap_or_default(),
|
||||
attachments: vec![],
|
||||
timestamp_ms,
|
||||
}
|
||||
}
|
||||
|
||||
/// Post visibility is always Public — the signature binds the content
|
||||
/// and only the anchor key can produce a verifying post.
|
||||
pub fn announcement_visibility() -> PostVisibility {
|
||||
PostVisibility::Public
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::storage::Storage;
|
||||
use ed25519_dalek::SigningKey;
|
||||
|
||||
fn temp_storage() -> Storage {
|
||||
Storage::open(":memory:").unwrap()
|
||||
}
|
||||
|
||||
fn make_keypair(seed_byte: u8) -> ([u8; 32], NodeId) {
|
||||
let seed = [seed_byte; 32];
|
||||
let signing_key = SigningKey::from_bytes(&seed);
|
||||
let public = signing_key.verifying_key();
|
||||
(seed, *public.as_bytes())
|
||||
}
|
||||
|
||||
/// Override DEFAULT_ANCHOR_POSTING_ID for tests by using whatever key
|
||||
/// we generate. We can't change the const, so instead verify_announcement_post
|
||||
/// is called with a post whose author matches the real const — we test
|
||||
/// the signature + serialization logic in isolation via a custom flow.
|
||||
#[test]
|
||||
fn announcement_signature_roundtrip() {
|
||||
let (sec, pubk) = make_keypair(0xaa);
|
||||
let release = Some(ReleaseAnnouncement {
|
||||
channel: "stable".to_string(),
|
||||
version: "0.6.3".to_string(),
|
||||
download_url: "https://itsgoin.com/download.html".to_string(),
|
||||
});
|
||||
let ts = 1234u64;
|
||||
let sig = crypto::sign_announcement(&sec, "release", "v0.6.3 available", "notes", ts, &release);
|
||||
assert!(crypto::verify_announcement(&pubk, "release", "v0.6.3 available", "notes", ts, &release, &sig));
|
||||
|
||||
// Flipping any field should break verification.
|
||||
assert!(!crypto::verify_announcement(&pubk, "release", "v0.6.4 available", "notes", ts, &release, &sig));
|
||||
let other_release = Some(ReleaseAnnouncement {
|
||||
channel: "beta".to_string(),
|
||||
..release.as_ref().unwrap().clone()
|
||||
});
|
||||
assert!(!crypto::verify_announcement(&pubk, "release", "v0.6.3 available", "notes", ts, &other_release, &sig));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_anchor_author_is_rejected() {
|
||||
// Craft a post whose author is NOT the hardcoded anchor key.
|
||||
let (sec, pubk) = make_keypair(0xbb);
|
||||
let release = Some(ReleaseAnnouncement {
|
||||
channel: "stable".to_string(),
|
||||
version: "0.6.3".to_string(),
|
||||
download_url: "x".to_string(),
|
||||
});
|
||||
let post = build_announcement_post(&pubk, &sec, "release", "Hi", "", release);
|
||||
let res = verify_announcement_post(&post);
|
||||
assert!(res.is_err(), "post authored by non-anchor key must be rejected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_noops_on_non_announcement_intent() {
|
||||
let s = temp_storage();
|
||||
let (sec, pubk) = make_keypair(0xcc);
|
||||
let post = build_announcement_post(&pubk, &sec, "release", "t", "b", None);
|
||||
// Not an announcement intent — apply should no-op and return Ok.
|
||||
apply_announcement_if_applicable(&s, &post, Some(&VisibilityIntent::Public)).unwrap();
|
||||
assert!(s.get_setting("announcement:release").unwrap().is_none());
|
||||
}
|
||||
}
|
||||
|
|
@ -111,6 +111,12 @@ pub fn receive_post(
|
|||
Some(VisibilityIntent::Profile) => {
|
||||
crate::profile::verify_profile_post(post)?;
|
||||
}
|
||||
Some(VisibilityIntent::Announcement) => {
|
||||
// Only the hardcoded anchor posting identity may author
|
||||
// announcements. verify_announcement_post checks both the
|
||||
// author match and the signature.
|
||||
crate::announcement::verify_announcement_post(post)?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
|
|
@ -122,6 +128,7 @@ pub fn receive_post(
|
|||
if stored {
|
||||
apply_control_post_if_applicable(s, post, intent)?;
|
||||
crate::profile::apply_profile_post_if_applicable(s, post, intent)?;
|
||||
crate::announcement::apply_announcement_if_applicable(s, post, intent)?;
|
||||
}
|
||||
Ok(stored)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -411,6 +411,75 @@ pub fn verify_profile(
|
|||
vk.verify_strict(&profile_post_bytes(display_name, bio, avatar_cid, timestamp_ms), &sig).is_ok()
|
||||
}
|
||||
|
||||
/// Canonical bytes for an announcement signature. Length-prefixed strings
|
||||
/// prevent extension/reordering attacks; the release subfields are all
|
||||
/// bundled after a 1-byte "has release" flag.
|
||||
fn announcement_bytes(
|
||||
category: &str,
|
||||
title: &str,
|
||||
body: &str,
|
||||
timestamp_ms: u64,
|
||||
release: &Option<crate::types::ReleaseAnnouncement>,
|
||||
) -> Vec<u8> {
|
||||
let cat = category.as_bytes();
|
||||
let tit = title.as_bytes();
|
||||
let bd = body.as_bytes();
|
||||
let mut buf = Vec::with_capacity(128 + cat.len() + tit.len() + bd.len());
|
||||
buf.extend_from_slice(b"annc:");
|
||||
buf.extend_from_slice(&(cat.len() as u64).to_le_bytes());
|
||||
buf.extend_from_slice(cat);
|
||||
buf.extend_from_slice(&(tit.len() as u64).to_le_bytes());
|
||||
buf.extend_from_slice(tit);
|
||||
buf.extend_from_slice(&(bd.len() as u64).to_le_bytes());
|
||||
buf.extend_from_slice(bd);
|
||||
buf.extend_from_slice(×tamp_ms.to_le_bytes());
|
||||
match release {
|
||||
Some(r) => {
|
||||
buf.push(1u8);
|
||||
let c = r.channel.as_bytes();
|
||||
let v = r.version.as_bytes();
|
||||
let u = r.download_url.as_bytes();
|
||||
buf.extend_from_slice(&(c.len() as u64).to_le_bytes());
|
||||
buf.extend_from_slice(c);
|
||||
buf.extend_from_slice(&(v.len() as u64).to_le_bytes());
|
||||
buf.extend_from_slice(v);
|
||||
buf.extend_from_slice(&(u.len() as u64).to_le_bytes());
|
||||
buf.extend_from_slice(u);
|
||||
}
|
||||
None => buf.push(0u8),
|
||||
}
|
||||
buf
|
||||
}
|
||||
|
||||
pub fn sign_announcement(
|
||||
seed: &[u8; 32],
|
||||
category: &str,
|
||||
title: &str,
|
||||
body: &str,
|
||||
timestamp_ms: u64,
|
||||
release: &Option<crate::types::ReleaseAnnouncement>,
|
||||
) -> Vec<u8> {
|
||||
let signing_key = SigningKey::from_bytes(seed);
|
||||
let sig = signing_key.sign(&announcement_bytes(category, title, body, timestamp_ms, release));
|
||||
sig.to_bytes().to_vec()
|
||||
}
|
||||
|
||||
pub fn verify_announcement(
|
||||
author: &NodeId,
|
||||
category: &str,
|
||||
title: &str,
|
||||
body: &str,
|
||||
timestamp_ms: u64,
|
||||
release: &Option<crate::types::ReleaseAnnouncement>,
|
||||
signature: &[u8],
|
||||
) -> bool {
|
||||
if signature.len() != 64 { return false; }
|
||||
let sig_bytes: [u8; 64] = match signature.try_into() { Ok(b) => b, Err(_) => return false };
|
||||
let sig = ed25519_dalek::Signature::from_bytes(&sig_bytes);
|
||||
let Ok(vk) = VerifyingKey::from_bytes(author) else { return false };
|
||||
vk.verify_strict(&announcement_bytes(category, title, body, timestamp_ms, release), &sig).is_ok()
|
||||
}
|
||||
|
||||
/// Verify an ed25519 delete signature: the author's public key signed the post_id.
|
||||
pub fn verify_delete_signature(author: &NodeId, post_id: &PostId, signature: &[u8]) -> bool {
|
||||
if signature.len() != 64 {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ pub mod http;
|
|||
pub mod export;
|
||||
pub mod identity;
|
||||
pub mod import;
|
||||
pub mod announcement;
|
||||
pub mod network;
|
||||
pub mod node;
|
||||
pub mod profile;
|
||||
|
|
@ -24,6 +25,17 @@ pub use iroh::{EndpointAddr, EndpointId};
|
|||
|
||||
use types::NodeId;
|
||||
|
||||
/// Posting-identity public key of the bootstrap anchor. Only announcements
|
||||
/// authored by this key are accepted by `control::receive_post` for the
|
||||
/// `VisibilityIntent::Announcement` intent. Hardcoded so clients cannot be
|
||||
/// tricked into accepting a forged network-wide announcement.
|
||||
pub const DEFAULT_ANCHOR_POSTING_ID: NodeId = [
|
||||
0x17, 0xaf, 0x14, 0x19, 0x56, 0xae, 0x0b, 0x50,
|
||||
0xdc, 0x1c, 0xb9, 0x24, 0x8c, 0xad, 0xf5, 0xfc,
|
||||
0xa3, 0x71, 0xea, 0x2d, 0x85, 0x31, 0xac, 0x9a,
|
||||
0xdd, 0x3c, 0x03, 0xca, 0xff, 0xc6, 0x14, 0x41,
|
||||
];
|
||||
|
||||
/// Parse a connect string "nodeid_hex@ip:port" or "nodeid_hex@host:port" or bare "nodeid_hex"
|
||||
/// into (NodeId, EndpointAddr). Supports DNS hostnames via `ToSocketAddrs`.
|
||||
/// Shared utility used by CLI, Tauri, and bootstrap.
|
||||
|
|
|
|||
|
|
@ -1072,13 +1072,14 @@ impl Node {
|
|||
VisibilityIntent::Friends => storage.list_public_follows(),
|
||||
VisibilityIntent::Circle(name) => storage.get_circle_members(name),
|
||||
VisibilityIntent::Direct(ids) => Ok(ids.clone()),
|
||||
// Control / Profile posts are always Public on the wire;
|
||||
// GroupKeyDistribute posts build their own recipient list in
|
||||
// `group_key_distribution::build_distribution_post`. None of
|
||||
// the three use this resolver.
|
||||
// Control / Profile / Announcement posts are always Public on
|
||||
// the wire; GroupKeyDistribute posts build their own recipient
|
||||
// list in `group_key_distribution::build_distribution_post`.
|
||||
// None of these use the standard resolver.
|
||||
VisibilityIntent::Control
|
||||
| VisibilityIntent::Profile
|
||||
| VisibilityIntent::GroupKeyDistribute => Ok(vec![]),
|
||||
| VisibilityIntent::GroupKeyDistribute
|
||||
| VisibilityIntent::Announcement => Ok(vec![]),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1886,6 +1887,73 @@ impl Node {
|
|||
|
||||
// ---- end Groups ----
|
||||
|
||||
// ---- Announcements ----
|
||||
|
||||
/// Publish a signed network-wide announcement. Only succeeds when run
|
||||
/// on the bootstrap anchor — the default posting identity must be
|
||||
/// `DEFAULT_ANCHOR_POSTING_ID`. Called from `itsgoin announce` during
|
||||
/// release deploys.
|
||||
pub async fn publish_announcement(
|
||||
&self,
|
||||
category: String,
|
||||
title: String,
|
||||
body: String,
|
||||
release: Option<crate::types::ReleaseAnnouncement>,
|
||||
) -> anyhow::Result<PostId> {
|
||||
if self.default_posting_id != crate::DEFAULT_ANCHOR_POSTING_ID {
|
||||
anyhow::bail!(
|
||||
"refusing to publish announcement: default posting identity is not the bootstrap anchor"
|
||||
);
|
||||
}
|
||||
let post = crate::announcement::build_announcement_post(
|
||||
&self.default_posting_id,
|
||||
&self.default_posting_secret,
|
||||
&category,
|
||||
&title,
|
||||
&body,
|
||||
release,
|
||||
);
|
||||
let post_id = crate::content::compute_post_id(&post);
|
||||
let timestamp_ms = post.timestamp_ms;
|
||||
|
||||
{
|
||||
let storage = self.storage.get().await;
|
||||
storage.store_post_with_intent(
|
||||
&post_id,
|
||||
&post,
|
||||
&PostVisibility::Public,
|
||||
&VisibilityIntent::Announcement,
|
||||
)?;
|
||||
crate::announcement::apply_announcement_if_applicable(
|
||||
&*storage,
|
||||
&post,
|
||||
Some(&VisibilityIntent::Announcement),
|
||||
)?;
|
||||
}
|
||||
self.update_neighbor_manifests_as(
|
||||
&self.default_posting_id,
|
||||
&self.default_posting_secret,
|
||||
&post_id,
|
||||
timestamp_ms,
|
||||
).await;
|
||||
info!(
|
||||
post_id = hex::encode(post_id),
|
||||
category = %category,
|
||||
"Published network-wide announcement"
|
||||
);
|
||||
Ok(post_id)
|
||||
}
|
||||
|
||||
/// Return the latest stored release announcement for the given channel
|
||||
/// (\"stable\" or \"beta\"), or `None` if none is known yet.
|
||||
pub async fn latest_release_announcement(
|
||||
&self,
|
||||
channel: &str,
|
||||
) -> anyhow::Result<Option<crate::announcement::StoredAnnouncement>> {
|
||||
let storage = self.storage.get().await;
|
||||
crate::announcement::latest_release(&*storage, channel)
|
||||
}
|
||||
|
||||
/// Scan any newly-received `VisibilityIntent::GroupKeyDistribute` posts
|
||||
/// and apply ones we can decrypt with one of our posting identities.
|
||||
/// Intended to run after a sync pass so group seeds propagate to members
|
||||
|
|
|
|||
|
|
@ -866,7 +866,7 @@ impl Storage {
|
|||
pub fn list_posts_reverse_chron(&self) -> anyhow::Result<Vec<(PostId, Post, PostVisibility)>> {
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT id, author, content, attachments, timestamp_ms, visibility FROM posts
|
||||
WHERE (visibility_intent IS NULL OR (visibility_intent != '\"Control\"' AND visibility_intent != '\"Profile\"'))
|
||||
WHERE (visibility_intent IS NULL OR (visibility_intent != '\"Control\"' AND visibility_intent != '\"Profile\"' AND visibility_intent != '\"Announcement\"' AND visibility_intent != '\"GroupKeyDistribute\"'))
|
||||
ORDER BY timestamp_ms DESC",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
|
|
@ -903,7 +903,7 @@ impl Storage {
|
|||
"SELECT p.id, p.author, p.content, p.attachments, p.timestamp_ms, p.visibility
|
||||
FROM posts p
|
||||
INNER JOIN follows f ON p.author = f.node_id
|
||||
WHERE (p.visibility_intent IS NULL OR (p.visibility_intent != '\"Control\"' AND p.visibility_intent != '\"Profile\"'))
|
||||
WHERE (p.visibility_intent IS NULL OR (p.visibility_intent != '\"Control\"' AND p.visibility_intent != '\"Profile\"' AND p.visibility_intent != '\"Announcement\"' AND p.visibility_intent != '\"GroupKeyDistribute\"'))
|
||||
ORDER BY p.timestamp_ms DESC",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
|
|
@ -940,12 +940,12 @@ impl Storage {
|
|||
"SELECT p.id, p.author, p.content, p.attachments, p.timestamp_ms, p.visibility
|
||||
FROM posts p INNER JOIN follows f ON p.author = f.node_id
|
||||
WHERE p.timestamp_ms < ?1
|
||||
AND (p.visibility_intent IS NULL OR (p.visibility_intent != '\"Control\"' AND p.visibility_intent != '\"Profile\"'))
|
||||
AND (p.visibility_intent IS NULL OR (p.visibility_intent != '\"Control\"' AND p.visibility_intent != '\"Profile\"' AND p.visibility_intent != '\"Announcement\"' AND p.visibility_intent != '\"GroupKeyDistribute\"'))
|
||||
ORDER BY p.timestamp_ms DESC LIMIT ?2"
|
||||
} else {
|
||||
"SELECT p.id, p.author, p.content, p.attachments, p.timestamp_ms, p.visibility
|
||||
FROM posts p INNER JOIN follows f ON p.author = f.node_id
|
||||
WHERE (p.visibility_intent IS NULL OR (p.visibility_intent != '\"Control\"' AND p.visibility_intent != '\"Profile\"'))
|
||||
WHERE (p.visibility_intent IS NULL OR (p.visibility_intent != '\"Control\"' AND p.visibility_intent != '\"Profile\"' AND p.visibility_intent != '\"Announcement\"' AND p.visibility_intent != '\"GroupKeyDistribute\"'))
|
||||
ORDER BY p.timestamp_ms DESC LIMIT ?2"
|
||||
};
|
||||
let mut stmt = self.conn.prepare(sql)?;
|
||||
|
|
@ -963,12 +963,12 @@ impl Storage {
|
|||
"SELECT id, author, content, attachments, timestamp_ms, visibility
|
||||
FROM posts
|
||||
WHERE timestamp_ms < ?1
|
||||
AND (visibility_intent IS NULL OR (visibility_intent != '\"Control\"' AND visibility_intent != '\"Profile\"'))
|
||||
AND (visibility_intent IS NULL OR (visibility_intent != '\"Control\"' AND visibility_intent != '\"Profile\"' AND visibility_intent != '\"Announcement\"' AND visibility_intent != '\"GroupKeyDistribute\"'))
|
||||
ORDER BY timestamp_ms DESC LIMIT ?2"
|
||||
} else {
|
||||
"SELECT id, author, content, attachments, timestamp_ms, visibility
|
||||
FROM posts
|
||||
WHERE (visibility_intent IS NULL OR (visibility_intent != '\"Control\"' AND visibility_intent != '\"Profile\"'))
|
||||
WHERE (visibility_intent IS NULL OR (visibility_intent != '\"Control\"' AND visibility_intent != '\"Profile\"' AND visibility_intent != '\"Announcement\"' AND visibility_intent != '\"GroupKeyDistribute\"'))
|
||||
ORDER BY timestamp_ms DESC LIMIT ?2"
|
||||
};
|
||||
let mut stmt = self.conn.prepare(sql)?;
|
||||
|
|
|
|||
|
|
@ -252,6 +252,11 @@ pub enum VisibilityIntent {
|
|||
/// standard encrypted post that propagates via the CDN. Members
|
||||
/// decrypt with their posting secret to recover the seed.
|
||||
GroupKeyDistribute,
|
||||
/// Network-wide announcement (release notice, protocol notice, etc.),
|
||||
/// authored only by the hardcoded anchor posting identity and verified
|
||||
/// against it on receive. Propagates via the CDN like any other post.
|
||||
/// Filtered out of feeds; surfaced only in the Settings / welcome UI.
|
||||
Announcement,
|
||||
}
|
||||
|
||||
/// Content payload of a `VisibilityIntent::Profile` post — persona display
|
||||
|
|
@ -271,6 +276,48 @@ pub struct ProfilePostContent {
|
|||
pub signature: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Content payload of a `VisibilityIntent::Announcement` post.
|
||||
///
|
||||
/// A network-wide announcement signed by the bootstrap anchor's posting
|
||||
/// identity (see `DEFAULT_ANCHOR_POSTING_ID`). Carries either a release
|
||||
/// notice (`category == "release"` with `release` populated) or a generic
|
||||
/// notice (`category == "notice"`). Propagates via the CDN; no direct push.
|
||||
///
|
||||
/// Signature is ed25519 over length-prefixed bytes of all fields except
|
||||
/// `signature` itself — see `crypto::sign_announcement`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AnnouncementContent {
|
||||
/// Machine-readable category: "release" | "notice".
|
||||
pub category: String,
|
||||
/// Short human-readable title shown in UI.
|
||||
pub title: String,
|
||||
/// Longer body / changelog notes.
|
||||
#[serde(default)]
|
||||
pub body: String,
|
||||
/// When this announcement was authored (ms). Enforces monotonicity —
|
||||
/// older announcements for the same category never overwrite newer
|
||||
/// stored state.
|
||||
pub timestamp_ms: u64,
|
||||
/// Release-specific fields, only set when `category == "release"`.
|
||||
#[serde(default)]
|
||||
pub release: Option<ReleaseAnnouncement>,
|
||||
/// 64-byte ed25519 signature by `DEFAULT_ANCHOR_POSTING_ID`.
|
||||
pub signature: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Release-specific subfields of an announcement. Kept separate so a
|
||||
/// `category == "notice"` post doesn't need these filled in.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ReleaseAnnouncement {
|
||||
/// "stable" | "beta".
|
||||
pub channel: String,
|
||||
/// Semver version string, e.g. "0.6.2".
|
||||
pub version: String,
|
||||
/// URL clients open in the system browser for the download (typically
|
||||
/// `https://itsgoin.com/download.html`).
|
||||
pub download_url: String,
|
||||
}
|
||||
|
||||
/// Content payload of a `VisibilityIntent::GroupKeyDistribute` post.
|
||||
/// Wrapped inside a standard `PostVisibility::Encrypted` envelope — members
|
||||
/// decrypt via `crypto::decrypt_post` with their posting secret, then parse
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -241,6 +241,7 @@ async fn post_to_dto(
|
|||
VisibilityIntent::Control => "control".to_string(),
|
||||
VisibilityIntent::Profile => "profile".to_string(),
|
||||
VisibilityIntent::GroupKeyDistribute => "group_key_distribute".to_string(),
|
||||
VisibilityIntent::Announcement => "announcement".to_string(),
|
||||
},
|
||||
_ => "unknown".to_string(),
|
||||
}
|
||||
|
|
@ -1339,6 +1340,117 @@ struct KnownAnchorDto {
|
|||
addresses: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ReleaseAnnouncementDto {
|
||||
channel: String,
|
||||
version: String,
|
||||
title: String,
|
||||
body: String,
|
||||
download_url: String,
|
||||
timestamp_ms: u64,
|
||||
is_newer_than_current: bool,
|
||||
}
|
||||
|
||||
/// Check for a newer release on the user's selected update channel.
|
||||
/// Returns None if no announcement is stored for that channel or the
|
||||
/// stored announcement isn't actually newer than the running build.
|
||||
#[tauri::command]
|
||||
async fn check_release_announcement(
|
||||
state: State<'_, AppNode>,
|
||||
channel: String,
|
||||
) -> Result<Option<ReleaseAnnouncementDto>, String> {
|
||||
let node = get_node(&state).await;
|
||||
let stored = node.latest_release_announcement(&channel).await.map_err(|e| e.to_string())?;
|
||||
let Some(stored) = stored else { return Ok(None); };
|
||||
let Some(release) = stored.content.release.as_ref() else { return Ok(None); };
|
||||
|
||||
let current = env!("CARGO_PKG_VERSION");
|
||||
let is_newer = version_is_newer(&release.version, current);
|
||||
if !is_newer { return Ok(None); }
|
||||
|
||||
Ok(Some(ReleaseAnnouncementDto {
|
||||
channel: release.channel.clone(),
|
||||
version: release.version.clone(),
|
||||
title: stored.content.title.clone(),
|
||||
body: stored.content.body.clone(),
|
||||
download_url: release.download_url.clone(),
|
||||
timestamp_ms: stored.content.timestamp_ms,
|
||||
is_newer_than_current: true,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Simple dotted-semver compare. Returns true if `a` > `b`.
|
||||
/// Handles "0.6.2" vs "0.6.3" and suffixed labels like "0.6.3-beta"
|
||||
/// (beta suffix treated as pre-release, so "0.6.3-beta" < "0.6.3").
|
||||
fn version_is_newer(a: &str, b: &str) -> bool {
|
||||
fn parse(v: &str) -> (Vec<u32>, bool) {
|
||||
let (core, pre) = match v.split_once('-') {
|
||||
Some((c, _)) => (c, true),
|
||||
None => (v, false),
|
||||
};
|
||||
let nums: Vec<u32> = core.split('.').filter_map(|p| p.parse().ok()).collect();
|
||||
(nums, pre)
|
||||
}
|
||||
let (na, pa) = parse(a);
|
||||
let (nb, pb) = parse(b);
|
||||
let len = na.len().max(nb.len());
|
||||
for i in 0..len {
|
||||
let ai = *na.get(i).unwrap_or(&0);
|
||||
let bi = *nb.get(i).unwrap_or(&0);
|
||||
if ai != bi { return ai > bi; }
|
||||
}
|
||||
// Cores equal — a stable (no pre) outranks a pre-release.
|
||||
!pa && pb
|
||||
}
|
||||
|
||||
/// Read the user's preferred update channel from local settings.
|
||||
/// Defaults to "stable" if never set.
|
||||
#[tauri::command]
|
||||
async fn get_update_channel(state: State<'_, AppNode>) -> Result<String, String> {
|
||||
let node = get_node(&state).await;
|
||||
let storage = node.storage.get().await;
|
||||
Ok(storage
|
||||
.get_setting("ui_update_channel")
|
||||
.map_err(|e| e.to_string())?
|
||||
.unwrap_or_else(|| "stable".to_string()))
|
||||
}
|
||||
|
||||
/// Persist the user's preferred update channel.
|
||||
#[tauri::command]
|
||||
async fn set_update_channel(state: State<'_, AppNode>, channel: String) -> Result<(), String> {
|
||||
if channel != "stable" && channel != "beta" {
|
||||
return Err(format!("invalid channel: {}", channel));
|
||||
}
|
||||
let node = get_node(&state).await;
|
||||
let storage = node.storage.get().await;
|
||||
storage.set_setting("ui_update_channel", &channel).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Open a URL in the user's default system browser.
|
||||
/// Desktop: spawns the platform opener (xdg-open / open / cmd start).
|
||||
/// Only https:// URLs are accepted to avoid being a generic command exec.
|
||||
/// TODO: Android requires an Intent.ACTION_VIEW — add tauri-plugin-opener
|
||||
/// when we need the banner to work on mobile.
|
||||
#[tauri::command]
|
||||
fn open_url_external(url: String) -> Result<(), String> {
|
||||
if !url.starts_with("https://") && !url.starts_with("http://") {
|
||||
return Err("only http(s) urls are allowed".to_string());
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
let res = std::process::Command::new("xdg-open").arg(&url).spawn();
|
||||
#[cfg(target_os = "macos")]
|
||||
let res = std::process::Command::new("open").arg(&url).spawn();
|
||||
#[cfg(target_os = "windows")]
|
||||
let res = std::process::Command::new("cmd").args(["/c", "start", "", &url]).spawn();
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
|
||||
let res: std::io::Result<std::process::Child> = Err(std::io::Error::new(
|
||||
std::io::ErrorKind::Unsupported,
|
||||
"open_url_external not supported on this platform",
|
||||
));
|
||||
res.map(|_| ()).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn list_known_anchors(state: State<'_, AppNode>) -> Result<Vec<KnownAnchorDto>, String> {
|
||||
let node = get_node(&state).await;
|
||||
|
|
@ -2907,6 +3019,10 @@ pub fn run() {
|
|||
set_anchors,
|
||||
list_anchor_peers,
|
||||
list_known_anchors,
|
||||
check_release_announcement,
|
||||
get_update_channel,
|
||||
set_update_channel,
|
||||
open_url_external,
|
||||
list_connections,
|
||||
worm_lookup,
|
||||
list_social_routes,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue