aboutsummaryrefslogtreecommitdiff
path: root/crates/chat-server/src
diff options
context:
space:
mode:
authorDoan Luu Duc Tai <doan@luuduct.ai>2026-10-30 22:12:54 -0500
committerDoan Luu Duc Tai <doan@luuduct.ai>2026-10-30 22:12:54 -0500
commitc3b361c45ac8dd898d5c83a161abed5fc138b6c0 (patch)
tree3eeabb31508f98a6753a2492238581f888769b4a /crates/chat-server/src
parent3034705b6679bff1802c4180b7b4711b94fed7f9 (diff)
Refine chat controls and add explicit owner recovery reset
Simplify the ASCII interface, expose owner setup, and load any selected number of messages using bounded history batches. Add backend-only owner reset with a matching public fingerprint and stopped-service guards, and document the loss of old history and key continuity.
Diffstat (limited to 'crates/chat-server/src')
-rw-r--r--crates/chat-server/src/main.rs24
-rw-r--r--crates/chat-server/src/maintenance.rs61
-rw-r--r--crates/chat-server/src/store.rs13
3 files changed, 96 insertions, 2 deletions
diff --git a/crates/chat-server/src/main.rs b/crates/chat-server/src/main.rs
index 6c7f4e1..c17a7f6 100644
--- a/crates/chat-server/src/main.rs
+++ b/crates/chat-server/src/main.rs
@@ -16,6 +16,7 @@
#![doc = "Local experimental chat server with ciphertext-only durable storage."]
mod http;
+mod maintenance;
mod service;
mod store;
@@ -33,8 +34,16 @@ struct Arguments {
root: PathBuf,
#[arg(long, default_value = "127.0.0.1:9080")]
bind: SocketAddr,
- #[arg(long)]
+ #[arg(long, conflicts_with_all = ["reset_owner", "owner_fingerprint", "snapshot"])]
init: bool,
+ #[arg(long, value_name = "FINGERPRINT", conflicts_with_all = ["owner_fingerprint", "snapshot"], help = "Permanently clear the matching owner registration and all chat history after key loss")]
+ reset_owner: Option<String>,
+ #[arg(
+ long,
+ conflicts_with = "snapshot",
+ help = "Print the registered owner's public fingerprint"
+ )]
+ owner_fingerprint: bool,
#[arg(long)]
snapshot: Option<PathBuf>,
}
@@ -76,11 +85,22 @@ async fn run(arguments: Arguments) -> Result<()> {
std::fs::create_dir_all(&data)?;
std::fs::set_permissions(&data, std::os::unix::fs::PermissionsExt::from_mode(0o700))?;
let database = data.join("chat.sqlite");
- let store = store::Store::open(&database)?;
+ let _server_lock = if arguments.snapshot.is_none() && !arguments.owner_fingerprint {
+ Some(maintenance::lock(&data)?)
+ } else {
+ None
+ };
+ let mut store = store::Store::open(&database)?;
std::fs::set_permissions(
&database,
std::os::unix::fs::PermissionsExt::from_mode(0o600),
)?;
+ if arguments.owner_fingerprint {
+ return maintenance::fingerprint(&store);
+ }
+ if let Some(expected) = arguments.reset_owner {
+ return maintenance::reset(&mut store, &expected, arguments.bind);
+ }
if let Some(destination) = arguments.snapshot {
store.snapshot(&destination)?;
std::fs::set_permissions(
diff --git a/crates/chat-server/src/maintenance.rs b/crates/chat-server/src/maintenance.rs
new file mode 100644
index 0000000..eac689d
--- /dev/null
+++ b/crates/chat-server/src/maintenance.rs
@@ -0,0 +1,61 @@
+// Copyright (C) 2026 Doan Luu Duc Tai <doan@luuduct.ai>
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+use crate::store::Store;
+use anyhow::{Context, Result, ensure};
+use std::fs::{File, OpenOptions};
+use std::io::Write;
+use std::net::{SocketAddr, TcpListener};
+use std::os::unix::fs::OpenOptionsExt;
+use std::path::Path;
+
+pub(crate) fn lock(directory: &Path) -> Result<File> {
+ let file = OpenOptions::new()
+ .read(true)
+ .write(true)
+ .create(true)
+ .truncate(false)
+ .mode(0o600)
+ .open(directory.join("server.lock"))?;
+ file.try_lock().context(
+ "Chat is already running. Stop it before starting another server or resetting the owner.",
+ )?;
+ Ok(file)
+}
+
+pub(crate) fn fingerprint(store: &Store) -> Result<()> {
+ let certificate = store.owner()?.context("No owner identity is registered")?;
+ writeln!(
+ std::io::stdout().lock(),
+ "{}",
+ stack_chat_crypto::public_identity(&certificate)?
+ )?;
+ Ok(())
+}
+
+pub(crate) fn reset(store: &mut Store, expected: &str, address: SocketAddr) -> Result<()> {
+ ensure!(
+ address.ip().is_loopback(),
+ "Reset must use the local chat address"
+ );
+ let _port =
+ TcpListener::bind(address).context("Stop the chat server before resetting its owner")?;
+ store.reset_owner(expected)?;
+ writeln!(
+ std::io::stdout().lock(),
+ "Owner registration and chat history cleared. Restart chat and use OWNER SETUP to create a new owner file. Existing backup archives were not changed."
+ )?;
+ Ok(())
+}
diff --git a/crates/chat-server/src/store.rs b/crates/chat-server/src/store.rs
index fce3bb3..0e09de7 100644
--- a/crates/chat-server/src/store.rs
+++ b/crates/chat-server/src/store.rs
@@ -45,6 +45,19 @@ impl Store {
Ok(())
}
+ pub(crate) fn reset_owner(&mut self, expected: &str) -> Result<()> {
+ let owner = self.owner()?.context("No owner identity is registered")?;
+ let fingerprint = stack_chat_crypto::public_identity(&owner)?;
+ ensure!(
+ expected.len() == 64 && expected.eq_ignore_ascii_case(&fingerprint),
+ "Owner fingerprint does not match; no data was cleared"
+ );
+ let transaction = self.connection.transaction()?;
+ transaction.execute_batch("DELETE FROM messages; DELETE FROM conversations; DELETE FROM metadata WHERE key='owner'; DELETE FROM sqlite_sequence WHERE name='messages';")?;
+ transaction.commit()?;
+ Ok(())
+ }
+
pub(crate) fn register(&self, id: &str, cert: &str) -> Result<()> {
if self.connection.query_row(
"SELECT EXISTS(SELECT 1 FROM conversations WHERE id=?1)",