v0.22.0 several enhancements and fixes for security, network, doc, test, packaging
Test / test (push) Canceled after 0s
Test / e2e (push) Canceled after 0s

This commit is contained in:
lixen committed 2026-10-03 14:52:16 -04:00
1 parent 9ab8c356d2
commit 71d1b82f83
102 files changed
+7102 -2178

No files matched your search

+18 -14
View File
@@ -34,9 +34,10 @@ type FileSink struct {
config *config.FileSinkOptions
// Application
input chan core.TransportEvent
writer *log.Logger // internal logger for file writing
logger *log.Logger // application logger
input chan core.TransportEvent
writer *log.Logger // internal logger for file writing
writerConfig *log.Config
logger *log.Logger // application logger
// Runtime
done chan struct{}
@@ -117,20 +118,21 @@ func NewFileSinkPlugin(
writerConfig.ShowLevel = false
writerConfig.Format = "raw"
// Create internal logger for file writing
writer := log.NewLogger()
if err := writer.ApplyConfig(writerConfig); err != nil {
// Validated here, applied in Start: applying creates the directory and
// file, which lw --check and a rejected reload must not do
if err := writerConfig.Validate(); err != nil {
return nil, fmt.Errorf("failed to initialize file writer: %w", err)
}
fs := &FileSink{
id: id,
proxy: proxy,
config: opts,
input: make(chan core.TransportEvent, opts.BufferSize),
writer: writer,
done: make(chan struct{}),
logger: logger,
id: id,
proxy: proxy,
config: opts,
input: make(chan core.TransportEvent, opts.BufferSize),
writer: log.NewLogger(),
writerConfig: writerConfig,
done: make(chan struct{}),
logger: logger,
}
fs.lastProcessed.Store(time.Time{})
@@ -168,7 +170,9 @@ func (fs *FileSink) Input() chan<- core.TransportEvent {
// Start begins the processing loop for the sink
func (fs *FileSink) Start(ctx context.Context) error {
// Start the internal file writer
if err := fs.writer.ApplyConfig(fs.writerConfig); err != nil {
return fmt.Errorf("failed to initialize file writer: %w", err)
}
if err := fs.writer.Start(); err != nil {
return fmt.Errorf("failed to start file writer: %w", err)
}
+9 -4
View File
@@ -55,8 +55,9 @@ type HTTPSink struct {
proxy *session.Proxy
// Configuration
config *config.HTTPSinkOptions
addr string
config *config.HTTPSinkOptions
addr string
network string
// Network
server *http.Server
@@ -116,6 +117,10 @@ func NewHTTPSinkPlugin(
if err := lconfig.Port(opts.Port); err != nil {
return nil, fmt.Errorf("port: %w", err)
}
network, err := core.Network(opts.Host)
if err != nil {
return nil, fmt.Errorf("host: %w", err)
}
if opts.StreamPath == "" {
opts.StreamPath = DefaultHTTPStreamPath
} else if !strings.HasPrefix(opts.StreamPath, "/") {
@@ -166,6 +171,7 @@ func NewHTTPSinkPlugin(
proxy: proxy,
config: opts,
addr: net.JoinHostPort(opts.Host, strconv.FormatInt(opts.Port, 10)),
network: network,
input: make(chan core.TransportEvent, opts.BufferSize),
done: make(chan struct{}),
logger: logger,
@@ -217,10 +223,9 @@ func (h *HTTPSink) Input() chan<- core.TransportEvent {
// Start binds the listener and serves stream/status endpoints
func (h *HTTPSink) Start(ctx context.Context) error {
// IPv4-only, parity with existing network sinks.
// TLS is applied via server.TLSConfig + ServeTLS below, not by wrapping
// ln; net/http then owns handshake, ALPN (h2), and per-conn errors.
ln, err := net.Listen("tcp4", h.addr)
ln, err := core.Listen(ctx, &net.ListenConfig{}, h.network, h.addr)
if err != nil {
return fmt.Errorf("http sink bind %s: %w", h.addr, err)
}
+17 -2
View File
@@ -1,4 +1,4 @@
import { login } from "./scram.js";
import { cookiesUsable, login, loginUnavailable } from "./scram.js";
const base = new URL("..", location.href);
const form = document.getElementById("login");
@@ -44,5 +44,20 @@ form.addEventListener("submit", async (event) => {
}
});
// A sign-in here only sets a cookie; the viewer, when the sink serves it,
// signs in by itself and keeps a token in memory instead
async function cookiesUnavailable() {
status.textContent = "This browser keeps no cookie for this site, so a sign-in here would not last.";
const viewer = await fetch("view", { method: "HEAD", cache: "no-store", redirect: "error" }).catch(() => null);
if (!viewer?.ok) return;
const link = document.createElement("a");
link.href = "view";
link.textContent = "Sign in on the viewer instead.";
status.append(" ", link);
}
// Enabled only once this script runs, so a failed load cannot submit the form natively
button.disabled = false;
const unavailable = loginUnavailable();
if (unavailable) status.textContent = unavailable;
else if (cookiesUsable()) button.disabled = false;
else cookiesUnavailable();
+122 -14
View File
@@ -16,11 +16,14 @@ const encoder = new TextEncoder();
/**
* Logs in to the logwisp http sink mounted at base (a URL ending in "/"), per
* the wire protocol in logwisp's doc/scram-auth-plan.md. The session arrives as
* an HttpOnly cookie; the promise resolves only once the server has proved it
* holds this user's verifier. onProgress(fraction) follows the Argon2 pass.
* logwisp's doc/scram-auth-plan.md; it resolves only once the server has proved
* it holds this user's verifier. The session is an HttpOnly cookie, or with
* session "token" the returned token. onProgress(fraction) follows Argon2.
*/
export async function login(base, username, password, { onProgress } = {}) {
export async function login(base, username, password, { onProgress, session = "cookie" } = {}) {
if (session !== "cookie" && session !== "token") {
throw new Error('session must be "cookie" or "token"');
}
const pw = checkCredentials(username, password);
subtle(); // an insecure page fails before anything is sent
const url = new URL("auth", base);
@@ -29,7 +32,8 @@ export async function login(base, username, password, { onProgress } = {}) {
const first = await post(url, { logwisp: 1, scram: { username, client_nonce: clientNonce } });
const { proof, serverSignature } = await prove(username, pw, clientNonce, first.challenge,
MIN_TIME, MIN_MEMORY, onProgress);
const step = await post(url, { proof, session: "cookie" });
// Without "session" the server answers with the token in the body
const step = await post(url, session === "cookie" ? { proof, session } : { proof });
const final = step.final;
if (!final || typeof final !== "object") {
throw new Error("server sent no final message");
@@ -40,15 +44,19 @@ export async function login(base, username, password, { onProgress } = {}) {
throw new Error("server could not prove it holds this user's verifier");
}
const expiresIn = Number.isSafeInteger(step.expires_in) && step.expires_in > 0 ? step.expires_in : 0;
return { username, expiresIn };
if (session === "cookie") return { username, expiresIn };
if (typeof step.token !== "string" || step.token === "") {
throw new Error("server issued no token");
}
return { username, expiresIn, token: step.token };
}
// Logout posts to auth itself: a clearing cookie from any other path would
// get another default path and miss the session cookie
export async function logout(base) {
// get another default path and miss the session cookie. A token is revoked.
export async function logout(base, { token } = {}) {
const res = await send(new URL("auth", base), {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: { "Content-Type": "application/json", ...bearer(token) },
body: JSON.stringify({ logout: true }),
});
if (res.status !== 200 && res.status !== 204) {
@@ -56,6 +64,101 @@ export async function logout(base) {
}
}
// Why no login can run on this page, or "": an insecure page refuses the probe's
// Secure cookie too, so a page asks this before cookiesUsable().
export function loginUnavailable() {
return globalThis.crypto?.subtle ? "" : "WebCrypto unavailable: the page must be served over HTTPS";
}
// Whether the session cookie would stick. Chromium blocking every cookie still
// reports navigator.cookieEnabled, so a cookie with the session's attributes
// must also come back.
export function cookiesUsable() {
if (!globalThis.navigator?.cookieEnabled) return false;
document.cookie = "logwisp_probe=1; Secure; SameSite=Strict";
const usable = document.cookie.split("; ").includes("logwisp_probe=1");
document.cookie = "logwisp_probe=; Max-Age=0; Secure; SameSite=Strict";
return usable;
}
/**
* Reads an event stream through fetch, which can send the bearer EventSource
* cannot. onEvent({type, data, lastEventId}) gets each event EventSource would
* dispatch. Resolves to {lastEventId, retry} when the server ends the stream;
* rejects with err.status on an answer other than 200, or on signal's abort.
*/
export async function stream(url, { token, signal, onEvent } = {}) {
const res = await send(new URL(url, globalThis.location?.href), {
headers: { Accept: "text/event-stream", ...bearer(token) },
signal,
});
const type = res.headers.get("Content-Type")?.split(";")[0].trim().toLowerCase();
if (res.status !== 200 || type !== "text/event-stream") {
res.body?.cancel().catch(() => {});
const err = new Error(res.status !== 200 ? `stream refused: ${res.status}` : "not an event stream");
err.status = res.status;
throw err;
}
const parser = eventParser(onEvent);
const reader = res.body.getReader();
const decoder = new TextDecoder(); // UTF-8, dropping a leading BOM as the spec asks
try {
for (let r = await reader.read(); !r.done; r = await reader.read()) {
parser.feed(decoder.decode(r.value, { stream: true }));
}
} finally {
reader.cancel().catch(() => {});
}
return parser.result();
}
// The event stream interpretation of the HTML standard; a CR that ends one
// chunk may be the first half of a CRLF the next chunk completes.
function eventParser(onEvent) {
let line = "", afterCR = false, data = "", type = "", idBuffer = "", lastEventId = "", retry = null;
const field = (name, value) => {
switch (name) {
case "event": type = value; break;
case "data": data += value + "\n"; break;
case "id": if (!value.includes("\0")) idBuffer = value; break;
case "retry": if (/^[0-9]+$/.test(value)) retry = Number(value); break;
}
};
const dispatch = () => {
lastEventId = idBuffer;
if (data !== "") onEvent?.({ type: type || "message", data: data.slice(0, -1), lastEventId });
data = type = "";
};
const interpret = (l) => {
if (l === "") return dispatch();
if (l.startsWith(":")) return;
const colon = l.indexOf(":");
if (colon < 0) return field(l, "");
const value = l.slice(colon + 1);
field(l.slice(0, colon), value.startsWith(" ") ? value.slice(1) : value);
};
const breaks = /[\r\n]/g;
return {
feed(text) {
let i = 0;
if (afterCR && text.startsWith("\n")) i = 1;
if (text !== "") afterCR = false;
for (breaks.lastIndex = i; breaks.exec(text); i = breaks.lastIndex) {
const end = breaks.lastIndex - 1;
interpret(line + text.slice(i, end));
line = "";
if (text[end] === "\r") {
if (end + 1 === text.length) afterCR = true;
else if (text[end + 1] === "\n") breaks.lastIndex++;
}
}
line += text.slice(i);
},
// An event the stream ended before its blank line is discarded
result: () => ({ lastEventId, retry }),
};
}
// Test hook: the proof for a fixed client nonce, under an explicit Argon2 floor
export async function _proofForTest(username, password, clientNonce, challenge, minTime, minMemory) {
const { proof, serverSignature } = await prove(username, checkCredentials(username, password),
@@ -135,19 +238,24 @@ async function post(url, body) {
return step;
}
// A redirect would replay the proof to wherever it points
// A redirect would replay the proof or the token to wherever it points
async function send(url, init) {
try {
return await fetch(url, { ...init, credentials: "same-origin", cache: "no-store", redirect: "error" });
} catch {
} catch (err) {
if (err?.name === "AbortError") throw err;
throw new Error(`cannot reach ${url.pathname}`);
}
}
function bearer(token) {
return token ? { Authorization: `Bearer ${token}` } : {};
}
function subtle() {
const s = globalThis.crypto?.subtle;
if (!s) throw new Error("WebCrypto unavailable: the page must be served over HTTPS");
return s;
const reason = loginUnavailable();
if (reason) throw new Error(reason);
return crypto.subtle;
}
async function hmac(key, message) {
+99 -1
View File
@@ -1,6 +1,6 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { argon2id, _proofForTest } from "./scram.js";
import { argon2id, _proofForTest, login, logout, stream } from "./scram.js";
const hex = (b) => Buffer.from(b).toString("hex");
const unhex = (s) => new Uint8Array(Buffer.from(s, "hex"));
@@ -42,3 +42,101 @@ test("proof and server signature match lixenwraith/auth", async () => {
server_signature: "0HKxosIZvdoAzstS4dEkNgoYvcDpFnOgwBjyhiSsK00=",
});
});
const STREAM = "https://site.test/logs/stream";
// A fetch answering with an event stream sent in the given chunks
function sseFetch(t, chunks, { status = 200, type = "text/event-stream" } = {}) {
return t.mock.method(globalThis, "fetch", async () => new Response(new ReadableStream({
start(c) {
for (const chunk of chunks) c.enqueue(new TextEncoder().encode(chunk));
c.close();
},
}), { status, headers: { "Content-Type": type } }));
}
async function events(t, chunks) {
sseFetch(t, chunks);
const got = [];
const end = await stream(STREAM, { onEvent: (e) => got.push(e) });
return { got, end };
}
// A split CRLF is one break, so "d" and "e" stay one event; a kept BOM would
// rename the first field and drop "a".
test("stream splits lines at CRLF, LF and a lone CR, across chunks", async (t) => {
const { got } = await events(t, ["\uFEFFdata: a\r\ndata: b\rdata: c\n\r", "\n", "data: d\r", "\ndata: e\r", "\r\n"]);
assert.deepEqual(got, [
{ type: "message", data: "a\nb\nc", lastEventId: "" },
{ type: "message", data: "d\ne", lastEventId: "" },
]);
});
test("stream interprets fields as the HTML standard does", async (t) => {
const { got, end } = await events(t, [
": comment\nevent: connected\ndata:{\"a\":1}\nid: 7\n\n",
"data\n\n",
"data: one space kept\nunknown: x\nid: 8\0\nretry: 3000\nretry: 15x\n\n",
"id: 8\nevent: no-data\n\n",
"id\ndata: never ended\n",
]);
assert.deepEqual(got, [
{ type: "connected", data: "{\"a\":1}", lastEventId: "7" },
{ type: "message", data: "", lastEventId: "7" },
{ type: "message", data: " one space kept", lastEventId: "7" },
]);
assert.deepEqual(end, { lastEventId: "8", retry: 3000 });
});
test("stream presents the bearer and refuses anything but a 200 event stream", async (t) => {
const fetch = sseFetch(t, []);
await stream(STREAM, { token: "tok" });
const init = fetch.mock.calls[0].arguments[1];
assert.equal(init.headers.Authorization, "Bearer tok");
assert.equal(init.redirect, "error");
fetch.mock.restore();
sseFetch(t, [], { status: 401 });
await assert.rejects(stream(STREAM, { token: "tok" }), { status: 401 });
sseFetch(t, [], { type: "text/html" });
await assert.rejects(stream(STREAM), { message: "not an event stream" });
});
test("stream rejects with the AbortError once its signal aborts", async (t) => {
t.mock.method(globalThis, "fetch", async (url, { signal }) => signal.throwIfAborted() ?? new Response(new ReadableStream({
start(c) {
c.enqueue(new TextEncoder().encode("data: first\n\n"));
signal.addEventListener("abort", () => c.error(signal.reason));
},
}), { headers: { "Content-Type": "text/event-stream" } }));
const ac = new AbortController();
await assert.rejects(stream(STREAM, { signal: ac.signal, onEvent: () => ac.abort() }), { name: "AbortError" });
await assert.rejects(stream(STREAM, { signal: AbortSignal.abort() }), { name: "AbortError" });
});
// The mock server signs with the client's own derivation at the Argon2 floor
test("token mode returns the token, and logout presents it", async (t) => {
const challenge = { salt: Buffer.alloc(16, 9).toString("base64"), argon_time: 3, argon_memory: 65536, argon_threads: 1 };
const sent = [];
t.mock.method(globalThis, "fetch", async (url, init) => {
const body = JSON.parse(init.body);
sent.push({ body, headers: init.headers });
if (body.scram) {
challenge.client_nonce = body.scram.client_nonce;
challenge.full_nonce = body.scram.client_nonce + "server-nonce";
return Response.json({ challenge });
}
if (body.proof) {
const want = await _proofForTest("alice", "password123", challenge.client_nonce, challenge, 3, 65536);
challenge.proof_ok = body.proof.client_proof === want.client_proof;
return Response.json({ final: { username: "alice", server_signature: want.server_signature }, token: "tok", expires_in: 900 });
}
return new Response(null, { status: 204 });
});
const base = "https://site.test/logs/";
assert.deepEqual(await login(base, "alice", "password123", { session: "token" }),
{ username: "alice", expiresIn: 900, token: "tok" });
assert.ok(challenge.proof_ok);
assert.equal("session" in sent[1].body, false);
await logout(base, { token: "tok" });
assert.equal(sent[2].headers.Authorization, "Bearer tok");
});
+9
View File
@@ -115,3 +115,12 @@ progress {
white-space: pre-wrap;
overflow-wrap: anywhere;
}
[hidden] {
display: none !important;
}
.note {
margin: 0;
color: var(--muted);
}
+8
View File
@@ -14,6 +14,14 @@
<span id="state" role="status">connecting…</span>
<button id="logout" type="button">Sign out</button>
</header>
<form id="login" class="card" method="post" hidden>
<p class="note">This browser keeps no cookie for this site, so the session lives in this page and ends when it closes.</p>
<label>Username <input name="username" autocomplete="username" required></label>
<label>Password <input name="password" type="password" autocomplete="current-password" required></label>
<button type="submit">Sign in</button>
<progress id="progress" max="1" hidden></progress>
<p id="status" role="status"></p>
</form>
<main id="log"></main>
</body>
</html>
+127 -22
View File
@@ -1,4 +1,4 @@
import { logout } from "./scram.js";
import { cookiesUsable, login, loginUnavailable, logout, stream } from "./scram.js";
const base = new URL("..", location.href);
// The server writes its status path here, relative to the mount
@@ -6,7 +6,22 @@ const statusPath = document.querySelector('meta[name="logwisp-status"]').content
const MAX_LINES = 5000;
const log = document.getElementById("log");
const state = document.getElementById("state");
let source = null;
const signOut = document.getElementById("logout");
const form = document.getElementById("login");
const progress = document.getElementById("progress");
const status = document.getElementById("status");
const unavailable = loginUnavailable();
// Without cookies the session is a token in this variable only: never stored,
// so it ends with the page, or earlier with its lifetime or sign-out
let tokenMode = !unavailable && !cookiesUsable();
let token = null;
// The login page comes back with this fragment: a 401 now means the browser
// dropped the session cookie
let fresh = location.hash === "#signed-in";
if (fresh) history.replaceState(null, "", location.pathname + location.search);
let stopStream = null;
let timer = null;
function append(line) {
const atBottom = log.scrollTop + log.clientHeight >= log.scrollHeight - 4;
@@ -18,50 +33,140 @@ function append(line) {
}
// The status answer names the stream path; its 401 is the only way to tell an
// expired session apart, since EventSource hides the status of a failure.
// ended session apart, since EventSource hides the status of a failure.
async function connect() {
if (tokenMode && !token) return showLogin("");
let res;
try {
res = await fetch(new URL("./" + statusPath, base), { credentials: "same-origin", cache: "no-store", redirect: "error" });
res = await fetch(new URL("./" + statusPath, base), {
headers: token ? { Authorization: `Bearer ${token}` } : {},
credentials: "same-origin", cache: "no-store", redirect: "error",
});
} catch {
return retry("server unreachable");
}
if (res.status === 401) {
location.replace(new URL("login?next=view", location.href).href);
return;
}
if (res.status === 401) return sessionEnded();
fresh = false;
const path = res.ok ? (await res.json().catch(() => ({})))?.endpoints?.stream : null;
if (typeof path !== "string" || !path.startsWith("/")) {
return retry(`status unavailable (${res.status})`);
}
// Stream paths are absolute in logwisp; the proxy may mount it under a prefix
source = new EventSource(new URL("./" + path.replace(/^\/+/, ""), base));
source.addEventListener("connected", () => { state.textContent = "live"; });
source.addEventListener("disconnect", () => {
source.close();
const url = new URL("./" + path.replace(/^\/+/, ""), base);
if (tokenMode) streamWithToken(url);
else streamWithCookie(url);
}
function onEvent(type, data) {
if (type === "connected") state.textContent = "live";
if (type === "disconnect") {
stopStream();
retry("server shut down");
});
source.onmessage = (event) => event.data.split("\n").forEach(append);
}
if (type === "message") data.split("\n").forEach(append);
}
function streamWithCookie(url) {
const source = new EventSource(url);
stopStream = () => source.close();
for (const type of ["connected", "disconnect", "message"]) {
source.addEventListener(type, (event) => onEvent(type, event.data));
}
source.onerror = () => {
if (source.readyState === EventSource.CLOSED) retry("disconnected");
else state.textContent = "reconnecting…";
};
}
function retry(reason) {
state.textContent = `${reason}, retrying…`;
setTimeout(connect, 5000);
async function streamWithToken(url) {
const abort = new AbortController();
stopStream = () => abort.abort();
let refused = 0;
try {
await stream(url, { token, signal: abort.signal, onEvent: (event) => onEvent(event.type, event.data) });
} catch (err) {
refused = err.status ?? 0;
}
if (abort.signal.aborted) return;
if (refused === 401) sessionEnded();
else retry(refused ? `stream refused (${refused})` : "disconnected");
}
document.getElementById("logout").addEventListener("click", async () => {
source?.close();
function sessionEnded() {
if (!tokenMode && !fresh) {
location.replace(new URL("login?next=" + encodeURIComponent("view#signed-in"), location.href).href);
return;
}
const expired = token !== null;
tokenMode = true;
token = null;
showLogin(expired ? "The session ended; sign in again." : "");
}
function showLogin(message) {
stopStream?.();
clearTimeout(timer);
state.textContent = "signed out";
signOut.hidden = true;
status.textContent = message;
form.hidden = false;
form.elements.username.focus();
}
function retry(reason) {
state.textContent = `${reason}, retrying…`;
clearTimeout(timer);
timer = setTimeout(connect, 5000);
}
form.addEventListener("submit", async (event) => {
event.preventDefault();
const fields = new FormData(form);
const button = form.querySelector("button");
button.disabled = true;
progress.removeAttribute("value");
progress.hidden = false;
status.textContent = "Deriving the key, this takes a few seconds…";
try {
await logout(base);
({ token } = await login(base, fields.get("username"), fields.get("password"), {
session: "token",
onProgress: (fraction) => { progress.value = fraction; },
}));
} catch (err) {
status.textContent = err.message;
return;
} finally {
progress.hidden = true;
button.disabled = false;
}
form.elements.password.value = "";
form.hidden = true;
signOut.hidden = false;
state.textContent = "connecting…";
connect();
});
signOut.addEventListener("click", async () => {
stopStream?.();
clearTimeout(timer);
try {
await logout(base, { token });
} catch (err) {
state.textContent = err.message;
return;
}
location.replace(new URL("login", location.href).href);
if (!tokenMode) {
location.replace(new URL("login", location.href).href);
return;
}
token = null;
showLogin("Signed out.");
});
connect();
// No session of either kind can start on this page, so it offers no form
if (unavailable) {
state.textContent = unavailable;
signOut.hidden = true;
} else {
connect();
}
+11 -7
View File
@@ -9,6 +9,7 @@ import (
"maps"
"net"
"net/http"
"net/url"
"os"
"strconv"
"strings"
@@ -103,6 +104,10 @@ func NewHTTPChainSinkPlugin(
if err := lconfig.Port(opts.Port); err != nil {
return nil, fmt.Errorf("port: %w", err)
}
network, err := core.Network(opts.Host)
if err != nil {
return nil, fmt.Errorf("host: %w", err)
}
if opts.IngestPath == "" {
opts.IngestPath = DefaultHTTPChainSinkIngestPath
} else if !strings.HasPrefix(opts.IngestPath, "/") {
@@ -158,10 +163,9 @@ func NewHTTPChainSinkPlugin(
addr := net.JoinHostPort(opts.Host, strconv.FormatInt(opts.Port, 10))
transport := &http.Transport{
DialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
// IPv4-only, aligns with tcp/http sinks
DialContext: func(ctx context.Context, _, address string) (net.Conn, error) {
d := net.Dialer{}
return d.DialContext(ctx, "tcp4", address)
return d.DialContext(ctx, network, address)
},
MaxIdleConnsPerHost: 2,
IdleConnTimeout: 90 * time.Second,
@@ -172,9 +176,9 @@ func NewHTTPChainSinkPlugin(
// NDJSON POSTs gain nothing from it
}
scheme := "http"
base := url.URL{Scheme: "http", Host: addr} // escapes an IPv6 zone
if tlsCfg != nil {
scheme = "https"
base.Scheme = "https"
}
t := &HTTPChainSink{
@@ -185,8 +189,8 @@ func NewHTTPChainSinkPlugin(
tlsEnabled: tlsCfg != nil,
mtls: tlsCfg != nil && len(tlsCfg.Certificates) > 0,
auth: authPolicy,
baseURL: scheme + "://" + addr,
url: scheme + "://" + addr + opts.IngestPath,
baseURL: base.String(),
url: base.String() + opts.IngestPath,
// A redirect would resend the batch, even from https to plaintext http
client: &http.Client{
Transport: transport,
+74
View File
@@ -1,6 +1,7 @@
package httpchain
import (
"context"
"crypto/tls"
"net"
"net/http"
@@ -16,6 +17,7 @@ import (
"logwisp/internal/config"
"logwisp/internal/core"
"logwisp/internal/session"
ingest "logwisp/internal/source/httpchain"
"logwisp/internal/testutil"
"github.com/lixenwraith/auth"
@@ -126,3 +128,75 @@ func TestRefusedTokenLogsInAgainAndDeliversOnce(t *testing.T) {
t.Fatalf("logins = %v, want 2", logins)
}
}
// The target URL brackets an IPv6 host and escapes its zone: a URL built
// from the raw host could not even form a request.
func TestTargetURLBracketsAnIPv6Host(t *testing.T) {
manager := session.NewManager(time.Hour)
t.Cleanup(manager.Stop)
for host, want := range map[string]string{
"::1": "http://[::1]:9000/ingest",
"fe80::1%eth0": "http://[fe80::1%25eth0]:9000/ingest",
} {
created, err := NewHTTPChainSinkPlugin("fwd", map[string]any{"host": host, "port": int64(9000)},
log.NewLogger(), session.NewProxy(manager, "fwd"))
if err != nil {
t.Fatal(err)
}
if got := created.(*HTTPChainSink).url; got != want {
t.Errorf("host %q: target %q, want %q", host, got, want)
}
}
}
// A link runs over the IPv6 loopback with TLS: the source binds ::1, and the
// sink dials it and verifies the certificate's ::1 address.
func TestLinkOverIPv6LoopbackWithTLS(t *testing.T) {
testutil.RequireIPv6(t)
pki := testutil.NewPKI(t, "edge-01")
probe, err := net.Listen("tcp6", "[::1]:0")
if err != nil {
t.Fatal(err)
}
_, port, _ := net.SplitHostPort(probe.Addr().String())
probe.Close()
p, _ := strconv.ParseInt(port, 10, 64)
manager := session.NewManager(time.Hour)
t.Cleanup(manager.Stop)
src, err := ingest.NewHTTPChainSourcePlugin("in", map[string]any{
"host": "::1", "port": p,
"tls": map[string]any{"enabled": true, "cert_file": pki.ServerCert, "key_file": pki.ServerKey},
}, log.NewLogger(), session.NewProxy(manager, "in"))
if err != nil {
t.Fatal(err)
}
entries := src.Subscribe()
if err := src.Start(); err != nil {
t.Fatal(err)
}
t.Cleanup(src.Stop)
created, err := NewHTTPChainSinkPlugin("fwd", map[string]any{
"host": "::1", "port": p,
"tls": map[string]any{"enabled": true, "ca_file": pki.CA},
}, log.NewLogger(), session.NewProxy(manager, "fwd"))
if err != nil {
t.Fatal(err)
}
sink := created.(*HTTPChainSink)
sink.append(core.TransportEvent{Time: time.Now(), Payload: []byte("over IPv6")})
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
defer cancel()
if !sink.flush(ctx) || sink.droppedBatches.Load() != 0 {
t.Fatalf("batch not delivered: %d request errors", sink.requestErrors.Load())
}
select {
case e := <-entries:
if e.Message != "over IPv6" {
t.Fatalf("message %q", e.Message)
}
case <-time.After(2 * time.Second):
t.Fatal("the source published nothing")
}
}
+1 -1
View File
@@ -34,4 +34,4 @@ type SinkStats struct {
StartTime time.Time
LastProcessed time.Time
Details map[string]any
}
}
+9 -4
View File
@@ -48,8 +48,9 @@ type TCPSink struct {
proxy *session.Proxy
// Configuration
config *config.TCPSinkOptions
addr string
config *config.TCPSinkOptions
addr string
network string
// Network
listener net.Listener
@@ -114,6 +115,10 @@ func NewTCPSinkPlugin(
if err := lconfig.Port(opts.Port); err != nil {
return nil, fmt.Errorf("port: %w", err)
}
network, err := core.Network(opts.Host)
if err != nil {
return nil, fmt.Errorf("host: %w", err)
}
if opts.BufferSize <= 0 {
opts.BufferSize = DefaultTCPBufferSize
}
@@ -140,6 +145,7 @@ func NewTCPSinkPlugin(
proxy: proxy,
config: opts,
addr: net.JoinHostPort(opts.Host, strconv.FormatInt(opts.Port, 10)),
network: network,
input: make(chan core.TransportEvent, opts.BufferSize),
done: make(chan struct{}),
logger: logger,
@@ -192,8 +198,7 @@ func (t *TCPSink) listen() (net.Listener, error) {
Idle: time.Duration(t.config.KeepAlivePeriodMS) * time.Millisecond,
}
}
// IPv4-only, parity with existing network sinks
ln, err := lc.Listen(context.Background(), "tcp4", t.addr)
ln, err := core.Listen(context.Background(), &lc, t.network, t.addr)
if err != nil {
return nil, err
}
+8 -2
View File
@@ -51,6 +51,7 @@ type TCPChainSink struct {
node string
addr string
network string
tlsConfig *tls.Config
// Authorization: pins the downstream server's identity
@@ -100,6 +101,10 @@ func NewTCPChainSinkPlugin(
if err := lconfig.Port(opts.Port); err != nil {
return nil, fmt.Errorf("port: %w", err)
}
network, err := core.Network(opts.Host)
if err != nil {
return nil, fmt.Errorf("host: %w", err)
}
if opts.BufferSize <= 0 {
opts.BufferSize = DefaultChainSinkBufferSize
@@ -149,6 +154,7 @@ func NewTCPChainSinkPlugin(
config: opts,
node: node,
addr: net.JoinHostPort(opts.Host, strconv.FormatInt(opts.Port, 10)),
network: network,
tlsConfig: tlsCfg,
auth: authPolicy,
input: make(chan core.TransportEvent, opts.BufferSize),
@@ -377,10 +383,10 @@ func (t *TCPChainSink) connect(ctx context.Context) error {
// handshake under ctx, so bound dial + handshake together here
dctx, cancel := context.WithTimeout(ctx, t.dialTimeout+tlsx.HandshakeTimeout)
td := tls.Dialer{NetDialer: &nd, Config: t.tlsConfig}
conn, err = td.DialContext(dctx, "tcp4", t.addr) // IPv4-only
conn, err = td.DialContext(dctx, t.network, t.addr)
cancel()
} else {
conn, err = nd.DialContext(ctx, "tcp4", t.addr) // IPv4-only
conn, err = nd.DialContext(ctx, t.network, t.addr)
}
if err != nil {
return err