summaryrefslogtreecommitdiffstats
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/assets/web/templates/index.html230
-rw-r--r--internal/config/config.go64
-rw-r--r--internal/config/config_test.go151
-rw-r--r--internal/smtpclient/client.go141
-rw-r--r--internal/smtpclient/client_test.go99
-rw-r--r--internal/submit/message.go14
-rw-r--r--internal/submit/message_test.go41
-rw-r--r--internal/submit/types.go91
-rw-r--r--internal/submit/types_test.go125
-rw-r--r--internal/submit/validation_test.go2
-rw-r--r--internal/transporthealth/monitor.go83
-rw-r--r--internal/transporthealth/monitor_test.go30
12 files changed, 964 insertions, 107 deletions
diff --git a/internal/assets/web/templates/index.html b/internal/assets/web/templates/index.html
index 60df055..4595421 100644
--- a/internal/assets/web/templates/index.html
+++ b/internal/assets/web/templates/index.html
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
- <title>N2Usenet Gateway v2.7 Nym</title>
+ <title>N2Usenet Gateway v2.7.4 Nym</title>
<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="shortcut icon" href="/favicon.ico">
<style>
@@ -503,7 +503,7 @@
<body>
<div class="container">
<header>
- <h1>N2Usenet Gateway v2.7 Nym</h1>
+ <h1>N2Usenet Gateway v2.7.4 Nym</h1>
<div class="theme-toggle">
<span>🌞</span>
<input type="checkbox" id="themeToggle">
@@ -554,12 +554,12 @@
</div>
<div class="section-info">
- <p><strong>About N2Usenet:</strong> A privacy-focused standalone gateway that posts to Usenet newsgroups over Nym to the TCPReset Mail2News and NNTP infrastructure. No access logs are kept. To post you must first create, or load, a <strong>VFACE pseudonymous identity</strong>: an Ed25519 keypair whose public key deterministically generates your identicon. Same key &rarr; same identicon &rarr; same person over time.</p>
+ <p><strong>About N2Usenet:</strong> A privacy-focused standalone gateway that posts to Usenet newsgroups over Nym to the TCPReset Mail2News and NNTP infrastructure. No access logs are kept. To post you must first create, or load, a <strong>VFACE pseudonymous identity</strong>: username, email address, and Ed25519 keypair. The complete tuple deterministically generates the identicon and is published in the post.</p>
<p><strong>What is Proof-of-Work?</strong> This step generates a hashcash token that prevents spam by requiring your computer to perform some calculations.</p>
</div>
<label>Username (your pseudonym): <input type="text" id="fromName" placeholder="YourPseudonym" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" required></label>
- <label>Email (for identity hash): <input type="email" id="hcEmail" placeholder="your@email.example" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" required></label>
+ <label>Email (public profile identity): <input type="email" id="hcEmail" placeholder="your@email.example" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" required></label>
<label>Difficulty (bits):
<select id="hcBits">
<option value="16">16 bits (very fast, ~instant - recommended for hardened browsers)</option>
@@ -597,7 +597,7 @@
<div class="section-info">
<p><strong>What is this?</strong> Your keypair is generated and your message is signed <strong>locally in your browser</strong> with the self-hosted TweetNaCl (<code>nacl</code>) library, and your Ed25519 secret key never leaves your device. Only the public identity (username, email, public key) is sent, to render the identicon. The signature proves you authored this post.</p>
- <p><strong>VFACE / Face Header:</strong> Your identicon is produced by the original <code>identicons-cli</code> engine, the same backend as <code>identicons.virebent.art</code>, so it is fully deterministic: the same key always yields the same identicon. It is embedded as a folded <code>Face:</code> header (RFC&nbsp;4021/2822) alongside <code>X-Ed25519-Pub</code> and <code>X-Ed25519-Sig</code>, visible in newsreaders like Newsgrouper and re-verifiable by any reader from your public key.</p>
+ <p><strong>VFACE / Face Header:</strong> Your identicon is produced by the original <code>identicons-cli</code> engine, the same backend as <code>identicons.virebent.art</code>. It is deterministic from <code>username|email|public-key</code>, so all three fields must remain unchanged to preserve the same VFACE identity. It is embedded as a folded <code>Face:</code> header (RFC&nbsp;4021/2822) alongside <code>X-Ed25519-Pub</code> and <code>X-Ed25519-Sig</code>.</p>
</div>
<label>Email (from PoW):</label>
@@ -610,7 +610,7 @@
<button id="genKeyBtn">🔑 Generate New Keypair</button>
<button id="saveIdentityBtn" disabled>💾 Save Identity</button>
</div>
- <p style="margin: 10px 0 0 0; font-size: 0.9em; opacity: 0.8;">Saving is optional: skip it for a fresh throwaway identity each time, or save the file to reuse the same identicon later and prove later posts are from the same author. Keep the file private, whoever holds it can sign as you.</p>
+ <p style="margin: 10px 0 0 0; font-size: 0.9em; opacity: 0.8;">Saving is optional: skip it for a fresh throwaway identity each time, or save the file to restore the same username, email, keypair, and identicon later. These fields are locked together while the identity is active. Keep the file private, whoever holds it can sign as you.</p>
<input type="file" id="keyFileInput" accept=".json" style="display:none;">
<button id="signMsgBtn" disabled>✍️ Sign Message</button>
@@ -624,45 +624,60 @@
<div id="send" class="tab-content">
<h2>Send Message</h2>
<div class="section-info">
- <p><strong>What is this?</strong> This final step sends your signed message to Usenet via Nym, Mail2News, and the <code>news.tcpreset.net</code> NNTP service.</p>
+ <p><strong>What is this?</strong> This final step sends your signed message through Nym-wrapped SMTP/TLS, the Virebent mail gateway, TCPReset Mail2News, and finally Tor to the TCPReset onion NNTP endpoint.</p>
<div class="gateway-info">
- <h4>🔐 Nym to TCPReset Posting Path</h4>
- <p style="margin-bottom: 15px; font-size: 0.95em;">Your message is submitted over HTTPS, then relayed from this server through Nym to the Mail2News gateway and finally into <code>news.tcpreset.net</code> NNTP:</p>
+ <h4>🔐 HTTPS, Nym, TLS and Onion NNTP Posting Path</h4>
+ <p style="margin-bottom: 15px; font-size: 0.95em;">Your browser submits the signed payload to N2Usenet over HTTPS. From the N2Usenet gateway onward, the route is Nym-wrapped SMTP/TLS, the authenticated Virebent MTA, TCPReset Mail2News, then Tor to the onion NNTP endpoint:</p>
<div class="gateway-item">
<span class="gateway-priority">Step 1</span>
<div style="flex: 1;">
- <strong>Nym SMTP transport</strong>
- <p style="font-size: 0.85em; margin: 5px 0 0 0; color: #666;">Nym SOCKS5/requester transport to the TCPReset Mail2News SMTP gateway.</p>
+ <strong>Nym-wrapped SMTP/TLS</strong>
+ <div class="gateway-address" style="margin-top: 5px;">Nym SOCKS5 → mixnet → dedicated requester</div>
+ <p style="font-size: 0.85em; margin: 5px 0 0 0; color: #666;">Nym carries the SMTP connection to <code>mail.virebent.art:587</code>. Inside that Nym path, STARTTLS provides server-authenticated TLS and SMTP AUTH identifies N2Usenet to the MTA.</p>
</div>
</div>
<details style="margin-top: 8px; font-size: 0.8em;">
- <summary style="cursor: pointer; color: #007bff;">Show relay nodes</summary>
+ <summary style="cursor: pointer; color: #007bff;">Show addresses used</summary>
<ul style="margin: 8px 0; padding-left: 20px; font-family: monospace; color: #555;">
- <li><strong>SMTP:</strong> mail2news.tcpreset.net:587 STARTTLS via Nym SOCKS5</li>
+ <li><strong>HTTPS:</strong> https://n2usenet.virebent.art</li>
+ <li><strong>SMTP/TLS target:</strong> mail.virebent.art:587</li>
+ <li><strong>AUTH and MAIL FROM:</strong> n2usenet@virebent.art</li>
+ <li><strong>Sender:</strong> n2usenet@virebent.art</li>
+ <li><strong>Public From:</strong> username and email from your active identity</li>
+ <li><strong>RCPT TO:</strong> mail2news@mail2news.tcpreset.net</li>
</ul>
</details>
<div class="gateway-item">
<span class="gateway-priority">Step 2</span>
<div style="flex: 1;">
- <strong>Mail2News Gateway</strong>
- <div class="gateway-address" style="margin-top: 5px;">mail2news@mail2news.tcpreset.net</div>
- <p style="font-size: 0.85em; margin: 5px 0 0 0; color: #666;">Converts the signed email payload into a Usenet post for the TCPReset news service.</p>
+ <strong>Authenticated Virebent MTA</strong>
+ <div class="gateway-address" style="margin-top: 5px;">mail.virebent.art:587 → mail2news.tcpreset.net:25</div>
+ <p style="font-size: 0.85em; margin: 5px 0 0 0; color: #666;">Postfix accepts the authenticated submission, keeps the fixed SMTP envelope and transport Sender, adds the Virebent DKIM signature, and relays the message to TCPReset Mail2News. The public From remains the active profile identity.</p>
</div>
</div>
<div class="gateway-item">
<span class="gateway-priority">Step 3</span>
<div style="flex: 1;">
- <strong>TCPReset NNTP</strong>
- <div class="gateway-address" style="margin-top: 5px;">nntp://news.tcpreset.net</div>
- <p style="font-size: 0.85em; margin: 5px 0 0 0; color: #666;">Final posting target for the standalone N2Usenet path.</p>
+ <strong>TCPReset Mail2News Gateway</strong>
+ <div class="gateway-address" style="margin-top: 5px;">mail2news@mail2news.tcpreset.net</div>
+ <p style="font-size: 0.85em; margin: 5px 0 0 0; color: #666;">Mail2News validates and converts the signed email into a Usenet article, strips configured transport metadata, and regenerates the Message-ID for the NNTP post.</p>
+ </div>
+ </div>
+
+ <div class="gateway-item">
+ <span class="gateway-priority">Step 4</span>
+ <div style="flex: 1;">
+ <strong>Tor to TCPReset Onion NNTP</strong>
+ <div class="gateway-address" style="margin-top: 5px;">peannyjkqwqfynd24p6dszvtchkq7hfkwymi5by5y332wmosy5dwfaqd.onion:119</div>
+ <p style="font-size: 0.85em; margin: 5px 0 0 0; color: #666;">Mail2News connects through Tor and issues NNTP <code>POST</code> to the onion endpoint. The TCPReset INN service publishes the article under <code>news.tcpreset.net</code>.</p>
</div>
</div>
<p style="margin-top: 15px; padding: 10px; background: rgba(0,123,255,0.1); border-radius: 4px; font-size: 0.9em;">
- <strong>🔒 Privacy Guarantee:</strong> The browser-to-server hop is HTTPS. The server-to-Mail2News hop uses Nym plus SMTP TLS. Mail2News then posts into <code>news.tcpreset.net</code> NNTP.
+ <strong>🔒 Privacy boundaries:</strong> HTTPS protects the browser-to-N2Usenet hop, but the N2Usenet server still receives the browser connection. Nym carries the N2Usenet-to-MTA session, while SMTP TLS protects that session end to end inside Nym. Mail2News then uses Tor for the final onion NNTP hop. Once accepted by INN, the Usenet article is public.
</p>
</div>
</div>
@@ -689,7 +704,7 @@
</div>
<footer>
- <div>N2Usenet Gateway v2.7.0 © 2025 - Privacy-focused Usenet posting via Nym</div>
+ <div>N2Usenet Gateway v2.7.4 © 2025 - Privacy-focused Usenet posting via Nym</div>
<div class="footer-links">
<a href="https://yamn.virebent.art">Home</a>
<a href="&#109;&#97;&#105;&#108;&#116;&#111;&#58;%69%6E%66%6F%40%76%69%72%65%62%65%6E%74%2E%61%72%74">Contact</a>
@@ -743,8 +758,47 @@ function updateTabIndicators() {
}
let keyPair = null;
+let identitySnapshot = null;
let workersSupported = true;
+function readIdentityFields() {
+ const usernameInput = document.getElementById('fromName');
+ const emailInput = document.getElementById('hcEmail');
+ const username = usernameInput.value.trim();
+ const email = emailInput.value.trim();
+ if (!username) throw new Error('Enter a username before creating the identity.');
+ if (!email || !emailInput.checkValidity()) throw new Error('Enter a valid email address before creating the identity.');
+ return {username, email};
+}
+
+function setIdentityFields(username, email) {
+ document.getElementById('fromName').value = username;
+ document.getElementById('hcEmail').value = email;
+ document.getElementById('readonlyFromName').value = username;
+ document.getElementById('readonlyEmailSign').value = email;
+ document.getElementById('readonlyEmailSend').value = email;
+ document.getElementById('fromFull').value = `${username} <${email}>`;
+}
+
+function lockIdentityFields() {
+ const identity = readIdentityFields();
+ const pubkey = keyPair ? nacl.util.encodeBase64(keyPair.publicKey) : '';
+ identitySnapshot = {username: identity.username, email: identity.email, pubkey};
+ document.getElementById('fromName').readOnly = true;
+ document.getElementById('hcEmail').readOnly = true;
+ setIdentityFields(identity.username, identity.email);
+ return identitySnapshot;
+}
+
+function assertIdentityUnchanged() {
+ if (!identitySnapshot) return;
+ const identity = readIdentityFields();
+ const pubkey = keyPair ? nacl.util.encodeBase64(keyPair.publicKey) : '';
+ if (identity.username !== identitySnapshot.username || identity.email !== identitySnapshot.email || (identitySnapshot.pubkey && pubkey !== identitySnapshot.pubkey)) {
+ throw new Error('The active username, email, and keypair must remain unchanged. Start a new identity to change them.');
+ }
+}
+
function setOutputField(id, value, emptyText) {
const el = document.getElementById(id);
if (!el) return;
@@ -763,8 +817,12 @@ function resetEphemeralState(messageID) {
if (keyPair.publicKey && typeof keyPair.publicKey.fill === 'function') keyPair.publicKey.fill(0);
}
keyPair = null;
+ identitySnapshot = null;
currentIdenticonData = null;
+ document.getElementById('fromName').readOnly = false;
+ document.getElementById('hcEmail').readOnly = false;
+
appState.step1Complete = false;
appState.step2Complete = false;
appState.step3Complete = false;
@@ -853,7 +911,7 @@ function resetEphemeralState(messageID) {
showTab('pow');
if (messageID) {
- showNotification(`✓ Message sent. Message-ID: ${messageID}. Identity and form data cleared from memory.`, 'success', 12000);
+ showNotification(`✓ Message accepted by the mail relay. Final Usenet delivery is asynchronous. Message-ID: ${messageID}. Identity and form data cleared from memory.`, 'success', 16000);
}
}
@@ -964,7 +1022,7 @@ document.addEventListener('DOMContentLoaded', function() {
}
showTab('pow');
- showNotification('N2Usenet Gateway v2.7.0 ready. No identity or message data is persisted.', 'success', 4000);
+ showNotification('N2Usenet Gateway v2.7.4 ready. No identity or message data is persisted.', 'success', 4000);
});
// ============================================================================
@@ -1070,7 +1128,14 @@ function mineSingleThread(prefix, targetZeros, progressCallback, foundCallback)
// ============================================================================
document.getElementById('genTokenBtn').onclick = () => {
- const email = document.getElementById('hcEmail').value.trim();
+ let identity;
+ try {
+ identity = readIdentityFields();
+ } catch (error) {
+ showNotification('⚠ ' + error.message, 'warning');
+ return;
+ }
+ const email = identity.email;
const bits = parseInt(document.getElementById('hcBits').value);
if (!email) {
@@ -1129,11 +1194,12 @@ document.getElementById('genTokenBtn').onclick = () => {
outputField.classList.remove('empty');
document.getElementById('hcToken').value = token;
- const fromName = document.getElementById('fromName').value || 'Anonymous';
+ const fromName = identity.username;
document.getElementById('fromFull').value = `${fromName} <${email}>`;
document.getElementById('readonlyFromName').value = fromName;
document.getElementById('readonlyEmailSign').value = email;
document.getElementById('readonlyEmailSend').value = email;
+ lockIdentityFields();
btn.disabled = false;
btn.textContent = 'Generate Token';
@@ -1209,8 +1275,10 @@ document.getElementById('genTokenBtn').onclick = () => {
document.getElementById('genKeyBtn').onclick = async function() {
try {
+ const identity = readIdentityFields();
showNotification('🔐 Generating Ed25519 key pair...', 'info');
keyPair = nacl.sign.keyPair();
+ lockIdentityFields();
const pubB64 = nacl.util.encodeBase64(keyPair.publicKey);
@@ -1221,8 +1289,8 @@ document.getElementById('genKeyBtn').onclick = async function() {
document.getElementById('signMsgBtn').disabled = false;
document.getElementById('saveIdentityBtn').disabled = false;
- const username = document.getElementById('fromName').value || 'Anonymous';
- const email = document.getElementById('hcEmail').value || '';
+ const username = identity.username;
+ const email = identity.email;
document.getElementById('identityLoadedPow').style.display = 'flex';
document.getElementById('loadedIdentityName').textContent = username;
document.getElementById('loadedIdentityEmail').textContent = email || 'Identity active only in memory';
@@ -1240,15 +1308,17 @@ document.getElementById('saveIdentityBtn').onclick = function() {
showNotification('⚠ Generate or load an identity first!', 'warning');
return;
}
- const username = document.getElementById('fromName').value || 'Anonymous';
- const email = document.getElementById('hcEmail').value || '';
+ assertIdentityUnchanged();
+ const username = identitySnapshot.username;
+ const email = identitySnapshot.email;
const data = {
- version: 2,
+ version: 3,
type: 'n2usenet-identity',
publicKey: nacl.util.encodeBase64(keyPair.publicKey),
secretKey: nacl.util.encodeBase64(keyPair.secretKey),
username: username,
email: email,
+ identityHash: currentIdenticonData ? currentIdenticonData.hash : '',
created: new Date().toISOString()
};
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
@@ -1278,10 +1348,20 @@ document.getElementById('identityFileInput').onchange = async function(e) {
const text = await file.text();
const data = JSON.parse(text);
- if (!data.publicKey || !data.secretKey) {
+ if ((data.version !== 2 && data.version !== 3) || data.type !== 'n2usenet-identity' || !data.publicKey || !data.secretKey || typeof data.username !== 'string' || typeof data.email !== 'string') {
throw new Error('Invalid identity file format');
}
+ const username = data.username.trim();
+ const email = data.email.trim();
+ if (!username || !email) {
+ throw new Error('Identity file is missing username or email');
+ }
+
+ if (appState.step1Complete && document.getElementById('hcEmail').value.trim() !== email) {
+ throw new Error('The loaded identity does not match the generated proof-of-work token');
+ }
+
const pubKey = nacl.util.decodeBase64(data.publicKey);
const secKey = nacl.util.decodeBase64(data.secretKey);
@@ -1289,7 +1369,14 @@ document.getElementById('identityFileInput').onchange = async function(e) {
throw new Error('Invalid key lengths');
}
- keyPair = { publicKey: pubKey, secretKey: secKey };
+ const derivedKeyPair = nacl.sign.keyPair.fromSecretKey(secKey);
+ if (!derivedKeyPair.publicKey.every((value, index) => value === pubKey[index])) {
+ throw new Error('Public and secret keys do not belong to the same identity');
+ }
+
+ keyPair = derivedKeyPair;
+ setIdentityFields(username, email);
+ lockIdentityFields();
const pubB64 = data.publicKey;
const pubOutput = document.getElementById('pubKeyOutput');
@@ -1299,25 +1386,15 @@ document.getElementById('identityFileInput').onchange = async function(e) {
document.getElementById('signMsgBtn').disabled = false;
document.getElementById('saveIdentityBtn').disabled = false;
- if (data.username) {
- document.getElementById('fromName').value = data.username;
- document.getElementById('readonlyFromName').value = data.username;
- }
- if (data.email) {
- document.getElementById('hcEmail').value = data.email;
- const fromName = document.getElementById('fromName').value || 'Anonymous';
- document.getElementById('fromFull').value = `${fromName} <${data.email}>`;
- document.getElementById('readonlyEmailSign').value = data.email;
- document.getElementById('readonlyEmailSend').value = data.email;
- }
-
- const username = data.username || 'Anonymous';
- const email = data.email || '';
document.getElementById('identityLoadedPow').style.display = 'flex';
document.getElementById('loadedIdentityName').textContent = username;
document.getElementById('loadedIdentityEmail').textContent = email;
await updateIdenticonPreview();
+
+ if (data.identityHash && currentIdenticonData && data.identityHash !== currentIdenticonData.hash) {
+ throw new Error('Identity hash does not match username, email, and public key');
+ }
if (currentIdenticonData && currentIdenticonData.dataUrl) {
document.getElementById('identiconPreviewPow').src = currentIdenticonData.dataUrl;
@@ -1364,6 +1441,7 @@ document.getElementById('signMsgBtn').onclick = function() {
showNotification('⚠ Please generate or load a key pair first!', 'warning');
return;
}
+ assertIdentityUnchanged();
const msg = wrapUsenetText(document.getElementById('messageToSign').value.trim());
if (!msg) {
@@ -1416,9 +1494,35 @@ document.getElementById('fromName').addEventListener('input', function() {
// STEP 3: SEND FORM
// ============================================================================
+async function refreshCSRFToken() {
+ const resp = await fetch('/csrf', {
+ method: 'GET',
+ headers: {'Accept': 'application/json'},
+ credentials: 'same-origin',
+ cache: 'no-store'
+ });
+ if (!resp.ok) {
+ throw new Error('Could not refresh the request token.');
+ }
+ const data = await resp.json();
+ if (!data.token) {
+ throw new Error('The server returned an invalid request token.');
+ }
+ document.querySelectorAll('input[name="csrf_token"]').forEach(field => {
+ field.value = data.token;
+ });
+}
+
document.getElementById('sendForm').addEventListener('submit', async function(e) {
e.preventDefault();
+ try {
+ assertIdentityUnchanged();
+ } catch (error) {
+ showNotification('⚠ ' + error.message, 'warning', 8000);
+ return;
+ }
+
const requiredFields = [
{id: 'fromFull', name: 'From'},
{id: 'newsgroups', name: 'Newsgroups'},
@@ -1482,20 +1586,28 @@ document.getElementById('sendForm').addEventListener('submit', async function(e)
sendBtn.parentNode.insertBefore(progressDiv, sendBtn.nextSibling);
try {
- const resp = await fetch('/submit', {
- method: 'POST',
- headers: {
- 'Accept': 'application/json',
- 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8'
- },
- body: new URLSearchParams(new FormData(this)).toString(),
- credentials: 'same-origin',
- cache: 'no-store'
- });
+ let resp;
let data = {};
- try {
- data = await resp.json();
- } catch (err) {}
+ for (let attempt = 0; attempt < 2; attempt++) {
+ await refreshCSRFToken();
+ resp = await fetch('/submit', {
+ method: 'POST',
+ headers: {
+ 'Accept': 'application/json',
+ 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8'
+ },
+ body: new URLSearchParams(new FormData(this)).toString(),
+ credentials: 'same-origin',
+ cache: 'no-store'
+ });
+ data = {};
+ try {
+ data = await resp.json();
+ } catch (err) {}
+ if (resp.ok || data.error !== 'Invalid request token.' || attempt > 0) {
+ break;
+ }
+ }
if (!resp.ok) {
throw new Error(data.error || 'Delivery failed.');
}
diff --git a/internal/config/config.go b/internal/config/config.go
index 5ee60ea..faf2a93 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -3,6 +3,7 @@ package config
import (
"fmt"
"net"
+ "net/mail"
"os"
"strconv"
"strings"
@@ -23,11 +24,16 @@ type SMTPConfig struct {
Port int
Recipient string
EnvelopeFrom string
+ Sender string
+ Username string
+ Password string
+ PasswordFile string
HELO string
TLSServerName string
RequireTLS bool
ImplicitTLS bool
Timeout time.Duration
+ ProbeInterval time.Duration
DryRun bool
}
@@ -55,6 +61,8 @@ type SecurityConfig struct {
MessageIDDomain string
IdenticonsCLI string
RequireFace bool
+ CSRFKeyFile string
+ CSRFKey []byte
}
func Load() (Config, error) {
@@ -66,11 +74,15 @@ func Load() (Config, error) {
Port: envInt("N2U_SMTP_PORT", "M2U_SMTP_PORT", 25),
Recipient: env("N2U_SMTP_RECIPIENT", "M2U_SMTP_RECIPIENT", "mail2news@mail2news.tcpreset.net"),
EnvelopeFrom: env("N2U_SMTP_ENVELOPE_FROM", "M2U_SMTP_ENVELOPE_FROM", "n2usenet@virebent.art"),
+ Sender: envAny("n2usenet@virebent.art", "N2U_SMTP_SENDER", "M2U_SMTP_SENDER", "N2U_SMTP_HEADER_FROM", "M2U_SMTP_HEADER_FROM"),
+ Username: env("N2U_SMTP_USERNAME", "M2U_SMTP_USERNAME", ""),
+ PasswordFile: env("N2U_SMTP_PASSWORD_FILE", "M2U_SMTP_PASSWORD_FILE", ""),
HELO: env("N2U_SMTP_HELO", "M2U_SMTP_HELO", "n2usenet.virebent.art"),
TLSServerName: env("N2U_SMTP_TLS_SERVER_NAME", "M2U_SMTP_TLS_SERVER_NAME", "mail.tcpreset.net"),
RequireTLS: envBool("N2U_SMTP_REQUIRE_TLS", "M2U_SMTP_REQUIRE_TLS", true),
ImplicitTLS: envBool("N2U_SMTP_IMPLICIT_TLS", "M2U_SMTP_IMPLICIT_TLS", false),
Timeout: envDuration("N2U_SMTP_TIMEOUT", "M2U_SMTP_TIMEOUT", 90*time.Second),
+ ProbeInterval: envDuration("N2U_TRANSPORT_PROBE_INTERVAL", "M2U_TRANSPORT_PROBE_INTERVAL", 15*time.Minute),
DryRun: envBool("N2U_DRY_RUN", "M2U_DRY_RUN", false),
},
Nym: NymConfig{
@@ -96,8 +108,23 @@ func Load() (Config, error) {
MessageIDDomain: env("N2U_MESSAGE_ID_DOMAIN", "M2U_MESSAGE_ID_DOMAIN", "n2usenet.virebent.art"),
IdenticonsCLI: env("N2U_IDENTICONS_CLI", "M2U_IDENTICONS_CLI", "/usr/local/bin/identicons-cli"),
RequireFace: envBool("N2U_REQUIRE_FACE", "M2U_REQUIRE_FACE", true),
+ CSRFKeyFile: env("N2U_CSRF_KEY_FILE", "M2U_CSRF_KEY_FILE", ""),
},
}
+ if cfg.SMTP.PasswordFile != "" {
+ password, err := readSecretFile(cfg.SMTP.PasswordFile)
+ if err != nil {
+ return Config{}, fmt.Errorf("read SMTP password file: %w", err)
+ }
+ cfg.SMTP.Password = password
+ }
+ if cfg.Security.CSRFKeyFile != "" {
+ key, err := readSecretFile(cfg.Security.CSRFKeyFile)
+ if err != nil {
+ return Config{}, fmt.Errorf("read CSRF key file: %w", err)
+ }
+ cfg.Security.CSRFKey = []byte(key)
+ }
if err := cfg.Validate(); err != nil {
return Config{}, err
}
@@ -120,9 +147,22 @@ func (c Config) Validate() error {
if c.SMTP.EnvelopeFrom == "" {
return fmt.Errorf("N2U_SMTP_ENVELOPE_FROM is required")
}
+ sender, err := mail.ParseAddress(c.SMTP.Sender)
+ if err != nil || sender.Address == "" {
+ return fmt.Errorf("N2U_SMTP_SENDER must be a valid email address")
+ }
+ if (c.SMTP.Username == "") != (c.SMTP.Password == "") {
+ return fmt.Errorf("N2U_SMTP_USERNAME and N2U_SMTP_PASSWORD_FILE must be configured together")
+ }
+ if c.SMTP.Username != "" && !c.SMTP.RequireTLS && !c.SMTP.ImplicitTLS {
+ return fmt.Errorf("SMTP authentication requires TLS")
+ }
if c.SMTP.RequireTLS && c.SMTP.TLSServerName == "" {
return fmt.Errorf("N2U_SMTP_TLS_SERVER_NAME is required when TLS is required")
}
+ if c.SMTP.ProbeInterval < time.Minute {
+ return fmt.Errorf("N2U_TRANSPORT_PROBE_INTERVAL must be at least 1m")
+ }
if c.Nym.Enabled {
if _, _, err := net.SplitHostPort(c.Nym.SocksAddr); err != nil {
return fmt.Errorf("invalid N2U_NYM_SOCKS: %w", err)
@@ -146,6 +186,9 @@ func (c Config) Validate() error {
if c.Security.RequireFace && c.Security.IdenticonsCLI == "" {
return fmt.Errorf("N2U_IDENTICONS_CLI is required when N2U_REQUIRE_FACE=true")
}
+ if c.Security.CSRFKeyFile != "" && len(c.Security.CSRFKey) < 32 {
+ return fmt.Errorf("N2U_CSRF_KEY_FILE must contain at least 32 bytes")
+ }
return nil
}
@@ -157,6 +200,15 @@ func env(primary, legacy, fallback string) string {
return v
}
+func envAny(fallback string, keys ...string) string {
+ for _, key := range keys {
+ if value := strings.TrimSpace(os.Getenv(key)); value != "" {
+ return value
+ }
+ }
+ return fallback
+}
+
func envBool(primary, legacy string, fallback bool) bool {
v, ok := envValue(primary, legacy)
if !ok {
@@ -207,3 +259,15 @@ func envValue(primary, legacy string) (string, bool) {
}
return "", false
}
+
+func readSecretFile(path string) (string, error) {
+ raw, err := os.ReadFile(path)
+ if err != nil {
+ return "", err
+ }
+ value := strings.TrimSpace(string(raw))
+ if value == "" {
+ return "", fmt.Errorf("file is empty")
+ }
+ return value, nil
+}
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
new file mode 100644
index 0000000..fdb02e4
--- /dev/null
+++ b/internal/config/config_test.go
@@ -0,0 +1,151 @@
+package config
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+func validConfig() Config {
+ return Config{
+ Listen: "127.0.0.1:8095",
+ SMTP: SMTPConfig{
+ Host: "mail.virebent.art",
+ Port: 587,
+ Recipient: "mail2news@mail2news.tcpreset.net",
+ EnvelopeFrom: "n2usenet@virebent.art",
+ Sender: "n2usenet@virebent.art",
+ TLSServerName: "mail.virebent.art",
+ RequireTLS: true,
+ ProbeInterval: 15 * time.Minute,
+ },
+ Security: SecurityConfig{
+ MinHashcashBits: 20,
+ MinMessageBytes: 10,
+ MaxMessageBytes: 65536,
+ MaxNewsgroups: 3,
+ RateLimitCount: 10,
+ RateLimitWindow: time.Hour,
+ IdenticonsCLI: "/usr/local/bin/identicons-cli",
+ RequireFace: true,
+ },
+ }
+}
+
+func TestLoadAcceptsLegacyHeaderFromAsSender(t *testing.T) {
+ t.Setenv("N2U_SMTP_HEADER_FROM", "legacy-sender@virebent.art")
+
+ cfg, err := Load()
+ if err != nil {
+ t.Fatalf("Load returned error: %v", err)
+ }
+ if got, want := cfg.SMTP.Sender, "legacy-sender@virebent.art"; got != want {
+ t.Fatalf("Sender = %q, want %q", got, want)
+ }
+}
+
+func TestLoadPrefersSenderOverLegacyHeaderFrom(t *testing.T) {
+ t.Setenv("N2U_SMTP_SENDER", "sender@virebent.art")
+ t.Setenv("N2U_SMTP_HEADER_FROM", "legacy-sender@virebent.art")
+
+ cfg, err := Load()
+ if err != nil {
+ t.Fatalf("Load returned error: %v", err)
+ }
+ if got, want := cfg.SMTP.Sender, "sender@virebent.art"; got != want {
+ t.Fatalf("Sender = %q, want %q", got, want)
+ }
+}
+
+func TestValidateSMTPAuthentication(t *testing.T) {
+ tests := []struct {
+ name string
+ mutate func(*Config)
+ wantErr string
+ }{
+ {
+ name: "credentials with STARTTLS",
+ mutate: func(cfg *Config) {
+ cfg.SMTP.Username = "n2usenet@virebent.art"
+ cfg.SMTP.Password = "test-password"
+ },
+ },
+ {
+ name: "missing password",
+ mutate: func(cfg *Config) {
+ cfg.SMTP.Username = "n2usenet@virebent.art"
+ },
+ wantErr: "must be configured together",
+ },
+ {
+ name: "missing username",
+ mutate: func(cfg *Config) {
+ cfg.SMTP.Password = "test-password"
+ },
+ wantErr: "must be configured together",
+ },
+ {
+ name: "credentials without TLS",
+ mutate: func(cfg *Config) {
+ cfg.SMTP.Username = "n2usenet@virebent.art"
+ cfg.SMTP.Password = "test-password"
+ cfg.SMTP.RequireTLS = false
+ },
+ wantErr: "requires TLS",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ cfg := validConfig()
+ tt.mutate(&cfg)
+ err := cfg.Validate()
+ if tt.wantErr == "" {
+ if err != nil {
+ t.Fatalf("Validate returned error: %v", err)
+ }
+ return
+ }
+ if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
+ t.Fatalf("Validate error = %v, want substring %q", err, tt.wantErr)
+ }
+ })
+ }
+}
+
+func TestLoadReadsSMTPPasswordFile(t *testing.T) {
+ passwordPath := filepath.Join(t.TempDir(), "smtp-password")
+ if err := os.WriteFile(passwordPath, []byte("test-password\n"), 0600); err != nil {
+ t.Fatalf("write password file: %v", err)
+ }
+ t.Setenv("N2U_SMTP_USERNAME", "n2usenet@virebent.art")
+ t.Setenv("N2U_SMTP_PASSWORD_FILE", passwordPath)
+ t.Setenv("N2U_SMTP_REQUIRE_TLS", "true")
+
+ cfg, err := Load()
+ if err != nil {
+ t.Fatalf("Load returned error: %v", err)
+ }
+ if got, want := cfg.SMTP.Password, "test-password"; got != want {
+ t.Fatalf("Password = %q, want %q", got, want)
+ }
+}
+
+func TestLoadReadsPersistentCSRFKey(t *testing.T) {
+ keyPath := filepath.Join(t.TempDir(), "csrf-key")
+ want := strings.Repeat("k", 32)
+ if err := os.WriteFile(keyPath, []byte(want+"\n"), 0600); err != nil {
+ t.Fatalf("write CSRF key file: %v", err)
+ }
+ t.Setenv("N2U_CSRF_KEY_FILE", keyPath)
+
+ cfg, err := Load()
+ if err != nil {
+ t.Fatalf("Load returned error: %v", err)
+ }
+ if got := string(cfg.Security.CSRFKey); got != want {
+ t.Fatalf("CSRFKey = %q, want %q", got, want)
+ }
+}
diff --git a/internal/smtpclient/client.go b/internal/smtpclient/client.go
index d4ee686..a0abc06 100644
--- a/internal/smtpclient/client.go
+++ b/internal/smtpclient/client.go
@@ -4,11 +4,13 @@ import (
"bufio"
"context"
"crypto/tls"
+ "encoding/base64"
"fmt"
"io"
"net"
"net/textproto"
"strings"
+ "sync"
"time"
)
@@ -19,6 +21,8 @@ type Config struct {
Port int
Recipient string
EnvelopeFrom string
+ Username string
+ Password string
HELO string
TLSServerName string
RequireTLS bool
@@ -30,6 +34,7 @@ type Config struct {
type Client struct {
cfg Config
dial DialContextFunc
+ mu sync.Mutex
}
type Message struct {
@@ -45,22 +50,90 @@ func (c *Client) Send(ctx context.Context, msg Message) error {
if c.cfg.DryRun {
return nil
}
- if c.dial == nil {
- c.dial = (&net.Dialer{}).DialContext
+ c.mu.Lock()
+ defer c.mu.Unlock()
+
+ conn, err := c.connect(ctx)
+ if err != nil {
+ return err
+ }
+ defer conn.close()
+ session := conn.session
+
+ envelopeFrom := sanitizeEnvelope(c.cfg.EnvelopeFrom)
+ if envelopeFrom == "" {
+ envelopeFrom = sanitizeEnvelope(msg.EnvelopeFrom)
+ }
+ if _, _, err := session.cmd(250, "MAIL FROM:<%s>\r\n", envelopeFrom); err != nil {
+ return fmt.Errorf("mail from rejected: %w", err)
+ }
+ if _, _, err := session.cmd(250, "RCPT TO:<%s>\r\n", sanitizeEnvelope(c.cfg.Recipient)); err != nil {
+ return fmt.Errorf("rcpt to rejected: %w", err)
+ }
+ if _, _, err := session.cmd(354, "DATA\r\n"); err != nil {
+ return fmt.Errorf("data rejected: %w", err)
+ }
+ if err := writeSMTPData(session.w, msg.Raw); err != nil {
+ return err
+ }
+ if _, _, err := session.read(250); err != nil {
+ return fmt.Errorf("message rejected: %w", err)
+ }
+ _, _, _ = session.cmd(221, "QUIT\r\n")
+ return nil
+}
+
+// Check verifies the complete SOCKS/SMTP/TLS/AUTH path without issuing a mail
+// transaction or sending message data.
+func (c *Client) Check(ctx context.Context) error {
+ if c.cfg.DryRun {
+ return nil
+ }
+ c.mu.Lock()
+ defer c.mu.Unlock()
+
+ conn, err := c.connect(ctx)
+ if err != nil {
+ return err
+ }
+ defer conn.close()
+ _, _, _ = conn.session.cmd(221, "QUIT\r\n")
+ return nil
+}
+
+type smtpConnection struct {
+ conn net.Conn
+ session *session
+ cancel context.CancelFunc
+}
+
+func (c *smtpConnection) close() {
+ c.cancel()
+ _ = c.conn.Close()
+}
+
+func (c *Client) connect(ctx context.Context) (*smtpConnection, error) {
+ dial := c.dial
+ if dial == nil {
+ dial = (&net.Dialer{}).DialContext
}
timeout := c.cfg.Timeout
if timeout <= 0 {
timeout = 90 * time.Second
}
ctx, cancel := context.WithTimeout(ctx, timeout)
- defer cancel()
addr := net.JoinHostPort(c.cfg.Host, fmt.Sprintf("%d", c.cfg.Port))
- conn, err := c.dial(ctx, "tcp", addr)
+ conn, err := dial(ctx, "tcp", addr)
if err != nil {
- return fmt.Errorf("dial smtp: %w", err)
+ cancel()
+ return nil, fmt.Errorf("dial smtp: %w", err)
+ }
+ fail := func(err error) (*smtpConnection, error) {
+ cancel()
+ _ = conn.Close()
+ return nil, err
}
- defer conn.Close()
if deadline, ok := ctx.Deadline(); ok {
_ = conn.SetDeadline(deadline)
}
@@ -70,58 +143,51 @@ func (c *Client) Send(ctx context.Context, msg Message) error {
MinVersion: tls.VersionTLS12,
})
if err := tlsConn.HandshakeContext(ctx); err != nil {
- return fmt.Errorf("implicit tls handshake: %w", err)
+ return fail(fmt.Errorf("implicit tls handshake: %w", err))
}
conn = tlsConn
}
session := newSession(conn)
if _, _, err := session.read(220); err != nil {
- return fmt.Errorf("smtp greeting: %w", err)
+ return fail(fmt.Errorf("smtp greeting: %w", err))
}
if err := session.ehlo(c.cfg.HELO); err != nil {
- return err
+ return fail(err)
}
if c.cfg.RequireTLS && !c.cfg.ImplicitTLS {
if _, _, err := session.cmd(220, "STARTTLS\r\n"); err != nil {
- return fmt.Errorf("starttls: %w", err)
+ return fail(fmt.Errorf("starttls: %w", err))
}
tlsConn := tls.Client(conn, &tls.Config{
ServerName: c.cfg.TLSServerName,
MinVersion: tls.VersionTLS12,
})
if err := tlsConn.HandshakeContext(ctx); err != nil {
- return fmt.Errorf("tls handshake: %w", err)
+ return fail(fmt.Errorf("tls handshake: %w", err))
}
+ conn = tlsConn
session = newSession(tlsConn)
if err := session.ehlo(c.cfg.HELO); err != nil {
- return err
+ return fail(err)
}
}
- envelopeFrom := sanitizeEnvelope(c.cfg.EnvelopeFrom)
- if envelopeFrom == "" {
- envelopeFrom = sanitizeEnvelope(msg.EnvelopeFrom)
- }
- if _, _, err := session.cmd(250, "MAIL FROM:<%s>\r\n", envelopeFrom); err != nil {
- return fmt.Errorf("mail from rejected: %w", err)
- }
- if _, _, err := session.cmd(250, "RCPT TO:<%s>\r\n", sanitizeEnvelope(c.cfg.Recipient)); err != nil {
- return fmt.Errorf("rcpt to rejected: %w", err)
- }
- if _, _, err := session.cmd(354, "DATA\r\n"); err != nil {
- return fmt.Errorf("data rejected: %w", err)
- }
- if err := writeSMTPData(session.w, msg.Raw); err != nil {
- return err
- }
- if _, _, err := session.read(250); err != nil {
- return fmt.Errorf("message rejected: %w", err)
+ if c.cfg.Username != "" || c.cfg.Password != "" {
+ if c.cfg.Username == "" || c.cfg.Password == "" {
+ return fail(fmt.Errorf("smtp authentication credentials are incomplete"))
+ }
+ if !c.cfg.RequireTLS && !c.cfg.ImplicitTLS {
+ return fail(fmt.Errorf("smtp authentication requires TLS"))
+ }
+ if err := session.authPlain(c.cfg.Username, c.cfg.Password); err != nil {
+ return fail(err)
+ }
}
- _, _, _ = session.cmd(221, "QUIT\r\n")
- return nil
+
+ return &smtpConnection{conn: conn, session: session, cancel: cancel}, nil
}
type session struct {
@@ -151,6 +217,17 @@ func (s *session) ehlo(helo string) error {
return nil
}
+func (s *session) authPlain(username, password string) error {
+ if strings.ContainsRune(username, '\x00') || strings.ContainsRune(password, '\x00') {
+ return fmt.Errorf("smtp authentication credentials contain invalid data")
+ }
+ payload := base64.StdEncoding.EncodeToString([]byte("\x00" + username + "\x00" + password))
+ if _, _, err := s.cmd(235, "AUTH PLAIN %s\r\n", payload); err != nil {
+ return fmt.Errorf("smtp authentication rejected: %w", err)
+ }
+ return nil
+}
+
func (s *session) cmd(expect int, format string, args ...any) (int, string, error) {
if _, err := fmt.Fprintf(s.w, format, args...); err != nil {
return 0, "", err
diff --git a/internal/smtpclient/client_test.go b/internal/smtpclient/client_test.go
index d9eec6d..e91d128 100644
--- a/internal/smtpclient/client_test.go
+++ b/internal/smtpclient/client_test.go
@@ -3,6 +3,7 @@ package smtpclient
import (
"bufio"
"context"
+ "encoding/base64"
"net"
"strings"
"testing"
@@ -91,3 +92,101 @@ func TestSendUsesConfiguredEnvelopeFrom(t *testing.T) {
t.Fatal("configured envelope sender was not used")
}
}
+
+func TestSessionAuthPlain(t *testing.T) {
+ server, client := net.Pipe()
+ defer client.Close()
+
+ command := make(chan string, 1)
+ go func() {
+ defer server.Close()
+ r := bufio.NewReader(server)
+ line, err := r.ReadString('\n')
+ if err != nil {
+ command <- ""
+ return
+ }
+ command <- line
+ _, _ = server.Write([]byte("235 2.7.0 authentication successful\r\n"))
+ }()
+
+ session := newSession(client)
+ if err := session.authPlain("n2usenet@virebent.art", "test-password"); err != nil {
+ t.Fatalf("authPlain returned error: %v", err)
+ }
+
+ line := <-command
+ const prefix = "AUTH PLAIN "
+ if !strings.HasPrefix(line, prefix) {
+ t.Fatalf("unexpected AUTH command: %q", line)
+ }
+ token := strings.TrimSpace(strings.TrimPrefix(line, prefix))
+ decoded, err := base64.StdEncoding.DecodeString(token)
+ if err != nil {
+ t.Fatalf("decode AUTH payload: %v", err)
+ }
+ if got, want := string(decoded), "\x00n2usenet@virebent.art\x00test-password"; got != want {
+ t.Fatalf("unexpected AUTH payload: got %q want %q", got, want)
+ }
+}
+
+func TestSessionAuthPlainRejectsNUL(t *testing.T) {
+ session := &session{}
+ if err := session.authPlain("n2usenet\x00admin", "test-password"); err == nil {
+ t.Fatal("authPlain accepted a username containing NUL")
+ }
+ if err := session.authPlain("n2usenet", "test\x00password"); err == nil {
+ t.Fatal("authPlain accepted a password containing NUL")
+ }
+}
+
+func TestCheckDoesNotStartMailTransaction(t *testing.T) {
+ server, client := net.Pipe()
+ defer client.Close()
+
+ commands := make(chan string, 8)
+ go func() {
+ defer close(commands)
+ defer server.Close()
+ r := bufio.NewReader(server)
+ w := bufio.NewWriter(server)
+ _, _ = w.WriteString("220 test\r\n")
+ _ = w.Flush()
+ for {
+ line, err := r.ReadString('\n')
+ if err != nil {
+ return
+ }
+ commands <- line
+ switch {
+ case strings.HasPrefix(line, "EHLO "):
+ _, _ = w.WriteString("250 test\r\n")
+ case strings.HasPrefix(line, "QUIT"):
+ _, _ = w.WriteString("221 bye\r\n")
+ _ = w.Flush()
+ return
+ default:
+ _, _ = w.WriteString("500 unexpected\r\n")
+ }
+ _ = w.Flush()
+ }
+ }()
+
+ mailer := New(Config{
+ Host: "mail.virebent.art",
+ Port: 25,
+ HELO: "n2usenet.virebent.art",
+ RequireTLS: false,
+ Timeout: 5 * time.Second,
+ }, func(context.Context, string, string) (net.Conn, error) {
+ return client, nil
+ })
+ if err := mailer.Check(context.Background()); err != nil {
+ t.Fatalf("Check returned error: %v", err)
+ }
+ for command := range commands {
+ if strings.HasPrefix(command, "MAIL ") || strings.HasPrefix(command, "RCPT ") || strings.HasPrefix(command, "DATA") {
+ t.Fatalf("health check started a mail transaction: %q", command)
+ }
+ }
+}
diff --git a/internal/submit/message.go b/internal/submit/message.go
index 3bf8834..db80503 100644
--- a/internal/submit/message.go
+++ b/internal/submit/message.go
@@ -17,14 +17,24 @@ import (
var messageIDRE = regexp.MustCompile(`^<[^<>\s]+@[^<>\s]+>$`)
-func BuildMessage(sub Submission, recipient, messageIDDomain, identiconsCLI string, requireFace bool) (raw string, messageID string, err error) {
+func BuildMessage(sub Submission, recipient, sender, messageIDDomain, identiconsCLI string, requireFace bool) (raw string, messageID string, err error) {
messageID, err = newMessageID(messageIDDomain)
if err != nil {
return "", "", err
}
+ identity, err := mail.ParseAddress(sub.From)
+ if err != nil {
+ return "", "", fmt.Errorf("invalid identity address")
+ }
+ transportSender, err := mail.ParseAddress(sender)
+ if err != nil || transportSender.Address == "" {
+ return "", "", fmt.Errorf("invalid transport Sender address")
+ }
+
headers := []string{
- "From: " + sub.From,
+ "From: " + identity.String(),
+ "Sender: " + transportSender.String(),
"To: " + recipient,
"Subject: " + sub.Subject,
"Message-ID: " + messageID,
diff --git a/internal/submit/message_test.go b/internal/submit/message_test.go
new file mode 100644
index 0000000..c13e958
--- /dev/null
+++ b/internal/submit/message_test.go
@@ -0,0 +1,41 @@
+package submit
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestBuildMessagePublishesProfileIdentityAndTransportSender(t *testing.T) {
+ sub := Submission{
+ From: "Alice <alice@example.invalid>",
+ Newsgroups: []string{"misc.test"},
+ Subject: "Test",
+ Message: "hello",
+ PublicKeyB64: "unused-without-face",
+ SignatureB64: "unused-without-face",
+ }
+
+ raw, _, err := BuildMessage(
+ sub,
+ "mail2news@mail2news.tcpreset.net",
+ "n2usenet@virebent.art",
+ "n2usenet.virebent.art",
+ "/missing/identicons-cli",
+ false,
+ )
+ if err != nil {
+ t.Fatalf("BuildMessage returned error: %v", err)
+ }
+ if !strings.Contains(raw, "From: \"Alice\" <alice@example.invalid>\r\n") {
+ t.Fatalf("profile identity is not used as the public From:\n%s", raw)
+ }
+ if !strings.Contains(raw, "Sender: <n2usenet@virebent.art>\r\n") {
+ t.Fatalf("transport Sender is missing:\n%s", raw)
+ }
+ if strings.Contains(raw, "From: \"Alice\" <n2usenet@virebent.art>\r\n") {
+ t.Fatalf("transport account replaced the public profile identity:\n%s", raw)
+ }
+ if strings.Contains(raw, "X-N2Usenet-Identity:") {
+ t.Fatalf("redundant private identity header is present:\n%s", raw)
+ }
+}
diff --git a/internal/submit/types.go b/internal/submit/types.go
index 52f57b4..66727b2 100644
--- a/internal/submit/types.go
+++ b/internal/submit/types.go
@@ -10,6 +10,7 @@ import (
"fmt"
"html/template"
"io/fs"
+ "log"
"mime"
"net"
"net/http"
@@ -27,12 +28,19 @@ type Mailer interface {
Send(ctx context.Context, msg smtpclient.Message) error
}
+type TransportState interface {
+ Ready() bool
+ MarkSuccess()
+ MarkFailure(error)
+}
+
type App struct {
cfg config.Config
mailer Mailer
replay *storage.ReplayCache
staticFS fs.FS
indexTmpl *template.Template
+ transport TransportState
csrfKey []byte
rateKey []byte
rateMu sync.Mutex
@@ -51,11 +59,16 @@ type IndexData struct {
PublicBaseURL string
}
-func NewApp(cfg config.Config, mailer Mailer, replay *storage.ReplayCache, staticFS fs.FS, indexTemplate string) (*App, error) {
- csrfKey := make([]byte, 32)
+func NewApp(cfg config.Config, mailer Mailer, transport TransportState, replay *storage.ReplayCache, staticFS fs.FS, indexTemplate string) (*App, error) {
+ csrfKey := append([]byte(nil), cfg.Security.CSRFKey...)
+ if len(csrfKey) == 0 {
+ csrfKey = make([]byte, 32)
+ }
rateKey := make([]byte, 32)
- if _, err := rand.Read(csrfKey); err != nil {
- return nil, fmt.Errorf("csrf key: %w", err)
+ if len(cfg.Security.CSRFKey) == 0 {
+ if _, err := rand.Read(csrfKey); err != nil {
+ return nil, fmt.Errorf("csrf key: %w", err)
+ }
}
if _, err := rand.Read(rateKey); err != nil {
return nil, fmt.Errorf("rate key: %w", err)
@@ -65,7 +78,7 @@ func NewApp(cfg config.Config, mailer Mailer, replay *storage.ReplayCache, stati
return nil, fmt.Errorf("parse index template: %w", err)
}
return &App{
- cfg: cfg, mailer: mailer, replay: replay, staticFS: staticFS,
+ cfg: cfg, mailer: mailer, transport: transport, replay: replay, staticFS: staticFS,
indexTmpl: tmpl, csrfKey: csrfKey, rateKey: rateKey,
rates: map[string]rateBucket{}, locks: map[string]struct{}{},
}, nil
@@ -74,7 +87,9 @@ func NewApp(cfg config.Config, mailer Mailer, replay *storage.ReplayCache, stati
func (a *App) Routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /", a.handleIndex)
+ mux.HandleFunc("GET /csrf", a.handleCSRF)
mux.HandleFunc("GET /healthz", a.handleHealth)
+ mux.HandleFunc("GET /readyz", a.handleReadiness)
mux.HandleFunc("GET /favicon.ico", a.handleFavicon)
mux.HandleFunc("POST /identity/face", a.handleFace)
mux.HandleFunc("POST /identicon.php", a.handleIdenticonCompat)
@@ -145,19 +160,49 @@ func (a *App) handleIdenticonCompat(w http.ResponseWriter, r *http.Request) {
}
func (a *App) handleIndex(w http.ResponseWriter, r *http.Request) {
+ token := a.issueCSRFToken(w, r)
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.Header().Set("Cache-Control", "no-store")
+ _ = a.indexTmpl.Execute(w, IndexData{CSRFToken: token, PublicBaseURL: a.cfg.PublicBaseURL})
+}
+
+func (a *App) handleCSRF(w http.ResponseWriter, r *http.Request) {
+ token := a.issueCSRFToken(w, r)
+ setNoStoreHeaders(w)
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]string{"token": token})
+}
+
+func (a *App) issueCSRFToken(w http.ResponseWriter, r *http.Request) string {
+ if token, ok := a.csrfTokenFromCookie(r); ok {
+ return token
+ }
token := randomHex(32)
http.SetCookie(w, &http.Cookie{
Name: "m2u_csrf",
Value: a.signCSRF(token),
Path: "/",
- MaxAge: 7200,
Secure: a.cfg.Security.SecureCookies,
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
})
- w.Header().Set("Content-Type", "text/html; charset=utf-8")
- w.Header().Set("Cache-Control", "no-store")
- _ = a.indexTmpl.Execute(w, IndexData{CSRFToken: token, PublicBaseURL: a.cfg.PublicBaseURL})
+ return token
+}
+
+func (a *App) csrfTokenFromCookie(r *http.Request) (string, bool) {
+ cookie, err := r.Cookie("m2u_csrf")
+ if err != nil {
+ return "", false
+ }
+ dot := strings.LastIndexByte(cookie.Value, '.')
+ if dot <= 0 {
+ return "", false
+ }
+ token := cookie.Value[:dot]
+ if !hmac.Equal([]byte(cookie.Value), []byte(a.signCSRF(token))) {
+ return "", false
+ }
+ return token, true
}
func (a *App) handleHealth(w http.ResponseWriter, r *http.Request) {
@@ -166,6 +211,18 @@ func (a *App) handleHealth(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("ok\n"))
}
+func (a *App) handleReadiness(w http.ResponseWriter, _ *http.Request) {
+ setNoStoreHeaders(w)
+ w.Header().Set("Content-Type", "text/plain; charset=utf-8")
+ if a.transport == nil || !a.transport.Ready() {
+ w.WriteHeader(http.StatusServiceUnavailable)
+ _, _ = w.Write([]byte("transport unavailable\n"))
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte("ready\n"))
+}
+
func (a *App) handleSubmit(w http.ResponseWriter, r *http.Request) {
if !a.allowRate(r) {
a.errorResponse(w, r, http.StatusTooManyRequests, "Too many requests. Try again later.")
@@ -204,15 +261,22 @@ func (a *App) handleSubmit(w http.ResponseWriter, r *http.Request) {
return
}
- raw, messageID, err := BuildMessage(sub, a.cfg.SMTP.Recipient, a.cfg.Security.MessageIDDomain, a.cfg.Security.IdenticonsCLI, a.cfg.Security.RequireFace)
+ raw, messageID, err := BuildMessage(sub, a.cfg.SMTP.Recipient, a.cfg.SMTP.Sender, a.cfg.Security.MessageIDDomain, a.cfg.Security.IdenticonsCLI, a.cfg.Security.RequireFace)
if err != nil {
a.errorResponse(w, r, http.StatusInternalServerError, "Message build failed.")
return
}
if err := a.mailer.Send(r.Context(), smtpclient.Message{EnvelopeFrom: sub.FromAddress, Raw: raw}); err != nil {
+ if a.transport != nil {
+ a.transport.MarkFailure(err)
+ }
+ log.Printf("submission delivery failed message_id=%s: %v", messageID, err)
a.errorResponse(w, r, http.StatusBadGateway, "Delivery failed.")
return
}
+ if a.transport != nil {
+ a.transport.MarkSuccess()
+ }
a.successResponse(w, r, messageID)
}
@@ -321,7 +385,10 @@ func (a *App) successResponse(w http.ResponseWriter, r *http.Request, messageID
if wantsJSON(r) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
- _ = json.NewEncoder(w).Encode(map[string]string{"messageId": messageID})
+ _ = json.NewEncoder(w).Encode(map[string]string{
+ "messageId": messageID,
+ "status": "accepted",
+ })
return
}
a.successHTML(w, messageID)
@@ -330,7 +397,7 @@ func (a *App) successResponse(w http.ResponseWriter, r *http.Request, messageID
func (a *App) successHTML(w http.ResponseWriter, messageID string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
- _, _ = fmt.Fprintf(w, "<!doctype html><meta charset=utf-8><title>Message sent</title><main><h1>Message sent</h1><p><strong>Message-ID:</strong></p><pre>%s</pre><p><a href=\"/\">Send another</a></p></main>", template.HTMLEscapeString(messageID))
+ _, _ = fmt.Fprintf(w, "<!doctype html><meta charset=utf-8><title>Message accepted</title><main><h1>Message accepted by the mail relay</h1><p>Final Usenet delivery is asynchronous.</p><p><strong>Message-ID:</strong></p><pre>%s</pre><p><a href=\"/\">Send another</a></p></main>", template.HTMLEscapeString(messageID))
}
func randomHex(n int) string {
diff --git a/internal/submit/types_test.go b/internal/submit/types_test.go
index a2a7c1a..16a2b84 100644
--- a/internal/submit/types_test.go
+++ b/internal/submit/types_test.go
@@ -4,6 +4,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
+ "net/url"
"strings"
"testing"
"testing/fstest"
@@ -15,13 +16,135 @@ import (
func newTestApp(t *testing.T) *App {
t.Helper()
- app, err := NewApp(config.Config{}, nil, storage.NewReplayCache(time.Minute), fstest.MapFS{}, "<!doctype html>")
+ app, err := NewApp(config.Config{}, nil, nil, storage.NewReplayCache(time.Minute), fstest.MapFS{}, "<!doctype html>")
if err != nil {
t.Fatalf("NewApp returned error: %v", err)
}
return app
}
+func TestCSRFCookieHasNoElapsedTimeLimit(t *testing.T) {
+ app := newTestApp(t)
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ rec := httptest.NewRecorder()
+
+ app.handleIndex(rec, req)
+
+ cookies := rec.Result().Cookies()
+ if len(cookies) != 1 {
+ t.Fatalf("unexpected cookie count: got %d want 1", len(cookies))
+ }
+ cookie := cookies[0]
+ if cookie.Name != "m2u_csrf" {
+ t.Fatalf("unexpected cookie name: %q", cookie.Name)
+ }
+ if cookie.MaxAge != 0 || !cookie.Expires.IsZero() {
+ t.Fatalf("CSRF cookie has an elapsed-time expiry: MaxAge=%d Expires=%v", cookie.MaxAge, cookie.Expires)
+ }
+ if !cookie.HttpOnly || cookie.SameSite != http.SameSiteStrictMode {
+ t.Fatal("CSRF cookie security attributes were weakened")
+ }
+}
+
+func TestCSRFRefreshIssuesValidToken(t *testing.T) {
+ app := newTestApp(t)
+ req := httptest.NewRequest(http.MethodGet, "/csrf", nil)
+ rec := httptest.NewRecorder()
+
+ app.handleCSRF(rec, req)
+
+ if got := rec.Header().Get("Cache-Control"); got != "no-store" {
+ t.Fatalf("unexpected Cache-Control: %q", got)
+ }
+ var body map[string]string
+ if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
+ t.Fatalf("decode CSRF response: %v", err)
+ }
+ token := body["token"]
+ if token == "" {
+ t.Fatal("CSRF refresh returned an empty token")
+ }
+ cookies := rec.Result().Cookies()
+ if len(cookies) != 1 {
+ t.Fatalf("unexpected cookie count: got %d want 1", len(cookies))
+ }
+
+ form := url.Values{"csrf_token": {token}}
+ post := httptest.NewRequest(http.MethodPost, "/submit", strings.NewReader(form.Encode()))
+ post.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ post.AddCookie(cookies[0])
+ if err := post.ParseForm(); err != nil {
+ t.Fatalf("parse form: %v", err)
+ }
+ if !app.verifyCSRF(post) {
+ t.Fatal("refreshed CSRF token did not verify")
+ }
+}
+
+func TestCSRFCookieSurvivesRestartWithPersistentKey(t *testing.T) {
+ cfg := config.Config{Security: config.SecurityConfig{CSRFKey: []byte(strings.Repeat("k", 32))}}
+ newApp := func() *App {
+ app, err := NewApp(cfg, nil, nil, storage.NewReplayCache(time.Minute), fstest.MapFS{}, "<!doctype html>")
+ if err != nil {
+ t.Fatalf("NewApp returned error: %v", err)
+ }
+ return app
+ }
+
+ first := httptest.NewRecorder()
+ newApp().handleCSRF(first, httptest.NewRequest(http.MethodGet, "/csrf", nil))
+ var firstBody map[string]string
+ if err := json.Unmarshal(first.Body.Bytes(), &firstBody); err != nil {
+ t.Fatalf("decode first CSRF response: %v", err)
+ }
+ cookies := first.Result().Cookies()
+ if len(cookies) != 1 {
+ t.Fatalf("unexpected cookie count: %d", len(cookies))
+ }
+
+ requestAfterRestart := httptest.NewRequest(http.MethodGet, "/csrf", nil)
+ requestAfterRestart.AddCookie(cookies[0])
+ second := httptest.NewRecorder()
+ newApp().handleCSRF(second, requestAfterRestart)
+ var secondBody map[string]string
+ if err := json.Unmarshal(second.Body.Bytes(), &secondBody); err != nil {
+ t.Fatalf("decode second CSRF response: %v", err)
+ }
+ if secondBody["token"] != firstBody["token"] {
+ t.Fatal("restart rotated a still-valid session token")
+ }
+ if got := len(second.Result().Cookies()); got != 0 {
+ t.Fatalf("valid session unexpectedly rotated %d cookies", got)
+ }
+}
+
+type transportStateStub struct {
+ ready bool
+}
+
+func (s *transportStateStub) Ready() bool { return s.ready }
+func (s *transportStateStub) MarkSuccess() { s.ready = true }
+func (s *transportStateStub) MarkFailure(error) { s.ready = false }
+
+func TestReadinessUsesCachedTransportState(t *testing.T) {
+ app := newTestApp(t)
+ state := &transportStateStub{}
+ app.transport = state
+
+ recorder := httptest.NewRecorder()
+ app.handleReadiness(recorder, httptest.NewRequest(http.MethodGet, "/readyz", nil))
+ if recorder.Code != http.StatusServiceUnavailable {
+ t.Fatalf("unready status = %d, want %d", recorder.Code, http.StatusServiceUnavailable)
+ }
+
+ state.ready = true
+ recorder = httptest.NewRecorder()
+ app.handleReadiness(recorder, httptest.NewRequest(http.MethodGet, "/readyz", nil))
+ if recorder.Code != http.StatusOK {
+ t.Fatalf("ready status = %d, want %d", recorder.Code, http.StatusOK)
+ }
+}
+
func TestSuccessResponseJSONNoStore(t *testing.T) {
app := newTestApp(t)
req := httptest.NewRequest(http.MethodPost, "/submit", nil)
diff --git a/internal/submit/validation_test.go b/internal/submit/validation_test.go
index 630b647..d1284f1 100644
--- a/internal/submit/validation_test.go
+++ b/internal/submit/validation_test.go
@@ -88,7 +88,7 @@ func TestBuildMessageRequiresFaceWhenConfigured(t *testing.T) {
PublicKeyB64: base64.StdEncoding.EncodeToString(make([]byte, ed25519.PublicKeySize)),
SignatureB64: base64.StdEncoding.EncodeToString(make([]byte, ed25519.SignatureSize)),
}
- _, _, err := BuildMessage(sub, "mail2news@mail2news.tcpreset.net", "example.net", "/missing/identicons-cli", true)
+ _, _, err := BuildMessage(sub, "mail2news@mail2news.tcpreset.net", "n2usenet@virebent.art", "example.net", "/missing/identicons-cli", true)
if err == nil {
t.Fatal("expected missing Face generator to reject message")
}
diff --git a/internal/transporthealth/monitor.go b/internal/transporthealth/monitor.go
new file mode 100644
index 0000000..48369ee
--- /dev/null
+++ b/internal/transporthealth/monitor.go
@@ -0,0 +1,83 @@
+package transporthealth
+
+import (
+ "context"
+ "log"
+ "sync"
+ "time"
+)
+
+type Checker interface {
+ Check(context.Context) error
+}
+
+type Monitor struct {
+ checker Checker
+ interval time.Duration
+ timeout time.Duration
+
+ mu sync.RWMutex
+ ready bool
+ checkedAt time.Time
+}
+
+func New(checker Checker, interval, timeout time.Duration) *Monitor {
+ if interval < time.Minute {
+ interval = 15 * time.Minute
+ }
+ if timeout <= 0 {
+ timeout = 90 * time.Second
+ }
+ return &Monitor{checker: checker, interval: interval, timeout: timeout}
+}
+
+func (m *Monitor) Run(ctx context.Context) {
+ m.Probe(ctx)
+ ticker := time.NewTicker(m.interval)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-ticker.C:
+ m.Probe(ctx)
+ }
+ }
+}
+
+func (m *Monitor) Probe(ctx context.Context) {
+ probeCtx, cancel := context.WithTimeout(ctx, m.timeout)
+ defer cancel()
+ if err := m.checker.Check(probeCtx); err != nil {
+ m.MarkFailure(err)
+ log.Printf("transport probe failed: %v", err)
+ return
+ }
+ m.MarkSuccess()
+ log.Printf("transport probe succeeded")
+}
+
+func (m *Monitor) Ready() bool {
+ m.mu.RLock()
+ ready := m.ready
+ checkedAt := m.checkedAt
+ m.mu.RUnlock()
+ if !ready || checkedAt.IsZero() {
+ return false
+ }
+ return time.Since(checkedAt) <= 2*m.interval+m.timeout
+}
+
+func (m *Monitor) MarkSuccess() {
+ m.mu.Lock()
+ m.ready = true
+ m.checkedAt = time.Now()
+ m.mu.Unlock()
+}
+
+func (m *Monitor) MarkFailure(error) {
+ m.mu.Lock()
+ m.ready = false
+ m.checkedAt = time.Now()
+ m.mu.Unlock()
+}
diff --git a/internal/transporthealth/monitor_test.go b/internal/transporthealth/monitor_test.go
new file mode 100644
index 0000000..c5eb98d
--- /dev/null
+++ b/internal/transporthealth/monitor_test.go
@@ -0,0 +1,30 @@
+package transporthealth
+
+import (
+ "context"
+ "errors"
+ "testing"
+ "time"
+)
+
+type checkerFunc func(context.Context) error
+
+func (f checkerFunc) Check(ctx context.Context) error {
+ return f(ctx)
+}
+
+func TestProbeTracksReadiness(t *testing.T) {
+ var checkErr error
+ monitor := New(checkerFunc(func(context.Context) error { return checkErr }), time.Minute, time.Second)
+
+ monitor.Probe(context.Background())
+ if !monitor.Ready() {
+ t.Fatal("successful probe did not mark transport ready")
+ }
+
+ checkErr = errors.New("transport unavailable")
+ monitor.Probe(context.Background())
+ if monitor.Ready() {
+ t.Fatal("failed probe left transport ready")
+ }
+}