v0.22.0 several enhancements and fixes for security, network, doc, test, packaging
This commit is contained in:
1 parent
9ab8c356d2
commit
71d1b82f83
102 files changed
+7102
-2178
No files matched your search
+18
-14
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -115,3 +115,12 @@ progress {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.note {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -34,4 +34,4 @@ type SinkStats struct {
|
||||
StartTime time.Time
|
||||
LastProcessed time.Time
|
||||
Details map[string]any
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user