Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65d27702c8 | ||
|
|
438b4e09e6 | ||
|
|
3960254b27 | ||
|
|
8a11cb4659 | ||
|
|
5fb721346f |
No files matched your search
Generated
+1292
-78
File diff suppressed because it is too large.
Load diff
+35
-2
@@ -5,14 +5,47 @@ members = ["rust/*"]
|
|||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
|
axum = "0.8"
|
||||||
|
bytes = "1.10"
|
||||||
chrono = "0.4"
|
chrono = "0.4"
|
||||||
clap = "4.5"
|
clap = { version = "4.5", features = ["derive", "env"] }
|
||||||
env_logger = "0.11"
|
env_logger = "0.11"
|
||||||
|
garde = { version = "0.23", features = ["derive", "serde"] }
|
||||||
|
hex = "0.4"
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
serde = "1.0"
|
prost = "0.14"
|
||||||
|
prost-build = "0.14"
|
||||||
|
protoc-bin-vendored = "3.2"
|
||||||
|
# HTTP clients use the shared tls crate to install AWS-LC before building a reqwest client.
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls-webpki-roots-no-provider"] }
|
||||||
|
rustls = { version = "0.23", default-features = false, features = ["aws_lc_rs", "std", "tls12"] }
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
|
sha2 = "0.10"
|
||||||
signal = { path = "rust/signal" }
|
signal = { path = "rust/signal" }
|
||||||
|
sqlx = { version = "0.9", default-features = false, features = [
|
||||||
|
"chrono",
|
||||||
|
"postgres",
|
||||||
|
"runtime-tokio",
|
||||||
|
"tls-none",
|
||||||
|
] }
|
||||||
|
subtle = "2.6"
|
||||||
|
systemd = { version = "0.10", features = ["journal"] }
|
||||||
|
thiserror = "2.0"
|
||||||
|
tls = { path = "rust/tls" }
|
||||||
|
tokio = { version = "1.47", features = [
|
||||||
|
"macros",
|
||||||
|
"net",
|
||||||
|
"rt-multi-thread",
|
||||||
|
"signal",
|
||||||
|
"time",
|
||||||
|
] }
|
||||||
|
tokio-retry = "0.3"
|
||||||
|
tower-http = { version = "0.6", features = ["limit", "trace"] }
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||||
zfs = { path = "rust/zfs" }
|
zfs = { path = "rust/zfs" }
|
||||||
|
zstd = "0.13"
|
||||||
|
|
||||||
[workspace.lints.clippy]
|
[workspace.lints.clippy]
|
||||||
all = { level = "deny", priority = -1 }
|
all = { level = "deny", priority = -1 }
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
[package]
|
||||||
|
name = "haproxy-ingest-proto"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
description = "Shared protocol types and limits for HAProxy ingestion"
|
||||||
|
license = "MIT"
|
||||||
|
build = "build.rs"
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
prost.workspace = true
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
prost-build.workspace = true
|
||||||
|
protoc-bin-vendored.workspace = true
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# HAProxy cloud log ingestion
|
||||||
|
|
||||||
|
This workspace is split into three crates:
|
||||||
|
|
||||||
|
- `haproxy-ingest-proto` owns the shared Protobuf schema and protocol limits.
|
||||||
|
- `haproxy-log-shipper` is a short-lived client launched by a systemd timer on
|
||||||
|
`portal-1`. It reads only local `haproxy.service` journal records and drains
|
||||||
|
them in cursor order.
|
||||||
|
- `haproxy-ingest-server` is the private HTTP API on Jeeves. It authenticates
|
||||||
|
the source, commits records to PostgreSQL, and advances the journal cursor
|
||||||
|
atomically.
|
||||||
|
|
||||||
|
The wire contract is in `proto/haproxy_ingest.proto`; the initial PostgreSQL
|
||||||
|
schema is in `../haproxy_ingest_server/migrations/001_initial.sql`.
|
||||||
|
|
||||||
|
## One-time setup
|
||||||
|
|
||||||
|
The shipper endpoint is supplied by the `haproxy-log-shipper` service as
|
||||||
|
`http://jeeves.taile39c31.ts.net:8444`; the environment file only needs the
|
||||||
|
shared token. Ingestion uses HTTP over Tailscale and requires no certificates.
|
||||||
|
|
||||||
|
Create one random token and install the same value on the shipper and server.
|
||||||
|
A VM rebuild with a new `/etc/machine-id` is still treated as a new log source.
|
||||||
|
|
||||||
|
On `portal-1`, create `/var/lib/haproxy-log-shipper/environment`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
HAPROXY_INGEST_TOKEN=<at-least-32-character-random-token>
|
||||||
|
```
|
||||||
|
|
||||||
|
Set the file ownership and mode to `root:root` and `0600`.
|
||||||
|
|
||||||
|
On Jeeves, create
|
||||||
|
`/zfs/storage/secrets/services/haproxy-ingest-server`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
HAPROXY_INGEST_TOKEN=<the-same-random-token>
|
||||||
|
```
|
||||||
|
|
||||||
|
Set this file to `root:root` and `0600`. systemd loads it before switching the
|
||||||
|
API to its unprivileged service account.
|
||||||
|
|
||||||
|
A suitable token can be generated without writing it to shell history:
|
||||||
|
|
||||||
|
```console
|
||||||
|
openssl rand -hex 32
|
||||||
|
```
|
||||||
|
|
||||||
|
Deploy Jeeves before `portal-1`, then verify:
|
||||||
|
|
||||||
|
```console
|
||||||
|
systemctl status haproxy-ingest-server.service
|
||||||
|
systemctl status haproxy-log-shipper.timer
|
||||||
|
journalctl -u haproxy-log-shipper.service
|
||||||
|
```
|
||||||
|
|
||||||
|
The server listens over IPv4 on TCP 8444, which Jeeves' firewall permits only
|
||||||
|
through `tailscale0`. Both service sandboxes disable IPv6. Tailscale encrypts
|
||||||
|
traffic between the hosts. The bearer token authorizes access to the ingestion API;
|
||||||
|
the machine ID identifies a log source but is not an authorization boundary.
|
||||||
|
|
||||||
|
## Protocol behavior
|
||||||
|
|
||||||
|
- Maximum sender batch: 1,000 records or 20 MiB serialized Protobuf.
|
||||||
|
- Maximum receiver request: 24 MiB compressed, 20 MiB decompressed, 10,000 records.
|
||||||
|
- Compression: one Zstandard frame over the complete Protobuf body.
|
||||||
|
- Request timeouts: 3 seconds to connect and 10 seconds total.
|
||||||
|
- Retry delays: 1, 2, and 4 seconds plus bounded jitter.
|
||||||
|
- `409`: fetch the authoritative server cursor and rebuild.
|
||||||
|
- `413`: fail because a sender batch within the configured limits was rejected.
|
||||||
|
- `422`: replace only the reported offending record with a durable rejection.
|
||||||
|
- Ambiguous failures: query the status endpoint before retrying.
|
||||||
|
|
||||||
|
Journal retention on `portal-1` is persistent, bounded to 512 MiB, and retained
|
||||||
|
for up to 14 days. If an acknowledged cursor has already been vacuumed, the
|
||||||
|
shipper records a `JournalGap` before resuming from the oldest retained matching
|
||||||
|
entry.
|
||||||
|
|
||||||
|
## Database inspection
|
||||||
|
|
||||||
|
The API owns the `haproxy_ingest` database and creates daily
|
||||||
|
`haproxy_logs_YYYYMMDD` partitions before using chunked SQLx inserts.
|
||||||
|
Useful checks include:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT machine_id, hostname, last_journal_timestamp, last_commit_time
|
||||||
|
FROM ingest_source_state;
|
||||||
|
|
||||||
|
SELECT journal_timestamp, client_ip, http_method, request_path, status_code
|
||||||
|
FROM haproxy_logs
|
||||||
|
ORDER BY journal_timestamp DESC
|
||||||
|
LIMIT 100;
|
||||||
|
|
||||||
|
SELECT * FROM ingest_journal_gaps ORDER BY detected_at DESC;
|
||||||
|
SELECT * FROM ingest_rejected_events ORDER BY ingest_time DESC;
|
||||||
|
```
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let protoc = protoc_bin_vendored::protoc_bin_path()?;
|
||||||
|
unsafe {
|
||||||
|
std::env::set_var("PROTOC", protoc);
|
||||||
|
}
|
||||||
|
let mut config = prost_build::Config::new();
|
||||||
|
config.type_attribute(".haproxy.ingest.v1.LogBatch", "#[derive(Eq)]");
|
||||||
|
config.compile_protos(&["proto/haproxy_ingest.proto"], &["proto"])?;
|
||||||
|
println!("cargo:rerun-if-changed=proto/haproxy_ingest.proto");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
package haproxy.ingest.v1;
|
||||||
|
|
||||||
|
message LogBatch {
|
||||||
|
uint32 schema_version = 1;
|
||||||
|
string machine_id = 2;
|
||||||
|
string hostname = 3;
|
||||||
|
optional string previous_cursor = 4;
|
||||||
|
string last_cursor = 5;
|
||||||
|
int64 first_journal_timestamp_us = 6;
|
||||||
|
int64 last_journal_timestamp_us = 7;
|
||||||
|
repeated LogRecord records = 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
message LogRecord {
|
||||||
|
int64 journal_timestamp_us = 1;
|
||||||
|
|
||||||
|
oneof payload {
|
||||||
|
HAProxyEvent event = 2;
|
||||||
|
RejectedEvent rejected = 3;
|
||||||
|
JournalGap gap = 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
message HAProxyEvent {
|
||||||
|
reserved 22;
|
||||||
|
|
||||||
|
optional string client_ip = 1;
|
||||||
|
optional uint32 client_port = 2;
|
||||||
|
optional string frontend_name = 3;
|
||||||
|
optional string backend_name = 4;
|
||||||
|
optional string server_name = 5;
|
||||||
|
optional string http_method = 6;
|
||||||
|
optional string request_path = 7;
|
||||||
|
optional string http_version = 8;
|
||||||
|
int32 status_code = 9;
|
||||||
|
uint64 bytes_sent = 10;
|
||||||
|
int32 time_request_ms = 11;
|
||||||
|
int32 time_wait_ms = 12;
|
||||||
|
int32 time_connect_ms = 13;
|
||||||
|
int32 time_response_ms = 14;
|
||||||
|
int32 time_total_ms = 15;
|
||||||
|
optional string termination_state = 16;
|
||||||
|
uint32 retries = 17;
|
||||||
|
optional string tls_sni = 18;
|
||||||
|
optional string tls_version = 19;
|
||||||
|
optional string tls_cipher = 20;
|
||||||
|
optional bool tls_resumed = 21;
|
||||||
|
repeated string normalization_flags = 23;
|
||||||
|
optional uint32 active_connections = 24;
|
||||||
|
optional uint32 frontend_connections = 25;
|
||||||
|
optional uint32 backend_connections = 26;
|
||||||
|
optional uint32 server_connections = 27;
|
||||||
|
optional uint32 server_queue = 28;
|
||||||
|
optional uint32 backend_queue = 29;
|
||||||
|
}
|
||||||
|
|
||||||
|
message RejectedEvent {
|
||||||
|
string reason = 1;
|
||||||
|
bytes message_sha256 = 2;
|
||||||
|
optional string raw_message_prefix = 3;
|
||||||
|
bool raw_message_truncated = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message JournalGap {
|
||||||
|
string expected_cursor = 1;
|
||||||
|
string first_available_cursor = 2;
|
||||||
|
optional int64 first_available_timestamp_us = 3;
|
||||||
|
string reason = 4;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
pub mod proto {
|
||||||
|
#![allow(clippy::large_enum_variant)]
|
||||||
|
|
||||||
|
include!(concat!(env!("OUT_DIR"), "/haproxy.ingest.v1.rs"));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const SCHEMA_VERSION: u32 = 1;
|
||||||
|
pub const MAX_BATCH_RECORDS: usize = 1_000;
|
||||||
|
pub const MAX_BATCH_PROTO_BYTES: usize = 20 * 1024 * 1024;
|
||||||
|
pub const MAX_COMPRESSED_BYTES: usize = 24 * 1024 * 1024;
|
||||||
|
pub const MAX_DECOMPRESSED_BYTES: usize = MAX_BATCH_PROTO_BYTES;
|
||||||
|
pub const SERVER_MAX_RECORDS: usize = 10_000;
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
[package]
|
||||||
|
name = "haproxy-ingest-server"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
description = "HAProxy log ingestion API"
|
||||||
|
license = "MIT"
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow.workspace = true
|
||||||
|
axum.workspace = true
|
||||||
|
bytes.workspace = true
|
||||||
|
chrono = { workspace = true, features = ["serde"] }
|
||||||
|
clap.workspace = true
|
||||||
|
haproxy-ingest-proto = { path = "../haproxy_ingest_proto" }
|
||||||
|
hex.workspace = true
|
||||||
|
prost.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
sha2.workspace = true
|
||||||
|
sqlx.workspace = true
|
||||||
|
subtle.workspace = true
|
||||||
|
thiserror.workspace = true
|
||||||
|
tokio.workspace = true
|
||||||
|
tower-http.workspace = true
|
||||||
|
tracing.workspace = true
|
||||||
|
tracing-subscriber.workspace = true
|
||||||
|
zstd.workspace = true
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS ingest_source_state (
|
||||||
|
machine_id TEXT PRIMARY KEY,
|
||||||
|
hostname TEXT NOT NULL,
|
||||||
|
last_cursor TEXT,
|
||||||
|
last_journal_timestamp TIMESTAMPTZ,
|
||||||
|
last_source_ip INET,
|
||||||
|
last_commit_time TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS haproxy_logs (
|
||||||
|
id BIGINT GENERATED ALWAYS AS IDENTITY,
|
||||||
|
machine_id TEXT NOT NULL,
|
||||||
|
hostname TEXT NOT NULL,
|
||||||
|
source_ip INET NOT NULL,
|
||||||
|
journal_timestamp TIMESTAMPTZ NOT NULL,
|
||||||
|
request_time TIMESTAMPTZ,
|
||||||
|
client_ip INET,
|
||||||
|
client_port INTEGER,
|
||||||
|
frontend_name TEXT,
|
||||||
|
backend_name TEXT,
|
||||||
|
server_name TEXT,
|
||||||
|
http_method TEXT,
|
||||||
|
request_path TEXT,
|
||||||
|
http_version TEXT,
|
||||||
|
status_code INTEGER NOT NULL,
|
||||||
|
bytes_sent BIGINT NOT NULL,
|
||||||
|
time_request_ms INTEGER NOT NULL,
|
||||||
|
time_wait_ms INTEGER NOT NULL,
|
||||||
|
time_connect_ms INTEGER NOT NULL,
|
||||||
|
time_response_ms INTEGER NOT NULL,
|
||||||
|
time_total_ms INTEGER NOT NULL,
|
||||||
|
termination_state TEXT,
|
||||||
|
retries INTEGER NOT NULL,
|
||||||
|
tls_sni TEXT,
|
||||||
|
tls_version TEXT,
|
||||||
|
tls_cipher TEXT,
|
||||||
|
tls_resumed BOOLEAN,
|
||||||
|
normalization_flags TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
active_connections INTEGER,
|
||||||
|
frontend_connections INTEGER,
|
||||||
|
backend_connections INTEGER,
|
||||||
|
server_connections INTEGER,
|
||||||
|
server_queue INTEGER,
|
||||||
|
backend_queue INTEGER,
|
||||||
|
ingest_time TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (journal_timestamp, id)
|
||||||
|
) PARTITION BY RANGE (journal_timestamp);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS haproxy_logs_machine_time_idx
|
||||||
|
ON haproxy_logs (machine_id, journal_timestamp);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS haproxy_logs_status_time_idx
|
||||||
|
ON haproxy_logs (status_code, journal_timestamp);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ingest_rejected_events (
|
||||||
|
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
machine_id TEXT NOT NULL,
|
||||||
|
hostname TEXT NOT NULL,
|
||||||
|
journal_timestamp TIMESTAMPTZ NOT NULL,
|
||||||
|
reason TEXT NOT NULL,
|
||||||
|
message_sha256 BYTEA NOT NULL,
|
||||||
|
raw_message_prefix TEXT,
|
||||||
|
raw_message_truncated BOOLEAN NOT NULL,
|
||||||
|
source_ip INET NOT NULL,
|
||||||
|
ingest_time TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ingest_rejected_events_machine_time_idx
|
||||||
|
ON ingest_rejected_events (machine_id, journal_timestamp);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ingest_journal_gaps (
|
||||||
|
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
machine_id TEXT NOT NULL,
|
||||||
|
hostname TEXT NOT NULL,
|
||||||
|
expected_cursor TEXT NOT NULL,
|
||||||
|
first_available_cursor TEXT NOT NULL,
|
||||||
|
first_available_timestamp TIMESTAMPTZ,
|
||||||
|
reason TEXT NOT NULL,
|
||||||
|
source_ip INET NOT NULL,
|
||||||
|
detected_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ingest_journal_gaps_machine_time_idx
|
||||||
|
ON ingest_journal_gaps (machine_id, detected_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ingest_batches (
|
||||||
|
batch_id TEXT PRIMARY KEY,
|
||||||
|
machine_id TEXT NOT NULL,
|
||||||
|
hostname TEXT NOT NULL,
|
||||||
|
source_ip INET NOT NULL,
|
||||||
|
previous_cursor TEXT,
|
||||||
|
last_cursor TEXT NOT NULL,
|
||||||
|
first_journal_timestamp TIMESTAMPTZ,
|
||||||
|
last_journal_timestamp TIMESTAMPTZ,
|
||||||
|
record_count INTEGER NOT NULL,
|
||||||
|
compressed_bytes BIGINT NOT NULL,
|
||||||
|
uncompressed_bytes BIGINT NOT NULL,
|
||||||
|
received_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ingest_batches_machine_time_idx
|
||||||
|
ON ingest_batches (machine_id, received_at);
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
lib,
|
||||||
|
rustPlatform,
|
||||||
|
}:
|
||||||
|
rustPlatform.buildRustPackage {
|
||||||
|
pname = "haproxy-ingest-server";
|
||||||
|
version = "0.1.0";
|
||||||
|
src = import ../rust-source.nix { inherit lib; };
|
||||||
|
|
||||||
|
cargoLock.lockFile = ../../Cargo.lock;
|
||||||
|
cargoBuildFlags = [ "-p haproxy-ingest-server" ];
|
||||||
|
cargoTestFlags = [ "-p haproxy-ingest-server" ];
|
||||||
|
|
||||||
|
meta = {
|
||||||
|
description = "HAProxy log ingestion API";
|
||||||
|
license = lib.licenses.mit;
|
||||||
|
platforms = lib.platforms.linux;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,376 @@
|
|||||||
|
//! `SQLx` statements and Rust-to-PostgreSQL value conversion.
|
||||||
|
|
||||||
|
use std::net::IpAddr;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use chrono::{DateTime, NaiveDate, Utc};
|
||||||
|
use haproxy_ingest_proto::proto::{HaProxyEvent, JournalGap, LogBatch, RejectedEvent, log_record};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use sqlx::postgres::PgRow;
|
||||||
|
use sqlx::{AssertSqlSafe, Decode, PgPool, Postgres, QueryBuilder, Row, Transaction, Type};
|
||||||
|
|
||||||
|
use crate::{ApiError, timestamp};
|
||||||
|
|
||||||
|
const MIGRATION: &str = include_str!("../migrations/001_initial.sql");
|
||||||
|
const EVENT_INSERT_CHUNK_SIZE: usize = 1_000;
|
||||||
|
|
||||||
|
pub struct SourceStatus {
|
||||||
|
pub hostname: Option<String>,
|
||||||
|
pub last_cursor: Option<String>,
|
||||||
|
pub last_journal_timestamp: Option<DateTime<Utc>>,
|
||||||
|
pub last_commit_time: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn apply_ingestion_schema(pool: &PgPool) -> Result<()> {
|
||||||
|
sqlx::raw_sql(MIGRATION)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.context("failed to apply ingestion schema")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn select_source_status(
|
||||||
|
pool: &PgPool,
|
||||||
|
machine_id: &str,
|
||||||
|
) -> Result<Option<SourceStatus>, ApiError> {
|
||||||
|
let row = sqlx::query(
|
||||||
|
"SELECT hostname, last_cursor, last_journal_timestamp, last_commit_time \
|
||||||
|
FROM ingest_source_state WHERE machine_id = $1",
|
||||||
|
)
|
||||||
|
.bind(machine_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
.map_err(ApiError::internal)?;
|
||||||
|
row.map(|row| {
|
||||||
|
Ok(SourceStatus {
|
||||||
|
hostname: get(&row, "hostname")?,
|
||||||
|
last_cursor: get(&row, "last_cursor")?,
|
||||||
|
last_journal_timestamp: get(&row, "last_journal_timestamp")?,
|
||||||
|
last_commit_time: get(&row, "last_commit_time")?,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.transpose()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get<'r, T>(row: &'r PgRow, column: &str) -> Result<T, ApiError>
|
||||||
|
where
|
||||||
|
T: Decode<'r, Postgres> + Type<Postgres>,
|
||||||
|
{
|
||||||
|
row.try_get(column).map_err(ApiError::internal)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_lock_timeout(transaction: &mut Transaction<'_, Postgres>) -> Result<(), ApiError> {
|
||||||
|
sqlx::query("SET LOCAL lock_timeout = '5s'")
|
||||||
|
.execute(&mut **transaction)
|
||||||
|
.await
|
||||||
|
.map_err(ApiError::internal)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_statement_timeout(
|
||||||
|
transaction: &mut Transaction<'_, Postgres>,
|
||||||
|
) -> Result<(), ApiError> {
|
||||||
|
sqlx::query("SET LOCAL statement_timeout = '8s'")
|
||||||
|
.execute(&mut **transaction)
|
||||||
|
.await
|
||||||
|
.map_err(ApiError::internal)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn insert_source_state(
|
||||||
|
transaction: &mut Transaction<'_, Postgres>,
|
||||||
|
batch: &LogBatch,
|
||||||
|
) -> Result<(), ApiError> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO ingest_source_state (machine_id, hostname) VALUES ($1, $2) \
|
||||||
|
ON CONFLICT (machine_id) DO NOTHING",
|
||||||
|
)
|
||||||
|
.bind(&batch.machine_id)
|
||||||
|
.bind(&batch.hostname)
|
||||||
|
.execute(&mut **transaction)
|
||||||
|
.await
|
||||||
|
.map_err(ApiError::internal)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn select_committed_cursor(
|
||||||
|
transaction: &mut Transaction<'_, Postgres>,
|
||||||
|
machine_id: &str,
|
||||||
|
) -> Result<Option<String>, ApiError> {
|
||||||
|
sqlx::query_scalar(
|
||||||
|
"SELECT last_cursor FROM ingest_source_state WHERE machine_id = $1 FOR UPDATE",
|
||||||
|
)
|
||||||
|
.bind(machine_id)
|
||||||
|
.fetch_one(&mut **transaction)
|
||||||
|
.await
|
||||||
|
.map_err(ApiError::internal)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_daily_partition(
|
||||||
|
transaction: &mut Transaction<'_, Postgres>,
|
||||||
|
date: NaiveDate,
|
||||||
|
next: NaiveDate,
|
||||||
|
) -> Result<(), ApiError> {
|
||||||
|
let name = partition_name(date);
|
||||||
|
let statement = format!(
|
||||||
|
"CREATE TABLE IF NOT EXISTS {name} PARTITION OF haproxy_logs \
|
||||||
|
FOR VALUES FROM ('{date}') TO ('{next}')"
|
||||||
|
);
|
||||||
|
sqlx::query(AssertSqlSafe(statement.as_str()))
|
||||||
|
.execute(&mut **transaction)
|
||||||
|
.await
|
||||||
|
.map_err(ApiError::internal)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn partition_name(date: NaiveDate) -> String {
|
||||||
|
format!("haproxy_logs_{}", date.format("%Y%m%d"))
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PreparedEvent<'a> {
|
||||||
|
event: &'a HaProxyEvent,
|
||||||
|
journal_timestamp: DateTime<Utc>,
|
||||||
|
client_ip: Option<String>,
|
||||||
|
client_port: Option<i32>,
|
||||||
|
bytes_sent: i64,
|
||||||
|
retries: i32,
|
||||||
|
active_connections: Option<i32>,
|
||||||
|
frontend_connections: Option<i32>,
|
||||||
|
backend_connections: Option<i32>,
|
||||||
|
server_connections: Option<i32>,
|
||||||
|
server_queue: Option<i32>,
|
||||||
|
backend_queue: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prepare_events(batch: &LogBatch) -> Result<Vec<PreparedEvent<'_>>, ApiError> {
|
||||||
|
let mut prepared = Vec::new();
|
||||||
|
for record in &batch.records {
|
||||||
|
let Some(log_record::Payload::Event(event)) = &record.payload else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
prepared.push(PreparedEvent {
|
||||||
|
event,
|
||||||
|
journal_timestamp: timestamp(record.journal_timestamp_us)?,
|
||||||
|
client_ip: event
|
||||||
|
.client_ip
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|value| value.parse::<IpAddr>().ok())
|
||||||
|
.map(|value| value.to_string()),
|
||||||
|
client_port: event
|
||||||
|
.client_port
|
||||||
|
.map(i32::try_from)
|
||||||
|
.transpose()
|
||||||
|
.map_err(ApiError::internal)?,
|
||||||
|
bytes_sent: i64::try_from(event.bytes_sent).map_err(ApiError::internal)?,
|
||||||
|
retries: i32::try_from(event.retries).map_err(ApiError::internal)?,
|
||||||
|
active_connections: sql_i32(event.active_connections).map_err(ApiError::internal)?,
|
||||||
|
frontend_connections: sql_i32(event.frontend_connections)
|
||||||
|
.map_err(ApiError::internal)?,
|
||||||
|
backend_connections: sql_i32(event.backend_connections).map_err(ApiError::internal)?,
|
||||||
|
server_connections: sql_i32(event.server_connections).map_err(ApiError::internal)?,
|
||||||
|
server_queue: sql_i32(event.server_queue).map_err(ApiError::internal)?,
|
||||||
|
backend_queue: sql_i32(event.backend_queue).map_err(ApiError::internal)?,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(prepared)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_lines)]
|
||||||
|
pub async fn insert_events(
|
||||||
|
transaction: &mut Transaction<'_, Postgres>,
|
||||||
|
batch: &LogBatch,
|
||||||
|
source_ip: IpAddr,
|
||||||
|
) -> Result<(), ApiError> {
|
||||||
|
let prepared = prepare_events(batch)?;
|
||||||
|
let source_ip = source_ip.to_string();
|
||||||
|
for chunk in prepared.chunks(EVENT_INSERT_CHUNK_SIZE) {
|
||||||
|
let mut query = QueryBuilder::<Postgres>::new(
|
||||||
|
"INSERT INTO haproxy_logs (machine_id,hostname,source_ip,journal_timestamp,client_ip,\
|
||||||
|
client_port,frontend_name,backend_name,server_name,http_method,request_path,http_version,\
|
||||||
|
status_code,bytes_sent,time_request_ms,time_wait_ms,time_connect_ms,time_response_ms,\
|
||||||
|
time_total_ms,termination_state,retries,tls_sni,tls_version,tls_cipher,tls_resumed,\
|
||||||
|
normalization_flags,active_connections,frontend_connections,\
|
||||||
|
backend_connections,server_connections,server_queue,backend_queue) ",
|
||||||
|
);
|
||||||
|
query.push_values(chunk, |mut values, row| {
|
||||||
|
values
|
||||||
|
.push_bind(&batch.machine_id)
|
||||||
|
.push_bind(&batch.hostname)
|
||||||
|
.push_bind(source_ip.as_str())
|
||||||
|
.push_unseparated("::inet")
|
||||||
|
.push_bind(row.journal_timestamp)
|
||||||
|
.push_bind(row.client_ip.as_deref())
|
||||||
|
.push_unseparated("::inet")
|
||||||
|
.push_bind(row.client_port)
|
||||||
|
.push_bind(row.event.frontend_name.as_deref())
|
||||||
|
.push_bind(row.event.backend_name.as_deref())
|
||||||
|
.push_bind(row.event.server_name.as_deref())
|
||||||
|
.push_bind(row.event.http_method.as_deref())
|
||||||
|
.push_bind(row.event.request_path.as_deref())
|
||||||
|
.push_bind(row.event.http_version.as_deref())
|
||||||
|
.push_bind(row.event.status_code)
|
||||||
|
.push_bind(row.bytes_sent)
|
||||||
|
.push_bind(row.event.time_request_ms)
|
||||||
|
.push_bind(row.event.time_wait_ms)
|
||||||
|
.push_bind(row.event.time_connect_ms)
|
||||||
|
.push_bind(row.event.time_response_ms)
|
||||||
|
.push_bind(row.event.time_total_ms)
|
||||||
|
.push_bind(row.event.termination_state.as_deref())
|
||||||
|
.push_bind(row.retries)
|
||||||
|
.push_bind(row.event.tls_sni.as_deref())
|
||||||
|
.push_bind(row.event.tls_version.as_deref())
|
||||||
|
.push_bind(row.event.tls_cipher.as_deref())
|
||||||
|
.push_bind(row.event.tls_resumed)
|
||||||
|
.push_bind(row.event.normalization_flags.as_slice())
|
||||||
|
.push_bind(row.active_connections)
|
||||||
|
.push_bind(row.frontend_connections)
|
||||||
|
.push_bind(row.backend_connections)
|
||||||
|
.push_bind(row.server_connections)
|
||||||
|
.push_bind(row.server_queue)
|
||||||
|
.push_bind(row.backend_queue);
|
||||||
|
});
|
||||||
|
query
|
||||||
|
.build()
|
||||||
|
.execute(&mut **transaction)
|
||||||
|
.await
|
||||||
|
.map_err(ApiError::internal)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn insert_rejected_event(
|
||||||
|
transaction: &mut Transaction<'_, Postgres>,
|
||||||
|
batch: &LogBatch,
|
||||||
|
source_ip: IpAddr,
|
||||||
|
journal_timestamp: DateTime<Utc>,
|
||||||
|
rejected: &RejectedEvent,
|
||||||
|
) -> Result<(), ApiError> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO ingest_rejected_events (machine_id,hostname,journal_timestamp,reason,\
|
||||||
|
message_sha256,raw_message_prefix,raw_message_truncated,source_ip) \
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8::inet)",
|
||||||
|
)
|
||||||
|
.bind(&batch.machine_id)
|
||||||
|
.bind(&batch.hostname)
|
||||||
|
.bind(journal_timestamp)
|
||||||
|
.bind(&rejected.reason)
|
||||||
|
.bind(&rejected.message_sha256)
|
||||||
|
.bind(&rejected.raw_message_prefix)
|
||||||
|
.bind(rejected.raw_message_truncated)
|
||||||
|
.bind(source_ip.to_string())
|
||||||
|
.execute(&mut **transaction)
|
||||||
|
.await
|
||||||
|
.map_err(ApiError::internal)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn insert_journal_gap(
|
||||||
|
transaction: &mut Transaction<'_, Postgres>,
|
||||||
|
batch: &LogBatch,
|
||||||
|
source_ip: IpAddr,
|
||||||
|
first_available_timestamp: Option<DateTime<Utc>>,
|
||||||
|
gap: &JournalGap,
|
||||||
|
) -> Result<(), ApiError> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO ingest_journal_gaps (machine_id,hostname,expected_cursor,\
|
||||||
|
first_available_cursor,first_available_timestamp,reason,source_ip) \
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7::inet)",
|
||||||
|
)
|
||||||
|
.bind(&batch.machine_id)
|
||||||
|
.bind(&batch.hostname)
|
||||||
|
.bind(&gap.expected_cursor)
|
||||||
|
.bind(&gap.first_available_cursor)
|
||||||
|
.bind(first_available_timestamp)
|
||||||
|
.bind(&gap.reason)
|
||||||
|
.bind(source_ip.to_string())
|
||||||
|
.execute(&mut **transaction)
|
||||||
|
.await
|
||||||
|
.map_err(ApiError::internal)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn insert_ingest_batch(
|
||||||
|
transaction: &mut Transaction<'_, Postgres>,
|
||||||
|
batch: &LogBatch,
|
||||||
|
source_ip: IpAddr,
|
||||||
|
compressed_bytes: usize,
|
||||||
|
uncompressed_bytes: usize,
|
||||||
|
) -> Result<(), ApiError> {
|
||||||
|
let first_timestamp = timestamp(batch.first_journal_timestamp_us)?;
|
||||||
|
let last_timestamp = timestamp(batch.last_journal_timestamp_us)?;
|
||||||
|
let batch_id = batch_id(batch);
|
||||||
|
let record_count = i32::try_from(batch.records.len()).map_err(ApiError::internal)?;
|
||||||
|
let compressed_bytes = i64::try_from(compressed_bytes).map_err(ApiError::internal)?;
|
||||||
|
let uncompressed_bytes = i64::try_from(uncompressed_bytes).map_err(ApiError::internal)?;
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO ingest_batches (batch_id, machine_id, hostname, source_ip, previous_cursor, \
|
||||||
|
last_cursor, first_journal_timestamp, last_journal_timestamp, record_count, \
|
||||||
|
compressed_bytes, uncompressed_bytes) \
|
||||||
|
VALUES ($1,$2,$3,$4::inet,$5,$6,$7,$8,$9,$10,$11) ON CONFLICT (batch_id) DO NOTHING",
|
||||||
|
)
|
||||||
|
.bind(&batch_id)
|
||||||
|
.bind(&batch.machine_id)
|
||||||
|
.bind(&batch.hostname)
|
||||||
|
.bind(source_ip.to_string())
|
||||||
|
.bind(&batch.previous_cursor)
|
||||||
|
.bind(&batch.last_cursor)
|
||||||
|
.bind(first_timestamp)
|
||||||
|
.bind(last_timestamp)
|
||||||
|
.bind(record_count)
|
||||||
|
.bind(compressed_bytes)
|
||||||
|
.bind(uncompressed_bytes)
|
||||||
|
.execute(&mut **transaction)
|
||||||
|
.await
|
||||||
|
.map_err(ApiError::internal)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_source_state(
|
||||||
|
transaction: &mut Transaction<'_, Postgres>,
|
||||||
|
batch: &LogBatch,
|
||||||
|
source_ip: IpAddr,
|
||||||
|
) -> Result<(), ApiError> {
|
||||||
|
let last_timestamp = timestamp(batch.last_journal_timestamp_us)?;
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE ingest_source_state SET hostname=$2, last_cursor=$3, \
|
||||||
|
last_journal_timestamp=$4, last_source_ip=$5::inet, last_commit_time=now() \
|
||||||
|
WHERE machine_id=$1",
|
||||||
|
)
|
||||||
|
.bind(&batch.machine_id)
|
||||||
|
.bind(&batch.hostname)
|
||||||
|
.bind(&batch.last_cursor)
|
||||||
|
.bind(last_timestamp)
|
||||||
|
.bind(source_ip.to_string())
|
||||||
|
.execute(&mut **transaction)
|
||||||
|
.await
|
||||||
|
.map_err(ApiError::internal)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn batch_id(batch: &LogBatch) -> String {
|
||||||
|
let mut hash = Sha256::new();
|
||||||
|
hash.update(batch.machine_id.as_bytes());
|
||||||
|
hash.update([0]);
|
||||||
|
if let Some(previous) = &batch.previous_cursor {
|
||||||
|
hash.update(previous.as_bytes());
|
||||||
|
}
|
||||||
|
hash.update([0]);
|
||||||
|
hash.update(batch.last_cursor.as_bytes());
|
||||||
|
hex::encode(hash.finalize())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sql_i32(value: Option<u32>) -> Result<Option<i32>, std::num::TryFromIntError> {
|
||||||
|
value.map(i32::try_from).transpose()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[allow(clippy::unwrap_used)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn partition_names_are_date_only() {
|
||||||
|
let date = NaiveDate::from_ymd_opt(2026, 8, 29).unwrap();
|
||||||
|
assert_eq!(partition_name(date), "haproxy_logs_20260829");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,530 @@
|
|||||||
|
use std::{
|
||||||
|
collections::BTreeSet,
|
||||||
|
io::Read,
|
||||||
|
net::{IpAddr, SocketAddr},
|
||||||
|
};
|
||||||
|
|
||||||
|
use anyhow::{Context, Result, bail};
|
||||||
|
use axum::{
|
||||||
|
Router,
|
||||||
|
body::Bytes,
|
||||||
|
extract::{ConnectInfo, DefaultBodyLimit, Path, State},
|
||||||
|
http::{HeaderMap, StatusCode, header},
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
routing::{get as get_route, post},
|
||||||
|
};
|
||||||
|
use chrono::{DateTime, Days, Utc};
|
||||||
|
use clap::Parser;
|
||||||
|
use haproxy_ingest_proto::{
|
||||||
|
MAX_COMPRESSED_BYTES, MAX_DECOMPRESSED_BYTES,
|
||||||
|
proto::{LogBatch, log_record},
|
||||||
|
};
|
||||||
|
use prost::Message;
|
||||||
|
use serde::Serialize;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use sqlx::{PgPool, Postgres, Transaction, postgres::PgPoolOptions};
|
||||||
|
use subtle::ConstantTimeEq;
|
||||||
|
use tower_http::trace::TraceLayer;
|
||||||
|
use tracing::{info, warn};
|
||||||
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
|
mod db;
|
||||||
|
mod validation;
|
||||||
|
|
||||||
|
use validation::{ValidationError, validate_batch};
|
||||||
|
|
||||||
|
#[derive(Debug, Parser)]
|
||||||
|
#[command(about, version)]
|
||||||
|
struct Args {
|
||||||
|
#[arg(long, env = "HAPROXY_INGEST_LISTEN", default_value = "0.0.0.0:8444")]
|
||||||
|
listen: SocketAddr,
|
||||||
|
|
||||||
|
#[arg(long, env = "HAPROXY_INGEST_DATABASE_URL", hide_env_values = true)]
|
||||||
|
database_url: String,
|
||||||
|
|
||||||
|
#[arg(long, env = "HAPROXY_INGEST_TOKEN", hide_env_values = true)]
|
||||||
|
token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct AppState {
|
||||||
|
pool: PgPool,
|
||||||
|
auth: SourceToken,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct SourceToken {
|
||||||
|
hash: [u8; 32],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SourceToken {
|
||||||
|
fn parse(value: &str) -> Result<Self> {
|
||||||
|
let token = value.trim();
|
||||||
|
if token.len() < 32 {
|
||||||
|
bail!("source token must be at least 32 characters");
|
||||||
|
}
|
||||||
|
Ok(Self {
|
||||||
|
hash: Sha256::digest(token.as_bytes()).into(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn authenticate(&self, headers: &HeaderMap) -> Result<(), ApiError> {
|
||||||
|
let authorization = headers
|
||||||
|
.get(header::AUTHORIZATION)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.and_then(|value| value.strip_prefix("Bearer "))
|
||||||
|
.ok_or_else(|| ApiError::new(StatusCode::UNAUTHORIZED, "missing bearer token"))?;
|
||||||
|
let presented: [u8; 32] = Sha256::digest(authorization.as_bytes()).into();
|
||||||
|
(presented.ct_eq(&self.hash).unwrap_u8() == 1)
|
||||||
|
.then_some(())
|
||||||
|
.ok_or_else(|| ApiError::new(StatusCode::UNAUTHORIZED, "invalid bearer token"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct StatusResponse {
|
||||||
|
machine_id: String,
|
||||||
|
hostname: Option<String>,
|
||||||
|
last_cursor: Option<String>,
|
||||||
|
last_journal_timestamp: Option<DateTime<Utc>>,
|
||||||
|
last_commit_time: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct CommitResponse {
|
||||||
|
machine_id: String,
|
||||||
|
last_cursor: String,
|
||||||
|
records_committed: usize,
|
||||||
|
already_committed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct ErrorResponse {
|
||||||
|
error: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
record_index: Option<usize>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
last_cursor: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct ApiError {
|
||||||
|
status: StatusCode,
|
||||||
|
message: String,
|
||||||
|
record_index: Option<usize>,
|
||||||
|
last_cursor: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ApiError {
|
||||||
|
fn new(status: StatusCode, message: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
status,
|
||||||
|
message: message.into(),
|
||||||
|
record_index: None,
|
||||||
|
last_cursor: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invalid(error: &ValidationError) -> Self {
|
||||||
|
Self {
|
||||||
|
status: StatusCode::UNPROCESSABLE_ENTITY,
|
||||||
|
record_index: error.record_index(),
|
||||||
|
message: error.to_string(),
|
||||||
|
last_cursor: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn conflict(last_cursor: Option<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
status: StatusCode::CONFLICT,
|
||||||
|
message: "batch previous_cursor does not match the committed cursor".to_owned(),
|
||||||
|
record_index: None,
|
||||||
|
last_cursor,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn internal(error: impl std::fmt::Display) -> Self {
|
||||||
|
warn!(error = %error, "ingestion request failed");
|
||||||
|
Self::new(
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
"ingestion backend unavailable",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for ApiError {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
(
|
||||||
|
self.status,
|
||||||
|
axum::Json(ErrorResponse {
|
||||||
|
error: self.message,
|
||||||
|
record_index: self.record_index,
|
||||||
|
last_cursor: self.last_cursor,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Eq, PartialEq)]
|
||||||
|
enum CursorDecision {
|
||||||
|
Accept,
|
||||||
|
AlreadyCommitted,
|
||||||
|
Conflict,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.json()
|
||||||
|
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()))
|
||||||
|
.init();
|
||||||
|
let args = Args::parse();
|
||||||
|
let pool = PgPoolOptions::new()
|
||||||
|
.max_connections(8)
|
||||||
|
.connect(&args.database_url)
|
||||||
|
.await
|
||||||
|
.context("failed to connect to PostgreSQL")?;
|
||||||
|
|
||||||
|
db::apply_ingestion_schema(&pool).await?;
|
||||||
|
|
||||||
|
let state = AppState {
|
||||||
|
pool,
|
||||||
|
auth: SourceToken::parse(&args.token)?,
|
||||||
|
};
|
||||||
|
|
||||||
|
let app = Router::new()
|
||||||
|
.route("/v1/logs/status/{machine_id}", get_route(get_status))
|
||||||
|
.route("/v1/logs/batch", post(post_batch))
|
||||||
|
.layer(DefaultBodyLimit::max(MAX_COMPRESSED_BYTES))
|
||||||
|
.layer(TraceLayer::new_for_http())
|
||||||
|
.with_state(state);
|
||||||
|
let listener = tokio::net::TcpListener::bind(args.listen)
|
||||||
|
.await
|
||||||
|
.context("failed to bind ingestion HTTP listener")?;
|
||||||
|
|
||||||
|
info!(listen = %args.listen, "HAProxy ingestion API listening over HTTP");
|
||||||
|
axum::serve(
|
||||||
|
listener,
|
||||||
|
app.into_make_service_with_connect_info::<SocketAddr>(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.context("ingestion server failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_status(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(machine_id): Path<String>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> Result<axum::Json<StatusResponse>, ApiError> {
|
||||||
|
state.auth.authenticate(&headers)?;
|
||||||
|
let status = db::select_source_status(&state.pool, &machine_id).await?;
|
||||||
|
let response = match status {
|
||||||
|
None => StatusResponse {
|
||||||
|
machine_id: machine_id.clone(),
|
||||||
|
hostname: None,
|
||||||
|
last_cursor: None,
|
||||||
|
last_journal_timestamp: None,
|
||||||
|
last_commit_time: None,
|
||||||
|
},
|
||||||
|
Some(status) => StatusResponse {
|
||||||
|
machine_id: machine_id.clone(),
|
||||||
|
hostname: status.hostname,
|
||||||
|
last_cursor: status.last_cursor,
|
||||||
|
last_journal_timestamp: status.last_journal_timestamp,
|
||||||
|
last_commit_time: status.last_commit_time,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
Ok(axum::Json(response))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn post_batch(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
body: Bytes,
|
||||||
|
) -> Result<axum::Json<CommitResponse>, ApiError> {
|
||||||
|
state.auth.authenticate(&headers)?;
|
||||||
|
check_protobuf(&headers)?;
|
||||||
|
check_zstd(&headers)?;
|
||||||
|
let compressed_bytes = body.len();
|
||||||
|
let decoded = decompress_bounded(&body)?;
|
||||||
|
let uncompressed_bytes = decoded.len();
|
||||||
|
let batch = LogBatch::decode(decoded.as_slice()).map_err(|error| {
|
||||||
|
ApiError::new(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
format!("invalid protobuf: {error}"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
validate_batch(&batch).map_err(|error| ApiError::invalid(&error))?;
|
||||||
|
let response = commit_batch(
|
||||||
|
&state.pool,
|
||||||
|
&batch,
|
||||||
|
peer.ip(),
|
||||||
|
compressed_bytes,
|
||||||
|
uncompressed_bytes,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(axum::Json(response))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_protobuf(headers: &HeaderMap) -> Result<(), ApiError> {
|
||||||
|
if headers
|
||||||
|
.get(header::CONTENT_TYPE)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
!= Some("application/x-protobuf")
|
||||||
|
{
|
||||||
|
return Err(ApiError::new(
|
||||||
|
StatusCode::UNSUPPORTED_MEDIA_TYPE,
|
||||||
|
"Content-Type must be application/x-protobuf",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_zstd(headers: &HeaderMap) -> Result<(), ApiError> {
|
||||||
|
if headers
|
||||||
|
.get(header::CONTENT_ENCODING)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
!= Some("zstd")
|
||||||
|
{
|
||||||
|
return Err(ApiError::new(
|
||||||
|
StatusCode::UNSUPPORTED_MEDIA_TYPE,
|
||||||
|
"Content-Encoding must be zstd",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decompress_bounded(body: &[u8]) -> Result<Vec<u8>, ApiError> {
|
||||||
|
let decoder = zstd::stream::read::Decoder::new(body).map_err(|error| {
|
||||||
|
ApiError::new(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
format!("invalid zstd body: {error}"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let mut output = Vec::new();
|
||||||
|
let read_limit = u64::try_from(MAX_DECOMPRESSED_BYTES)
|
||||||
|
.map_err(ApiError::internal)?
|
||||||
|
.saturating_add(1);
|
||||||
|
decoder
|
||||||
|
.take(read_limit)
|
||||||
|
.read_to_end(&mut output)
|
||||||
|
.map_err(|error| {
|
||||||
|
ApiError::new(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
format!("invalid zstd body: {error}"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if output.len() > MAX_DECOMPRESSED_BYTES {
|
||||||
|
return Err(ApiError::new(
|
||||||
|
StatusCode::PAYLOAD_TOO_LARGE,
|
||||||
|
"decompressed protobuf exceeds 20 MiB",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn commit_batch(
|
||||||
|
pool: &PgPool,
|
||||||
|
batch: &LogBatch,
|
||||||
|
source_ip: IpAddr,
|
||||||
|
compressed_bytes: usize,
|
||||||
|
uncompressed_bytes: usize,
|
||||||
|
) -> Result<CommitResponse, ApiError> {
|
||||||
|
let mut transaction = pool.begin().await.map_err(ApiError::internal)?;
|
||||||
|
db::set_lock_timeout(&mut transaction).await?;
|
||||||
|
db::set_statement_timeout(&mut transaction).await?;
|
||||||
|
db::insert_source_state(&mut transaction, batch).await?;
|
||||||
|
let committed_cursor = db::select_committed_cursor(&mut transaction, &batch.machine_id).await?;
|
||||||
|
match classify_cursor_chain(
|
||||||
|
committed_cursor.as_deref(),
|
||||||
|
batch.previous_cursor.as_deref(),
|
||||||
|
&batch.last_cursor,
|
||||||
|
) {
|
||||||
|
CursorDecision::Accept => {}
|
||||||
|
CursorDecision::AlreadyCommitted => {
|
||||||
|
transaction.commit().await.map_err(ApiError::internal)?;
|
||||||
|
return Ok(CommitResponse {
|
||||||
|
machine_id: batch.machine_id.clone(),
|
||||||
|
last_cursor: batch.last_cursor.clone(),
|
||||||
|
records_committed: batch.records.len(),
|
||||||
|
already_committed: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
CursorDecision::Conflict => return Err(ApiError::conflict(committed_cursor)),
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_daily_partitions(&mut transaction, batch).await?;
|
||||||
|
db::insert_events(&mut transaction, batch, source_ip).await?;
|
||||||
|
insert_non_event_records(&mut transaction, batch, source_ip).await?;
|
||||||
|
db::insert_ingest_batch(
|
||||||
|
&mut transaction,
|
||||||
|
batch,
|
||||||
|
source_ip,
|
||||||
|
compressed_bytes,
|
||||||
|
uncompressed_bytes,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
db::update_source_state(&mut transaction, batch, source_ip).await?;
|
||||||
|
transaction.commit().await.map_err(ApiError::internal)?;
|
||||||
|
info!(
|
||||||
|
machine_id = %batch.machine_id,
|
||||||
|
records = batch.records.len(),
|
||||||
|
last_cursor = %batch.last_cursor,
|
||||||
|
"committed HAProxy log batch"
|
||||||
|
);
|
||||||
|
Ok(CommitResponse {
|
||||||
|
machine_id: batch.machine_id.clone(),
|
||||||
|
last_cursor: batch.last_cursor.clone(),
|
||||||
|
records_committed: batch.records.len(),
|
||||||
|
already_committed: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ensure_daily_partitions(
|
||||||
|
transaction: &mut Transaction<'_, Postgres>,
|
||||||
|
batch: &LogBatch,
|
||||||
|
) -> Result<(), ApiError> {
|
||||||
|
let mut dates = BTreeSet::new();
|
||||||
|
for record in &batch.records {
|
||||||
|
if matches!(record.payload, Some(log_record::Payload::Event(_))) {
|
||||||
|
dates.insert(timestamp(record.journal_timestamp_us)?.date_naive());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for date in dates {
|
||||||
|
let next = date.checked_add_days(Days::new(1)).ok_or_else(|| {
|
||||||
|
ApiError::new(StatusCode::UNPROCESSABLE_ENTITY, "partition date overflow")
|
||||||
|
})?;
|
||||||
|
db::create_daily_partition(transaction, date, next).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn insert_non_event_records(
|
||||||
|
transaction: &mut Transaction<'_, Postgres>,
|
||||||
|
batch: &LogBatch,
|
||||||
|
source_ip: IpAddr,
|
||||||
|
) -> Result<(), ApiError> {
|
||||||
|
for record in &batch.records {
|
||||||
|
match &record.payload {
|
||||||
|
Some(log_record::Payload::Rejected(rejected)) => {
|
||||||
|
let journal_timestamp = timestamp(record.journal_timestamp_us)?;
|
||||||
|
db::insert_rejected_event(
|
||||||
|
transaction,
|
||||||
|
batch,
|
||||||
|
source_ip,
|
||||||
|
journal_timestamp,
|
||||||
|
rejected,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
Some(log_record::Payload::Gap(gap)) => {
|
||||||
|
let first_available_timestamp = gap
|
||||||
|
.first_available_timestamp_us
|
||||||
|
.map(timestamp)
|
||||||
|
.transpose()?;
|
||||||
|
db::insert_journal_gap(
|
||||||
|
transaction,
|
||||||
|
batch,
|
||||||
|
source_ip,
|
||||||
|
first_available_timestamp,
|
||||||
|
gap,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
Some(log_record::Payload::Event(_)) | None => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn timestamp(micros: i64) -> Result<DateTime<Utc>, ApiError> {
|
||||||
|
DateTime::from_timestamp_micros(micros).ok_or_else(|| {
|
||||||
|
ApiError::new(
|
||||||
|
StatusCode::UNPROCESSABLE_ENTITY,
|
||||||
|
"journal timestamp is outside supported range",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn classify_cursor_chain(
|
||||||
|
committed: Option<&str>,
|
||||||
|
previous: Option<&str>,
|
||||||
|
last: &str,
|
||||||
|
) -> CursorDecision {
|
||||||
|
if committed == Some(last) {
|
||||||
|
CursorDecision::AlreadyCommitted
|
||||||
|
} else if committed == previous {
|
||||||
|
CursorDecision::Accept
|
||||||
|
} else {
|
||||||
|
CursorDecision::Conflict
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[allow(clippy::unwrap_used)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn starts_without_certificate_arguments() {
|
||||||
|
Args::try_parse_from([
|
||||||
|
"haproxy-ingest-server",
|
||||||
|
"--database-url",
|
||||||
|
"postgres://localhost/haproxy_ingest",
|
||||||
|
"--token",
|
||||||
|
"0123456789abcdef0123456789abcdef",
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shared_token_authenticates_requests() {
|
||||||
|
let token = SourceToken::parse("0123456789abcdef0123456789abcdef").unwrap();
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(
|
||||||
|
header::AUTHORIZATION,
|
||||||
|
"Bearer 0123456789abcdef0123456789abcdef".parse().unwrap(),
|
||||||
|
);
|
||||||
|
assert!(token.authenticate(&headers).is_ok());
|
||||||
|
headers.insert(
|
||||||
|
header::AUTHORIZATION,
|
||||||
|
"Bearer fedcba9876543210fedcba9876543210".parse().unwrap(),
|
||||||
|
);
|
||||||
|
assert!(token.authenticate(&headers).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decompression_limit_is_enforced() {
|
||||||
|
let input = vec![0_u8; MAX_DECOMPRESSED_BYTES + 1];
|
||||||
|
let compressed = zstd::stream::encode_all(input.as_slice(), 1).unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
decompress_bounded(&compressed),
|
||||||
|
Err(ApiError {
|
||||||
|
status: StatusCode::PAYLOAD_TOO_LARGE,
|
||||||
|
..
|
||||||
|
})
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cursor_chain_handles_bootstrap_continuation_duplicate_and_conflict() {
|
||||||
|
assert_eq!(
|
||||||
|
classify_cursor_chain(None, None, "c1"),
|
||||||
|
CursorDecision::Accept
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
classify_cursor_chain(Some("c1"), Some("c1"), "c2"),
|
||||||
|
CursorDecision::Accept
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
classify_cursor_chain(Some("c2"), Some("c1"), "c2"),
|
||||||
|
CursorDecision::AlreadyCommitted
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
classify_cursor_chain(Some("other"), Some("c1"), "c2"),
|
||||||
|
CursorDecision::Conflict
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
use std::net::IpAddr;
|
||||||
|
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use haproxy_ingest_proto::{
|
||||||
|
SCHEMA_VERSION, SERVER_MAX_RECORDS,
|
||||||
|
proto::{LogBatch, LogRecord, log_record},
|
||||||
|
};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum ValidationError {
|
||||||
|
#[error("unsupported schema_version {0}")]
|
||||||
|
SchemaVersion(u32),
|
||||||
|
#[error("machine_id must be 32 lowercase hexadecimal characters")]
|
||||||
|
MachineId,
|
||||||
|
#[error("hostname is empty or longer than 255 bytes")]
|
||||||
|
Hostname,
|
||||||
|
#[error("last_cursor is empty")]
|
||||||
|
LastCursor,
|
||||||
|
#[error("batch contains no records or more than {SERVER_MAX_RECORDS} records")]
|
||||||
|
RecordCount,
|
||||||
|
#[error("batch journal timestamp bounds are invalid")]
|
||||||
|
TimestampBounds,
|
||||||
|
#[error("record {index}: {reason}")]
|
||||||
|
Record { index: usize, reason: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ValidationError {
|
||||||
|
#[must_use]
|
||||||
|
pub const fn record_index(&self) -> Option<usize> {
|
||||||
|
match self {
|
||||||
|
Self::Record { index, .. } => Some(*index),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates protocol-level and per-record invariants before database access.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`ValidationError`] when the batch cannot be committed safely.
|
||||||
|
pub fn validate_batch(batch: &LogBatch) -> Result<(), ValidationError> {
|
||||||
|
if batch.schema_version != SCHEMA_VERSION {
|
||||||
|
return Err(ValidationError::SchemaVersion(batch.schema_version));
|
||||||
|
}
|
||||||
|
if batch.machine_id.len() != 32
|
||||||
|
|| !batch
|
||||||
|
.machine_id
|
||||||
|
.bytes()
|
||||||
|
.all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
|
||||||
|
{
|
||||||
|
return Err(ValidationError::MachineId);
|
||||||
|
}
|
||||||
|
if batch.hostname.is_empty() || batch.hostname.len() > 255 {
|
||||||
|
return Err(ValidationError::Hostname);
|
||||||
|
}
|
||||||
|
if batch.last_cursor.is_empty() {
|
||||||
|
return Err(ValidationError::LastCursor);
|
||||||
|
}
|
||||||
|
if batch.records.is_empty() || batch.records.len() > SERVER_MAX_RECORDS {
|
||||||
|
return Err(ValidationError::RecordCount);
|
||||||
|
}
|
||||||
|
// Journal cursors define record order; wall-clock timestamps may move backward.
|
||||||
|
if batch
|
||||||
|
.records
|
||||||
|
.first()
|
||||||
|
.map(|record| record.journal_timestamp_us)
|
||||||
|
!= Some(batch.first_journal_timestamp_us)
|
||||||
|
|| batch
|
||||||
|
.records
|
||||||
|
.last()
|
||||||
|
.map(|record| record.journal_timestamp_us)
|
||||||
|
!= Some(batch.last_journal_timestamp_us)
|
||||||
|
{
|
||||||
|
return Err(ValidationError::TimestampBounds);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (index, record) in batch.records.iter().enumerate() {
|
||||||
|
validate_record(index, record)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_record(index: usize, record: &LogRecord) -> Result<(), ValidationError> {
|
||||||
|
if DateTime::<Utc>::from_timestamp_micros(record.journal_timestamp_us).is_none() {
|
||||||
|
return record_error(
|
||||||
|
index,
|
||||||
|
"journal timestamp is outside PostgreSQL's supported range",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let Some(payload) = &record.payload else {
|
||||||
|
return record_error(index, "payload is absent");
|
||||||
|
};
|
||||||
|
match payload {
|
||||||
|
log_record::Payload::Event(event) => {
|
||||||
|
if event.bytes_sent > i64::MAX.cast_unsigned() {
|
||||||
|
return record_error(index, "bytes_sent exceeds PostgreSQL BIGINT");
|
||||||
|
}
|
||||||
|
if event.retries > i32::MAX.cast_unsigned()
|
||||||
|
|| [
|
||||||
|
event.active_connections,
|
||||||
|
event.frontend_connections,
|
||||||
|
event.backend_connections,
|
||||||
|
event.server_connections,
|
||||||
|
event.server_queue,
|
||||||
|
event.backend_queue,
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.any(|value| value > i32::MAX.cast_unsigned())
|
||||||
|
{
|
||||||
|
return record_error(index, "counter exceeds PostgreSQL INTEGER");
|
||||||
|
}
|
||||||
|
if event.client_port.is_some_and(|port| port > u16::MAX.into()) {
|
||||||
|
return record_error(index, "client_port exceeds 65535");
|
||||||
|
}
|
||||||
|
if let Some(ip) = &event.client_ip
|
||||||
|
&& ip.parse::<IpAddr>().is_err()
|
||||||
|
{
|
||||||
|
return record_error(index, "client_ip is invalid");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log_record::Payload::Rejected(rejected) => {
|
||||||
|
if rejected.reason.is_empty() || rejected.message_sha256.len() != 32 {
|
||||||
|
return record_error(index, "rejected event reason or SHA-256 is invalid");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log_record::Payload::Gap(gap) => {
|
||||||
|
if gap.expected_cursor.is_empty()
|
||||||
|
|| gap.first_available_cursor.is_empty()
|
||||||
|
|| gap.reason.is_empty()
|
||||||
|
{
|
||||||
|
return record_error(index, "journal gap fields are incomplete");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_error<T>(index: usize, reason: &str) -> Result<T, ValidationError> {
|
||||||
|
Err(ValidationError::Record {
|
||||||
|
index,
|
||||||
|
reason: reason.to_owned(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[allow(clippy::indexing_slicing, clippy::unwrap_used)]
|
||||||
|
mod tests {
|
||||||
|
use haproxy_ingest_proto::proto::{HaProxyEvent, LogRecord, log_record};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn batch() -> LogBatch {
|
||||||
|
LogBatch {
|
||||||
|
schema_version: 1,
|
||||||
|
machine_id: "0123456789abcdef0123456789abcdef".to_owned(),
|
||||||
|
hostname: "portal-1".to_owned(),
|
||||||
|
previous_cursor: None,
|
||||||
|
last_cursor: "cursor-1".to_owned(),
|
||||||
|
first_journal_timestamp_us: 1_700_000_000_000_000,
|
||||||
|
last_journal_timestamp_us: 1_700_000_000_000_000,
|
||||||
|
records: vec![LogRecord {
|
||||||
|
journal_timestamp_us: 1_700_000_000_000_000,
|
||||||
|
payload: Some(log_record::Payload::Event(HaProxyEvent::default())),
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accepts_a_valid_batch() {
|
||||||
|
validate_batch(&batch()).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accepts_a_batch_with_decreasing_journal_timestamps() {
|
||||||
|
let mut batch = batch();
|
||||||
|
batch.previous_cursor = Some("cursor-0".to_owned());
|
||||||
|
batch.last_cursor = "cursor-2".to_owned();
|
||||||
|
batch.last_journal_timestamp_us = 1_699_999_999_000_000;
|
||||||
|
batch.records.push(LogRecord {
|
||||||
|
journal_timestamp_us: batch.last_journal_timestamp_us,
|
||||||
|
payload: Some(log_record::Payload::Event(HaProxyEvent::default())),
|
||||||
|
});
|
||||||
|
|
||||||
|
validate_batch(&batch).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_batch_timestamps_that_do_not_match_the_endpoint_records() {
|
||||||
|
for (first, last) in [
|
||||||
|
(1_699_999_999_000_000, 1_700_000_000_000_000),
|
||||||
|
(1_700_000_000_000_000, 1_700_000_001_000_000),
|
||||||
|
] {
|
||||||
|
let mut batch = batch();
|
||||||
|
batch.first_journal_timestamp_us = first;
|
||||||
|
batch.last_journal_timestamp_us = last;
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
validate_batch(&batch),
|
||||||
|
Err(ValidationError::TimestampBounds)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reports_the_invalid_record_index() {
|
||||||
|
let mut batch = batch();
|
||||||
|
batch.records[0].payload = None;
|
||||||
|
let error = validate_batch(&batch).unwrap_err();
|
||||||
|
assert_eq!(error.record_index(), Some(0));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
[package]
|
||||||
|
name = "haproxy-log-shipper"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
description = "Ordered HAProxy journald ingestion client"
|
||||||
|
license = "MIT"
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow.workspace = true
|
||||||
|
chrono = { workspace = true, features = ["serde"] }
|
||||||
|
clap.workspace = true
|
||||||
|
garde.workspace = true
|
||||||
|
haproxy-ingest-proto = { path = "../haproxy_ingest_proto" }
|
||||||
|
prost.workspace = true
|
||||||
|
reqwest = { workspace = true, features = ["json"] }
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
sha2.workspace = true
|
||||||
|
systemd.workspace = true
|
||||||
|
tls.workspace = true
|
||||||
|
tokio.workspace = true
|
||||||
|
tokio-retry.workspace = true
|
||||||
|
tracing.workspace = true
|
||||||
|
tracing-subscriber.workspace = true
|
||||||
|
zstd.workspace = true
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
lib,
|
||||||
|
pkg-config,
|
||||||
|
rustPlatform,
|
||||||
|
systemd,
|
||||||
|
}:
|
||||||
|
rustPlatform.buildRustPackage {
|
||||||
|
pname = "haproxy-log-shipper";
|
||||||
|
version = "0.1.0";
|
||||||
|
src = import ../rust-source.nix { inherit lib; };
|
||||||
|
|
||||||
|
cargoLock.lockFile = ../../Cargo.lock;
|
||||||
|
cargoBuildFlags = [ "-p haproxy-log-shipper" ];
|
||||||
|
cargoTestFlags = [ "-p haproxy-log-shipper" ];
|
||||||
|
|
||||||
|
nativeBuildInputs = [ pkg-config ];
|
||||||
|
buildInputs = [ systemd ];
|
||||||
|
|
||||||
|
meta = {
|
||||||
|
description = "Ordered HAProxy journald ingestion client";
|
||||||
|
license = lib.licenses.mit;
|
||||||
|
platforms = lib.platforms.linux;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,360 @@
|
|||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result, bail};
|
||||||
|
use haproxy_ingest_proto::{
|
||||||
|
SCHEMA_VERSION,
|
||||||
|
proto::{LogBatch, LogRecord, log_record},
|
||||||
|
};
|
||||||
|
use prost::Message;
|
||||||
|
use reqwest::{Client, StatusCode, header};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use tokio_retry::strategy::{ExponentialBackoff, jitter};
|
||||||
|
use tracing::{Instrument, info, warn};
|
||||||
|
|
||||||
|
use crate::mapper::rejected_event;
|
||||||
|
|
||||||
|
const INITIAL_RETRIES: usize = 3;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct StatusResponse {
|
||||||
|
machine_id: String,
|
||||||
|
pub last_cursor: Option<String>,
|
||||||
|
last_journal_timestamp: Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct CommitResponse {
|
||||||
|
last_cursor: String,
|
||||||
|
records_committed: usize,
|
||||||
|
already_committed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ErrorResponse {
|
||||||
|
error: String,
|
||||||
|
record_index: Option<usize>,
|
||||||
|
last_cursor: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct PendingRecord {
|
||||||
|
pub record: LogRecord,
|
||||||
|
pub cursor: String,
|
||||||
|
pub raw_message: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct PendingBatch {
|
||||||
|
pub machine_id: String,
|
||||||
|
pub hostname: String,
|
||||||
|
pub previous_cursor: Option<String>,
|
||||||
|
pub records: Vec<PendingRecord>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PendingBatch {
|
||||||
|
fn last_cursor(&self) -> Result<&str> {
|
||||||
|
self.records
|
||||||
|
.last()
|
||||||
|
.map(|record| record.cursor.as_str())
|
||||||
|
.context("pending batch is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn to_wire(&self) -> Result<LogBatch> {
|
||||||
|
let first_journal_timestamp_us = self
|
||||||
|
.records
|
||||||
|
.first()
|
||||||
|
.context("pending batch is empty")?
|
||||||
|
.record
|
||||||
|
.journal_timestamp_us;
|
||||||
|
let last_journal_timestamp_us = self
|
||||||
|
.records
|
||||||
|
.last()
|
||||||
|
.context("pending batch is empty")?
|
||||||
|
.record
|
||||||
|
.journal_timestamp_us;
|
||||||
|
Ok(LogBatch {
|
||||||
|
schema_version: SCHEMA_VERSION,
|
||||||
|
machine_id: self.machine_id.clone(),
|
||||||
|
hostname: self.hostname.clone(),
|
||||||
|
previous_cursor: self.previous_cursor.clone(),
|
||||||
|
last_cursor: self.last_cursor()?.to_owned(),
|
||||||
|
first_journal_timestamp_us,
|
||||||
|
last_journal_timestamp_us,
|
||||||
|
records: self
|
||||||
|
.records
|
||||||
|
.iter()
|
||||||
|
.map(|pending| pending.record.clone())
|
||||||
|
.collect(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn replace_with_rejection(&mut self, index: usize, reason: &str) -> Result<()> {
|
||||||
|
let pending = self
|
||||||
|
.records
|
||||||
|
.get_mut(index)
|
||||||
|
.with_context(|| format!("receiver returned out-of-range record index {index}"))?;
|
||||||
|
if matches!(
|
||||||
|
pending.record.payload,
|
||||||
|
Some(log_record::Payload::Rejected(_))
|
||||||
|
) {
|
||||||
|
bail!("receiver rejected an already-isolated RejectedEvent at index {index}");
|
||||||
|
}
|
||||||
|
let raw = pending
|
||||||
|
.raw_message
|
||||||
|
.as_deref()
|
||||||
|
.context("receiver rejected a journal gap marker")?;
|
||||||
|
pending.record.payload = Some(log_record::Payload::Rejected(rejected_event(
|
||||||
|
raw,
|
||||||
|
&format!("receiver validation rejected record: {reason}"),
|
||||||
|
)));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum SendResult {
|
||||||
|
Committed(String),
|
||||||
|
Resync,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum PostBatchResult {
|
||||||
|
Complete(SendResult),
|
||||||
|
RetryNow,
|
||||||
|
RetryAfterStatusCheck,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum StatusRequestResult {
|
||||||
|
Complete(StatusResponse),
|
||||||
|
Retry,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn post_batch_with_retry(
|
||||||
|
client: &Client,
|
||||||
|
endpoint: &str,
|
||||||
|
pending: &mut PendingBatch,
|
||||||
|
) -> Result<SendResult> {
|
||||||
|
for (attempt, delay) in retry_schedule().enumerate() {
|
||||||
|
if let Some(delay) = delay {
|
||||||
|
tokio::time::sleep(delay).await;
|
||||||
|
}
|
||||||
|
let last_cursor = loop {
|
||||||
|
let batch = pending.to_wire()?;
|
||||||
|
let last_cursor = batch.last_cursor.clone();
|
||||||
|
let encoded = batch.encode_to_vec();
|
||||||
|
let compressed = zstd::stream::encode_all(encoded.as_slice(), 3)
|
||||||
|
.context("failed to compress protobuf batch")?;
|
||||||
|
let result = post_batch(client, endpoint, pending, compressed, &last_cursor)
|
||||||
|
.instrument(tracing::info_span!("post_batch", attempt))
|
||||||
|
.await?;
|
||||||
|
match result {
|
||||||
|
PostBatchResult::Complete(send_result) => return Ok(send_result),
|
||||||
|
PostBatchResult::RetryNow => {}
|
||||||
|
PostBatchResult::RetryAfterStatusCheck => break last_cursor,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Ok(status) = get_status_with_retry(client, endpoint, &pending.machine_id).await {
|
||||||
|
if status.last_cursor.as_deref() == Some(last_cursor.as_str()) {
|
||||||
|
info!(last_cursor, "status check confirmed ambiguous batch commit");
|
||||||
|
return Ok(SendResult::Committed(last_cursor));
|
||||||
|
}
|
||||||
|
if status.last_cursor != pending.previous_cursor {
|
||||||
|
return Ok(SendResult::Resync);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bail!("batch failed after initial attempt and {INITIAL_RETRIES} retries")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn post_batch(
|
||||||
|
client: &Client,
|
||||||
|
endpoint: &str,
|
||||||
|
pending: &mut PendingBatch,
|
||||||
|
compressed: Vec<u8>,
|
||||||
|
last_cursor: &str,
|
||||||
|
) -> Result<PostBatchResult> {
|
||||||
|
let response = client
|
||||||
|
.post(format!("{endpoint}/v1/logs/batch"))
|
||||||
|
.header(header::CONTENT_TYPE, "application/x-protobuf")
|
||||||
|
.header(header::CONTENT_ENCODING, "zstd")
|
||||||
|
.body(compressed)
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match response {
|
||||||
|
Ok(response) if response.status().is_success() => {
|
||||||
|
let committed: CommitResponse = response
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.context("receiver returned an invalid commit response")?;
|
||||||
|
if committed.last_cursor != last_cursor
|
||||||
|
|| committed.records_committed != pending.records.len()
|
||||||
|
{
|
||||||
|
bail!("receiver commit response did not match the sent batch");
|
||||||
|
}
|
||||||
|
info!(
|
||||||
|
already_committed = committed.already_committed,
|
||||||
|
"batch acknowledged"
|
||||||
|
);
|
||||||
|
Ok(PostBatchResult::Complete(SendResult::Committed(
|
||||||
|
last_cursor.to_owned(),
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
Ok(response) if response.status() == StatusCode::CONFLICT => {
|
||||||
|
let error = decode_error(response).await;
|
||||||
|
warn!(server_cursor = ?error.last_cursor, error = %error.error, "receiver rejected cursor chain");
|
||||||
|
Ok(PostBatchResult::Complete(SendResult::Resync))
|
||||||
|
}
|
||||||
|
Ok(response) if response.status() == StatusCode::PAYLOAD_TOO_LARGE => {
|
||||||
|
let error = decode_error(response).await;
|
||||||
|
bail!(
|
||||||
|
"receiver rejected a batch within the sender's configured limits as too large: {}",
|
||||||
|
error.error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Ok(response) if response.status() == StatusCode::UNPROCESSABLE_ENTITY => {
|
||||||
|
let error = decode_error(response).await;
|
||||||
|
let index = error
|
||||||
|
.record_index
|
||||||
|
.context("receiver returned 422 without an offending record index")?;
|
||||||
|
pending.replace_with_rejection(index, &error.error)?;
|
||||||
|
warn!(record_index = index, error = %error.error, "isolated receiver-rejected record");
|
||||||
|
Ok(PostBatchResult::RetryNow)
|
||||||
|
}
|
||||||
|
Ok(response)
|
||||||
|
if matches!(
|
||||||
|
response.status(),
|
||||||
|
StatusCode::UNAUTHORIZED
|
||||||
|
| StatusCode::FORBIDDEN
|
||||||
|
| StatusCode::BAD_REQUEST
|
||||||
|
| StatusCode::UNSUPPORTED_MEDIA_TYPE
|
||||||
|
) =>
|
||||||
|
{
|
||||||
|
let status = response.status();
|
||||||
|
let error = decode_error(response).await;
|
||||||
|
bail!("permanent receiver error {status}: {}", error.error);
|
||||||
|
}
|
||||||
|
Ok(response) if !response.status().is_server_error() => {
|
||||||
|
let status = response.status();
|
||||||
|
let error = decode_error(response).await;
|
||||||
|
bail!("unexpected receiver response {status}: {}", error.error);
|
||||||
|
}
|
||||||
|
Ok(response) => {
|
||||||
|
warn!(status = %response.status(), "transient receiver failure");
|
||||||
|
Ok(PostBatchResult::RetryAfterStatusCheck)
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
warn!(error = %error, "ambiguous batch request failure");
|
||||||
|
Ok(PostBatchResult::RetryAfterStatusCheck)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_status_with_retry(
|
||||||
|
client: &Client,
|
||||||
|
endpoint: &str,
|
||||||
|
machine_id: &str,
|
||||||
|
) -> Result<StatusResponse> {
|
||||||
|
for (attempt, delay) in retry_schedule().enumerate() {
|
||||||
|
if let Some(delay) = delay {
|
||||||
|
tokio::time::sleep(delay).await;
|
||||||
|
}
|
||||||
|
let result = get_status(client, endpoint, machine_id)
|
||||||
|
.instrument(tracing::info_span!("get_status", attempt))
|
||||||
|
.await?;
|
||||||
|
match result {
|
||||||
|
StatusRequestResult::Complete(status) => return Ok(status),
|
||||||
|
StatusRequestResult::Retry => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bail!("status request failed after initial attempt and {INITIAL_RETRIES} retries")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_status(
|
||||||
|
client: &Client,
|
||||||
|
endpoint: &str,
|
||||||
|
machine_id: &str,
|
||||||
|
) -> Result<StatusRequestResult> {
|
||||||
|
let response = match client
|
||||||
|
.get(format!("{endpoint}/v1/logs/status/{machine_id}"))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(response) => response,
|
||||||
|
Err(error) => {
|
||||||
|
warn!(error = %error, "transient receiver status failure");
|
||||||
|
return Ok(StatusRequestResult::Retry);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if !response.status().is_success() {
|
||||||
|
let status = response.status();
|
||||||
|
let error = decode_error(response).await;
|
||||||
|
if status.is_server_error() {
|
||||||
|
warn!(status = %status, error = %error.error, "transient receiver status failure");
|
||||||
|
return Ok(StatusRequestResult::Retry);
|
||||||
|
}
|
||||||
|
bail!(
|
||||||
|
"receiver status request failed with {status}: {}",
|
||||||
|
error.error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let status: StatusResponse = response
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.context("receiver returned an invalid status response")?;
|
||||||
|
if status.machine_id != machine_id {
|
||||||
|
bail!("receiver status response contains the wrong machine_id");
|
||||||
|
}
|
||||||
|
info!(
|
||||||
|
last_cursor = ?status.last_cursor,
|
||||||
|
last_journal_timestamp = ?status.last_journal_timestamp,
|
||||||
|
"loaded receiver checkpoint"
|
||||||
|
);
|
||||||
|
Ok(StatusRequestResult::Complete(status))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn retry_delays() -> impl Iterator<Item = Duration> {
|
||||||
|
// The library raises the base to each attempt, then multiplies by the factor.
|
||||||
|
// 2^n * 500 milliseconds gives delays of 1, 2, and 4 seconds.
|
||||||
|
ExponentialBackoff::from_millis(2)
|
||||||
|
.factor(500)
|
||||||
|
.take(INITIAL_RETRIES)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn retry_schedule() -> impl Iterator<Item = Option<Duration>> {
|
||||||
|
std::iter::once(None).chain(retry_delays().map(jitter).map(Some))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn decode_error(response: reqwest::Response) -> ErrorResponse {
|
||||||
|
response.json().await.unwrap_or_else(|error| ErrorResponse {
|
||||||
|
error: format!("response body was not valid error JSON: {error}"),
|
||||||
|
record_index: None,
|
||||||
|
last_cursor: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn retries_use_one_two_and_four_second_delays() {
|
||||||
|
assert_eq!(
|
||||||
|
retry_delays().collect::<Vec<_>>(),
|
||||||
|
[
|
||||||
|
Duration::from_secs(1),
|
||||||
|
Duration::from_secs(2),
|
||||||
|
Duration::from_secs(4),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn retry_schedule_starts_immediately_and_bounds_jitter() {
|
||||||
|
let mut schedule = retry_schedule();
|
||||||
|
assert_eq!(schedule.next(), Some(None));
|
||||||
|
for limit in retry_delays() {
|
||||||
|
assert!(matches!(schedule.next(), Some(Some(delay)) if delay <= limit));
|
||||||
|
}
|
||||||
|
assert_eq!(schedule.next(), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
use std::{fs, time::Duration};
|
||||||
|
|
||||||
|
use anyhow::{Context, Result, anyhow, bail};
|
||||||
|
use clap::Parser;
|
||||||
|
use haproxy_ingest_proto::{
|
||||||
|
MAX_BATCH_PROTO_BYTES, MAX_BATCH_RECORDS,
|
||||||
|
proto::{JournalGap, LogRecord, log_record},
|
||||||
|
};
|
||||||
|
use prost::Message;
|
||||||
|
use reqwest::{Client, header};
|
||||||
|
use systemd::journal::{Journal, OpenOptions};
|
||||||
|
use tls::install_tls_provider;
|
||||||
|
use tracing::{info, warn};
|
||||||
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
|
mod api;
|
||||||
|
mod mapper;
|
||||||
|
|
||||||
|
use api::{PendingBatch, PendingRecord, SendResult, get_status_with_retry, post_batch_with_retry};
|
||||||
|
use mapper::map_message;
|
||||||
|
|
||||||
|
#[derive(Debug, Parser)]
|
||||||
|
#[command(about, version)]
|
||||||
|
struct Args {
|
||||||
|
#[arg(long, env = "HAPROXY_INGEST_TOKEN", hide_env_values = true)]
|
||||||
|
token: String,
|
||||||
|
|
||||||
|
#[arg(long, env = "HAPROXY_INGEST_ENDPOINT")]
|
||||||
|
endpoint: String,
|
||||||
|
|
||||||
|
#[arg(long, env = "HAPROXY_INGEST_UNIT", default_value = "haproxy.service")]
|
||||||
|
unit: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.json()
|
||||||
|
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()))
|
||||||
|
.init();
|
||||||
|
let args = Args::parse();
|
||||||
|
install_tls_provider()?;
|
||||||
|
let machine_id = get_valid_machine_id()?;
|
||||||
|
let hostname = read_trimmed("/etc/hostname")?;
|
||||||
|
let client = build_client(&args.token)?;
|
||||||
|
|
||||||
|
let mut committed_cursor = get_status_with_retry(&client, &args.endpoint, &machine_id)
|
||||||
|
.await?
|
||||||
|
.last_cursor;
|
||||||
|
loop {
|
||||||
|
let Some(mut batch) =
|
||||||
|
read_batch(&machine_id, &hostname, committed_cursor.clone(), &args.unit)?
|
||||||
|
else {
|
||||||
|
info!(machine_id, "no uncommitted HAProxy journal records");
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
committed_cursor = match post_batch_with_retry(&client, &args.endpoint, &mut batch).await? {
|
||||||
|
SendResult::Committed(cursor) => {
|
||||||
|
info!(
|
||||||
|
records = batch.records.len(),
|
||||||
|
last_cursor = %cursor,
|
||||||
|
"receiver committed HAProxy batch"
|
||||||
|
);
|
||||||
|
Some(cursor)
|
||||||
|
}
|
||||||
|
SendResult::Resync => {
|
||||||
|
let status = get_status_with_retry(&client, &args.endpoint, &machine_id).await?;
|
||||||
|
|
||||||
|
warn!(
|
||||||
|
server_cursor = ?status.last_cursor,
|
||||||
|
"cursor conflict; rebuilding from receiver checkpoint"
|
||||||
|
);
|
||||||
|
|
||||||
|
status.last_cursor
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_client(token: &str) -> Result<Client> {
|
||||||
|
let authorization = format!("Bearer {token}")
|
||||||
|
.parse()
|
||||||
|
.context("HAPROXY_INGEST_TOKEN contains invalid HTTP header characters")?;
|
||||||
|
Client::builder()
|
||||||
|
.connect_timeout(Duration::from_secs(3))
|
||||||
|
.timeout(Duration::from_secs(10))
|
||||||
|
.default_headers({
|
||||||
|
let mut headers = reqwest::header::HeaderMap::new();
|
||||||
|
headers.insert(header::AUTHORIZATION, authorization);
|
||||||
|
headers
|
||||||
|
})
|
||||||
|
.build()
|
||||||
|
.context("failed to build HTTP client")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_batch(
|
||||||
|
machine_id: &str,
|
||||||
|
hostname: &str,
|
||||||
|
previous_cursor: Option<String>,
|
||||||
|
unit: &str,
|
||||||
|
) -> Result<Option<PendingBatch>> {
|
||||||
|
let (mut journal, setup) = setup_journal(machine_id, unit, previous_cursor.as_deref())?;
|
||||||
|
let mut pending_batch = PendingBatch {
|
||||||
|
machine_id: machine_id.to_owned(),
|
||||||
|
hostname: hostname.to_owned(),
|
||||||
|
previous_cursor,
|
||||||
|
records: Vec::new(),
|
||||||
|
};
|
||||||
|
match setup {
|
||||||
|
JournalSetup::Empty => return Ok(None),
|
||||||
|
JournalSetup::Ready => {}
|
||||||
|
JournalSetup::Gap(pending_record) => pending_batch.records.push(*pending_record),
|
||||||
|
}
|
||||||
|
while pending_batch.records.len() < MAX_BATCH_RECORDS {
|
||||||
|
let Some(entry) = journal
|
||||||
|
.next_entry()
|
||||||
|
.context("failed to read journal entry")?
|
||||||
|
else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
let cursor = journal.cursor().context("journal entry has no cursor")?;
|
||||||
|
let journal_timestamp_us = i64::try_from(
|
||||||
|
journal
|
||||||
|
.timestamp_usec()
|
||||||
|
.context("journal entry has no timestamp")?,
|
||||||
|
)
|
||||||
|
.context("journal timestamp exceeds int64")?;
|
||||||
|
let message = entry.get("MESSAGE").cloned().unwrap_or_default();
|
||||||
|
pending_batch.records.push(PendingRecord {
|
||||||
|
record: map_message(&message, journal_timestamp_us),
|
||||||
|
cursor,
|
||||||
|
raw_message: Some(message),
|
||||||
|
});
|
||||||
|
if pending_batch.to_wire()?.encoded_len() > MAX_BATCH_PROTO_BYTES {
|
||||||
|
if pending_batch.records.len() == 1 {
|
||||||
|
bail!("single mapped journal record exceeds the 20 MiB protobuf limit");
|
||||||
|
}
|
||||||
|
pending_batch.records.pop();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if pending_batch.records.is_empty() {
|
||||||
|
Ok(None)
|
||||||
|
} else {
|
||||||
|
Ok(Some(pending_batch))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum JournalSetup {
|
||||||
|
Empty,
|
||||||
|
Ready,
|
||||||
|
Gap(Box<PendingRecord>),
|
||||||
|
}
|
||||||
|
|
||||||
|
fn setup_journal(
|
||||||
|
machine_id: &str,
|
||||||
|
unit: &str,
|
||||||
|
previous_cursor: Option<&str>,
|
||||||
|
) -> Result<(Journal, JournalSetup)> {
|
||||||
|
let mut journal = OpenOptions::default()
|
||||||
|
.system(true)
|
||||||
|
.local_only(true)
|
||||||
|
.open()
|
||||||
|
.context("failed to open the local system journal")?;
|
||||||
|
journal
|
||||||
|
.match_add("_SYSTEMD_UNIT", unit.as_bytes())
|
||||||
|
.context("failed to filter journal by systemd unit")?;
|
||||||
|
journal
|
||||||
|
.match_add("_MACHINE_ID", machine_id.as_bytes())
|
||||||
|
.context("failed to filter journal by machine ID")?;
|
||||||
|
let setup = position_journal(&mut journal, previous_cursor)?;
|
||||||
|
Ok((journal, setup))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn position_journal(journal: &mut Journal, previous_cursor: Option<&str>) -> Result<JournalSetup> {
|
||||||
|
let Some(cursor) = previous_cursor else {
|
||||||
|
journal
|
||||||
|
.seek_head()
|
||||||
|
.context("failed to seek to journal head")?;
|
||||||
|
return Ok(JournalSetup::Ready);
|
||||||
|
};
|
||||||
|
let exact = journal.seek_cursor(cursor).is_ok()
|
||||||
|
&& journal
|
||||||
|
.next()
|
||||||
|
.context("failed to move to saved journal cursor")?
|
||||||
|
> 0
|
||||||
|
&& journal
|
||||||
|
.test_cursor(cursor)
|
||||||
|
.context("failed to test saved journal cursor")?;
|
||||||
|
if exact {
|
||||||
|
return Ok(JournalSetup::Ready);
|
||||||
|
}
|
||||||
|
|
||||||
|
journal
|
||||||
|
.seek_head()
|
||||||
|
.context("failed to seek to retained journal head")?;
|
||||||
|
if journal
|
||||||
|
.next()
|
||||||
|
.context("failed to read retained journal head")?
|
||||||
|
== 0
|
||||||
|
{
|
||||||
|
return Ok(JournalSetup::Empty);
|
||||||
|
}
|
||||||
|
let first_cursor = journal
|
||||||
|
.cursor()
|
||||||
|
.context("retained journal entry has no cursor")?;
|
||||||
|
let first_timestamp = i64::try_from(
|
||||||
|
journal
|
||||||
|
.timestamp_usec()
|
||||||
|
.context("retained journal entry has no timestamp")?,
|
||||||
|
)
|
||||||
|
.context("journal timestamp exceeds int64")?;
|
||||||
|
let gap = PendingRecord {
|
||||||
|
record: LogRecord {
|
||||||
|
journal_timestamp_us: first_timestamp,
|
||||||
|
payload: Some(log_record::Payload::Gap(JournalGap {
|
||||||
|
expected_cursor: cursor.to_owned(),
|
||||||
|
first_available_cursor: first_cursor.clone(),
|
||||||
|
first_available_timestamp_us: Some(first_timestamp),
|
||||||
|
reason: "saved cursor is no longer present in the filtered journal".to_owned(),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
cursor: first_cursor,
|
||||||
|
raw_message: None,
|
||||||
|
};
|
||||||
|
journal
|
||||||
|
.seek_head()
|
||||||
|
.context("failed to rewind retained journal")?;
|
||||||
|
Ok(JournalSetup::Gap(Box::new(gap)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_valid_machine_id() -> Result<String> {
|
||||||
|
let machine_id = read_trimmed("/etc/machine-id")?;
|
||||||
|
if machine_id.len() != 32
|
||||||
|
|| !machine_id
|
||||||
|
.bytes()
|
||||||
|
.all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
|
||||||
|
{
|
||||||
|
bail!("/etc/machine-id is not 32 lowercase hexadecimal characters");
|
||||||
|
}
|
||||||
|
Ok(machine_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_trimmed(path: &str) -> Result<String> {
|
||||||
|
let value = fs::read_to_string(path).with_context(|| format!("failed to read {path}"))?;
|
||||||
|
let value = value.trim();
|
||||||
|
if value.is_empty() {
|
||||||
|
return Err(anyhow!("{path} is empty"));
|
||||||
|
}
|
||||||
|
Ok(value.to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn builds_http_client_without_certificate() -> Result<()> {
|
||||||
|
let args = Args::try_parse_from([
|
||||||
|
"haproxy-log-shipper",
|
||||||
|
"--endpoint",
|
||||||
|
"http://127.0.0.1:8444",
|
||||||
|
"--token",
|
||||||
|
"test-token",
|
||||||
|
])?;
|
||||||
|
install_tls_provider()?;
|
||||||
|
build_client(&args.token)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
use std::{fmt::Display, net::IpAddr, str::FromStr};
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use garde::{Unvalidated, Valid};
|
||||||
|
use haproxy_ingest_proto::proto::{HaProxyEvent, LogRecord, RejectedEvent, log_record};
|
||||||
|
use serde::{Deserialize, Deserializer, de::Error as _};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
const RAW_PREFIX_LIMIT: usize = 4 * 1024;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, garde::Validate)]
|
||||||
|
#[garde(allow_unvalidated)]
|
||||||
|
struct RawHaProxyEvent {
|
||||||
|
#[serde(default, deserialize_with = "deserialize_optional_string")]
|
||||||
|
client_ip: Option<String>,
|
||||||
|
#[serde(default, deserialize_with = "deserialize_optional_number")]
|
||||||
|
#[garde(range(max = 65_535))]
|
||||||
|
client_port: Option<u32>,
|
||||||
|
#[serde(default, deserialize_with = "deserialize_optional_string")]
|
||||||
|
frontend_name: Option<String>,
|
||||||
|
#[serde(default, deserialize_with = "deserialize_optional_string")]
|
||||||
|
backend_name: Option<String>,
|
||||||
|
#[serde(default, deserialize_with = "deserialize_optional_string")]
|
||||||
|
server_name: Option<String>,
|
||||||
|
#[serde(default, deserialize_with = "deserialize_optional_string")]
|
||||||
|
http_method: Option<String>,
|
||||||
|
#[serde(default, deserialize_with = "deserialize_optional_string")]
|
||||||
|
request_path: Option<String>,
|
||||||
|
#[serde(default, deserialize_with = "deserialize_optional_string")]
|
||||||
|
http_version: Option<String>,
|
||||||
|
#[serde(default, deserialize_with = "deserialize_optional_number")]
|
||||||
|
#[garde(required, range(min = 100, max = 599))]
|
||||||
|
status_code: Option<i32>,
|
||||||
|
#[serde(default, deserialize_with = "deserialize_optional_number")]
|
||||||
|
#[garde(required, range(max = 9_223_372_036_854_775_807))]
|
||||||
|
bytes_sent: Option<u64>,
|
||||||
|
#[serde(default, deserialize_with = "deserialize_optional_number")]
|
||||||
|
#[garde(required, range(min = -1))]
|
||||||
|
time_request_ms: Option<i32>,
|
||||||
|
#[serde(default, deserialize_with = "deserialize_optional_number")]
|
||||||
|
#[garde(required, range(min = -1))]
|
||||||
|
time_wait_ms: Option<i32>,
|
||||||
|
#[serde(default, deserialize_with = "deserialize_optional_number")]
|
||||||
|
#[garde(required, range(min = -1))]
|
||||||
|
time_connect_ms: Option<i32>,
|
||||||
|
#[serde(default, deserialize_with = "deserialize_optional_number")]
|
||||||
|
#[garde(required, range(min = -1))]
|
||||||
|
time_response_ms: Option<i32>,
|
||||||
|
#[serde(default, deserialize_with = "deserialize_optional_number")]
|
||||||
|
#[garde(required, range(min = -1))]
|
||||||
|
time_total_ms: Option<i32>,
|
||||||
|
#[serde(default, deserialize_with = "deserialize_optional_string")]
|
||||||
|
termination_state: Option<String>,
|
||||||
|
#[serde(default, deserialize_with = "deserialize_optional_number")]
|
||||||
|
#[garde(range(max = 2_147_483_647))]
|
||||||
|
retries: Option<u32>,
|
||||||
|
#[serde(default, deserialize_with = "deserialize_optional_string")]
|
||||||
|
tls_sni: Option<String>,
|
||||||
|
#[serde(default, deserialize_with = "deserialize_optional_string")]
|
||||||
|
tls_version: Option<String>,
|
||||||
|
#[serde(default, deserialize_with = "deserialize_optional_string")]
|
||||||
|
tls_cipher: Option<String>,
|
||||||
|
#[serde(default, deserialize_with = "deserialize_optional_bool")]
|
||||||
|
tls_resumed: Option<bool>,
|
||||||
|
#[serde(default, deserialize_with = "deserialize_optional_number")]
|
||||||
|
#[garde(range(max = 2_147_483_647))]
|
||||||
|
active_connections: Option<u32>,
|
||||||
|
#[serde(default, deserialize_with = "deserialize_optional_number")]
|
||||||
|
#[garde(range(max = 2_147_483_647))]
|
||||||
|
frontend_connections: Option<u32>,
|
||||||
|
#[serde(default, deserialize_with = "deserialize_optional_number")]
|
||||||
|
#[garde(range(max = 2_147_483_647))]
|
||||||
|
backend_connections: Option<u32>,
|
||||||
|
#[serde(default, deserialize_with = "deserialize_optional_number")]
|
||||||
|
#[garde(range(max = 2_147_483_647))]
|
||||||
|
server_connections: Option<u32>,
|
||||||
|
#[serde(default, deserialize_with = "deserialize_optional_number")]
|
||||||
|
#[garde(range(max = 2_147_483_647))]
|
||||||
|
server_queue: Option<u32>,
|
||||||
|
#[serde(default, deserialize_with = "deserialize_optional_number")]
|
||||||
|
#[garde(range(max = 2_147_483_647))]
|
||||||
|
backend_queue: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn map_message(message: &str, journal_timestamp_us: i64) -> LogRecord {
|
||||||
|
let payload = match parse_event(message) {
|
||||||
|
Ok(event) => log_record::Payload::Event(event),
|
||||||
|
Err(error) => log_record::Payload::Rejected(rejected_event(message, &format!("{error:#}"))),
|
||||||
|
};
|
||||||
|
LogRecord {
|
||||||
|
journal_timestamp_us,
|
||||||
|
payload: Some(payload),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_event(message: &str) -> Result<HaProxyEvent> {
|
||||||
|
let event: Unvalidated<RawHaProxyEvent> =
|
||||||
|
serde_json::from_str(message).context("invalid HAProxy JSON")?;
|
||||||
|
let event = event.validate().context("invalid HAProxy event")?;
|
||||||
|
normalize_event(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_event(event: Valid<RawHaProxyEvent>) -> Result<HaProxyEvent> {
|
||||||
|
let event = event.into_inner();
|
||||||
|
let mut normalization_flags = Vec::new();
|
||||||
|
let client_ip = event.client_ip.and_then(|raw| {
|
||||||
|
raw.parse::<IpAddr>().map_or_else(
|
||||||
|
|_| {
|
||||||
|
normalization_flags.push("invalid_client_ip".to_owned());
|
||||||
|
None
|
||||||
|
},
|
||||||
|
|ip| Some(ip.to_string()),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(HaProxyEvent {
|
||||||
|
client_ip,
|
||||||
|
client_port: event.client_port,
|
||||||
|
frontend_name: event.frontend_name,
|
||||||
|
backend_name: event.backend_name,
|
||||||
|
server_name: event.server_name,
|
||||||
|
http_method: event.http_method,
|
||||||
|
request_path: event.request_path,
|
||||||
|
http_version: event.http_version,
|
||||||
|
status_code: event
|
||||||
|
.status_code
|
||||||
|
.context("validated status_code is absent")?,
|
||||||
|
bytes_sent: event.bytes_sent.context("validated bytes_sent is absent")?,
|
||||||
|
time_request_ms: event
|
||||||
|
.time_request_ms
|
||||||
|
.context("validated time_request_ms is absent")?,
|
||||||
|
time_wait_ms: event
|
||||||
|
.time_wait_ms
|
||||||
|
.context("validated time_wait_ms is absent")?,
|
||||||
|
time_connect_ms: event
|
||||||
|
.time_connect_ms
|
||||||
|
.context("validated time_connect_ms is absent")?,
|
||||||
|
time_response_ms: event
|
||||||
|
.time_response_ms
|
||||||
|
.context("validated time_response_ms is absent")?,
|
||||||
|
time_total_ms: event
|
||||||
|
.time_total_ms
|
||||||
|
.context("validated time_total_ms is absent")?,
|
||||||
|
termination_state: event.termination_state,
|
||||||
|
retries: event.retries.unwrap_or_default(),
|
||||||
|
tls_sni: event.tls_sni,
|
||||||
|
tls_version: event.tls_version,
|
||||||
|
tls_cipher: event.tls_cipher,
|
||||||
|
tls_resumed: event.tls_resumed,
|
||||||
|
normalization_flags,
|
||||||
|
active_connections: event.active_connections,
|
||||||
|
frontend_connections: event.frontend_connections,
|
||||||
|
backend_connections: event.backend_connections,
|
||||||
|
server_connections: event.server_connections,
|
||||||
|
server_queue: event.server_queue,
|
||||||
|
backend_queue: event.backend_queue,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn rejected_event(message: &str, reason: &str) -> RejectedEvent {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(message.as_bytes());
|
||||||
|
let prefix = truncate_utf8(message, RAW_PREFIX_LIMIT);
|
||||||
|
RejectedEvent {
|
||||||
|
reason: truncate_utf8(reason, 1_024).to_owned(),
|
||||||
|
message_sha256: hasher.finalize().to_vec(),
|
||||||
|
raw_message_prefix: Some(prefix.to_owned()),
|
||||||
|
raw_message_truncated: prefix.len() != message.len(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn deserialize_optional_string<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
match Option::<String>::deserialize(deserializer)? {
|
||||||
|
Some(value) if is_empty_or_dash(&value) => Ok(None),
|
||||||
|
value => Ok(value),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
enum NumberOrString<T> {
|
||||||
|
Number(T),
|
||||||
|
String(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
fn deserialize_optional_number<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
T: Deserialize<'de> + FromStr,
|
||||||
|
T::Err: Display,
|
||||||
|
{
|
||||||
|
match Option::<NumberOrString<T>>::deserialize(deserializer)? {
|
||||||
|
None => Ok(None),
|
||||||
|
Some(NumberOrString::Number(value)) => Ok(Some(value)),
|
||||||
|
Some(NumberOrString::String(value)) if is_empty_or_dash(&value) => Ok(None),
|
||||||
|
Some(NumberOrString::String(value)) => value.parse().map(Some).map_err(D::Error::custom),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
enum BoolValue {
|
||||||
|
Bool(bool),
|
||||||
|
Integer(i64),
|
||||||
|
String(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
fn deserialize_optional_bool<'de, D>(deserializer: D) -> Result<Option<bool>, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
match Option::<BoolValue>::deserialize(deserializer)? {
|
||||||
|
None => Ok(None),
|
||||||
|
Some(BoolValue::Bool(value)) => Ok(Some(value)),
|
||||||
|
Some(BoolValue::Integer(0)) => Ok(Some(false)),
|
||||||
|
Some(BoolValue::Integer(1)) => Ok(Some(true)),
|
||||||
|
Some(BoolValue::String(value)) if is_empty_or_dash(&value) => Ok(None),
|
||||||
|
Some(BoolValue::String(value)) if matches!(value.as_str(), "0" | "false") => {
|
||||||
|
Ok(Some(false))
|
||||||
|
}
|
||||||
|
Some(BoolValue::String(value)) if matches!(value.as_str(), "1" | "true") => Ok(Some(true)),
|
||||||
|
Some(BoolValue::Integer(_) | BoolValue::String(_)) => {
|
||||||
|
Err(D::Error::custom("expected a boolean, 0, 1, null, or '-'"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_empty_or_dash(value: &str) -> bool {
|
||||||
|
value.is_empty() || value == "-"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn truncate_utf8(value: &str, max_bytes: usize) -> &str {
|
||||||
|
if value.len() <= max_bytes {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
let mut end = max_bytes;
|
||||||
|
while !value.is_char_boundary(end) {
|
||||||
|
end = end.saturating_sub(1);
|
||||||
|
}
|
||||||
|
value.get(..end).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[allow(clippy::panic, clippy::unwrap_used)]
|
||||||
|
mod tests {
|
||||||
|
use haproxy_ingest_proto::proto::log_record;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn valid_json(extra: &str) -> String {
|
||||||
|
format!(
|
||||||
|
r#"{{"client_ip":"192.0.2.10","status_code":200,"bytes_sent":12,"time_request_ms":-1,"time_wait_ms":0,"time_connect_ms":3,"time_response_ms":4,"time_total_ms":7{extra}}}"#
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn preserves_negative_timer_sentinels() {
|
||||||
|
let record = map_message(&valid_json(""), 10);
|
||||||
|
let Some(log_record::Payload::Event(event)) = record.payload else {
|
||||||
|
panic!("expected mapped event");
|
||||||
|
};
|
||||||
|
assert_eq!(event.time_request_ms, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn serde_parses_the_full_u64_range() {
|
||||||
|
let message =
|
||||||
|
valid_json("").replace("\"bytes_sent\":12", "\"bytes_sent\":18446744073709551615");
|
||||||
|
let event: RawHaProxyEvent = serde_json::from_str(&message).unwrap();
|
||||||
|
assert_eq!(event.bytes_sent, Some(u64::MAX));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn serde_coerces_haproxy_values() {
|
||||||
|
let message = r#"{
|
||||||
|
"client_port":"443",
|
||||||
|
"frontend_name":"-",
|
||||||
|
"status_code":"200",
|
||||||
|
"bytes_sent":"12",
|
||||||
|
"time_request_ms":"-1",
|
||||||
|
"time_wait_ms":"0",
|
||||||
|
"time_connect_ms":"3",
|
||||||
|
"time_response_ms":"4",
|
||||||
|
"time_total_ms":"7",
|
||||||
|
"retries":"1",
|
||||||
|
"tls_resumed":"true",
|
||||||
|
"server_queue":null
|
||||||
|
}"#;
|
||||||
|
let record = map_message(message, 10);
|
||||||
|
let Some(log_record::Payload::Event(event)) = record.payload else {
|
||||||
|
panic!("expected mapped event");
|
||||||
|
};
|
||||||
|
assert_eq!(event.client_port, Some(443));
|
||||||
|
assert_eq!(event.frontend_name, None);
|
||||||
|
assert_eq!(event.status_code, 200);
|
||||||
|
assert_eq!(event.time_request_ms, -1);
|
||||||
|
assert_eq!(event.retries, 1);
|
||||||
|
assert_eq!(event.tls_resumed, Some(true));
|
||||||
|
assert_eq!(event.server_queue, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn garde_rejects_invalid_semantics() {
|
||||||
|
let messages = [
|
||||||
|
valid_json("").replace("\"status_code\":200", "\"status_code\":99"),
|
||||||
|
valid_json("").replace("\"time_total_ms\":7", "\"time_total_ms\":-2"),
|
||||||
|
valid_json(",\"client_port\":65536"),
|
||||||
|
];
|
||||||
|
for message in messages {
|
||||||
|
let record = map_message(&message, 10);
|
||||||
|
let Some(log_record::Payload::Rejected(rejected)) = record.payload else {
|
||||||
|
panic!("expected rejected event");
|
||||||
|
};
|
||||||
|
assert!(rejected.reason.contains("invalid HAProxy event"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_ip_is_non_fatal_and_flagged() {
|
||||||
|
let message = valid_json("").replace("192.0.2.10", "not-an-ip");
|
||||||
|
let record = map_message(&message, 10);
|
||||||
|
let Some(log_record::Payload::Event(event)) = record.payload else {
|
||||||
|
panic!("expected mapped event");
|
||||||
|
};
|
||||||
|
assert_eq!(event.client_ip, None);
|
||||||
|
assert_eq!(event.normalization_flags, ["invalid_client_ip"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn preserves_request_path() {
|
||||||
|
let path = format!("/{}é", "a".repeat(20_000));
|
||||||
|
let message = valid_json(&format!(",\"request_path\":{path:?}"));
|
||||||
|
let record = map_message(&message, 10);
|
||||||
|
let Some(log_record::Payload::Event(event)) = record.payload else {
|
||||||
|
panic!("expected mapped event");
|
||||||
|
};
|
||||||
|
assert_eq!(event.request_path.as_deref(), Some(path.as_str()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn malformed_event_becomes_durable_rejection() {
|
||||||
|
let record = map_message("not-json", 10);
|
||||||
|
let Some(log_record::Payload::Rejected(rejected)) = record.payload else {
|
||||||
|
panic!("expected rejected event");
|
||||||
|
};
|
||||||
|
assert_eq!(rejected.message_sha256.len(), 32);
|
||||||
|
assert!(rejected.reason.contains("invalid HAProxy JSON"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
[package]
|
||||||
|
name = "tls"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
description = "Shared AWS-LC TLS provider initialization"
|
||||||
|
license = "MIT"
|
||||||
|
publish = false
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow.workspace = true
|
||||||
|
rustls.workspace = true
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
//! Shared TLS configuration for workspace applications.
|
||||||
|
|
||||||
|
use anyhow::{Result, anyhow};
|
||||||
|
|
||||||
|
/// Install AWS-LC as the process-wide default TLS crypto provider.
|
||||||
|
///
|
||||||
|
/// Call once at startup, before creating HTTP clients.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error if a TLS crypto provider has already been initialized.
|
||||||
|
pub fn install_tls_provider() -> Result<()> {
|
||||||
|
rustls::crypto::aws_lc_rs::default_provider()
|
||||||
|
.install_default()
|
||||||
|
.map_err(|_| anyhow!("TLS crypto provider was already initialized"))
|
||||||
|
}
|
||||||
@@ -12,9 +12,10 @@ clap = { workspace = true, features = ["derive", "env"] }
|
|||||||
env_logger.workspace = true
|
env_logger.workspace = true
|
||||||
log.workspace = true
|
log.workspace = true
|
||||||
num-traits = "0.2"
|
num-traits = "0.2"
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
|
reqwest = { workspace = true, features = ["blocking", "json"] }
|
||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
tls.workspace = true
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
@@ -13,6 +13,7 @@ use reqwest::{
|
|||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
|
use tls::install_tls_provider;
|
||||||
|
|
||||||
const LAT_ENTITY: &str = "sensor.van_last_known_latitude";
|
const LAT_ENTITY: &str = "sensor.van_last_known_latitude";
|
||||||
const LON_ENTITY: &str = "sensor.van_last_known_longitude";
|
const LON_ENTITY: &str = "sensor.van_last_known_longitude";
|
||||||
@@ -159,6 +160,7 @@ fn main() -> Result<()> {
|
|||||||
} = Args::parse();
|
} = Args::parse();
|
||||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or(log_level.as_str()))
|
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or(log_level.as_str()))
|
||||||
.init();
|
.init();
|
||||||
|
install_tls_provider()?;
|
||||||
|
|
||||||
let ha_url = ha_url.trim_end_matches('/').to_owned();
|
let ha_url = ha_url.trim_end_matches('/').to_owned();
|
||||||
let ha_host = Url::parse(&ha_url)
|
let ha_host = Url::parse(&ha_url)
|
||||||
@@ -494,6 +496,14 @@ fn percent(value: Option<f64>) -> i64 {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn builds_http_clients_with_aws_lc() -> Result<()> {
|
||||||
|
install_tls_provider()?;
|
||||||
|
build_client("home-assistant.example", Some("test-token"))?;
|
||||||
|
build_client(PIRATE_WEATHER_HOST, None)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn maps_conditions_for_home_assistant() {
|
fn maps_conditions_for_home_assistant() {
|
||||||
assert_eq!(condition(Some("clear-day")), "sunny");
|
assert_eq!(condition(Some("clear-day")), "sunny");
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ let
|
|||||||
gitea_http = 6443;
|
gitea_http = 6443;
|
||||||
gitea_ssh = 2223;
|
gitea_ssh = 2223;
|
||||||
grafana = 3000;
|
grafana = 3000;
|
||||||
|
haproxy_ingest = 8444;
|
||||||
jellyfin_http = 8096;
|
jellyfin_http = 8096;
|
||||||
jellyswarrm = 3100;
|
jellyswarrm = 3100;
|
||||||
nix_binary_cache = 5000;
|
nix_binary_cache = 5000;
|
||||||
@@ -26,6 +27,7 @@ in
|
|||||||
interfaces.tailscale0.allowedTCPPorts = [
|
interfaces.tailscale0.allowedTCPPorts = [
|
||||||
ports.audiobookshelf
|
ports.audiobookshelf
|
||||||
ports.gems
|
ports.gems
|
||||||
|
ports.haproxy_ingest
|
||||||
ports.jellyfin_http
|
ports.jellyfin_http
|
||||||
ports.jellyswarrm
|
ports.jellyswarrm
|
||||||
ports.nix_binary_cache
|
ports.nix_binary_cache
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
{
|
||||||
|
pkgs,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
let
|
||||||
|
vars = import ../vars.nix;
|
||||||
|
haproxy_ingest_server = pkgs.callPackage ../../../rust/haproxy_ingest_server/package.nix { };
|
||||||
|
in
|
||||||
|
{
|
||||||
|
users = {
|
||||||
|
groups.haproxy-ingest = { };
|
||||||
|
users.haproxy-ingest = {
|
||||||
|
isSystemUser = true;
|
||||||
|
group = "haproxy-ingest";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
systemd = {
|
||||||
|
services = {
|
||||||
|
haproxy-ingest-server = {
|
||||||
|
description = "HAProxy log ingestion API";
|
||||||
|
documentation = [ "file://${../../../rust/haproxy_ingest_proto/README.md}" ];
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
after = [
|
||||||
|
"network-online.target"
|
||||||
|
"postgresql.service"
|
||||||
|
"tailscaled-autoconnect.service"
|
||||||
|
];
|
||||||
|
wants = [
|
||||||
|
"network-online.target"
|
||||||
|
"postgresql.service"
|
||||||
|
"tailscaled-autoconnect.service"
|
||||||
|
];
|
||||||
|
|
||||||
|
serviceConfig = {
|
||||||
|
Type = "simple";
|
||||||
|
User = "haproxy-ingest";
|
||||||
|
Group = "haproxy-ingest";
|
||||||
|
ExecStart = "${haproxy_ingest_server}/bin/haproxy-ingest-server";
|
||||||
|
Environment = [
|
||||||
|
"HAPROXY_INGEST_DATABASE_URL=postgres://haproxy_ingest@localhost/haproxy_ingest?host=/run/postgresql&sslmode=disable"
|
||||||
|
"HAPROXY_INGEST_LISTEN=0.0.0.0:8444"
|
||||||
|
"RUST_LOG=info,tower_http=warn"
|
||||||
|
];
|
||||||
|
EnvironmentFile = "${vars.secrets}/services/haproxy-ingest-server";
|
||||||
|
Restart = "on-failure";
|
||||||
|
RestartSec = "5s";
|
||||||
|
|
||||||
|
CapabilityBoundingSet = [ "" ];
|
||||||
|
DeviceAllow = [ "" ];
|
||||||
|
LockPersonality = true;
|
||||||
|
MemoryDenyWriteExecute = true;
|
||||||
|
NoNewPrivileges = true;
|
||||||
|
PrivateDevices = true;
|
||||||
|
PrivateTmp = true;
|
||||||
|
ProtectClock = true;
|
||||||
|
ProtectControlGroups = true;
|
||||||
|
ProtectHome = true;
|
||||||
|
ProtectHostname = true;
|
||||||
|
ProtectKernelLogs = true;
|
||||||
|
ProtectKernelModules = true;
|
||||||
|
ProtectKernelTunables = true;
|
||||||
|
ProtectSystem = "strict";
|
||||||
|
RemoveIPC = true;
|
||||||
|
RestrictAddressFamilies = [
|
||||||
|
"AF_INET"
|
||||||
|
"AF_UNIX"
|
||||||
|
];
|
||||||
|
RestrictNamespaces = true;
|
||||||
|
RestrictRealtime = true;
|
||||||
|
RestrictSUIDSGID = true;
|
||||||
|
SystemCallArchitectures = "native";
|
||||||
|
SystemCallFilter = [
|
||||||
|
"@system-service"
|
||||||
|
"~@privileged"
|
||||||
|
];
|
||||||
|
UMask = "0077";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -36,6 +36,9 @@ in
|
|||||||
# signalbot
|
# signalbot
|
||||||
local signalbot signalbot trust
|
local signalbot signalbot trust
|
||||||
|
|
||||||
|
# HAProxy cloud log ingestion API
|
||||||
|
local haproxy_ingest haproxy_ingest trust
|
||||||
|
|
||||||
# math
|
# math
|
||||||
local postgres math trust
|
local postgres math trust
|
||||||
host postgres math 127.0.0.1/32 trust
|
host postgres math 127.0.0.1/32 trust
|
||||||
@@ -97,6 +100,13 @@ in
|
|||||||
replication = true;
|
replication = true;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
name = "haproxy_ingest";
|
||||||
|
ensureDBOwnership = true;
|
||||||
|
ensureClauses = {
|
||||||
|
login = true;
|
||||||
|
};
|
||||||
|
}
|
||||||
{
|
{
|
||||||
name = "signalbot";
|
name = "signalbot";
|
||||||
ensureDBOwnership = true;
|
ensureDBOwnership = true;
|
||||||
@@ -108,6 +118,7 @@ in
|
|||||||
ensureDatabases = [
|
ensureDatabases = [
|
||||||
"data_science_dev"
|
"data_science_dev"
|
||||||
"gitea"
|
"gitea"
|
||||||
|
"haproxy_ingest"
|
||||||
"math"
|
"math"
|
||||||
"n8n"
|
"n8n"
|
||||||
"richie"
|
"richie"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
let
|
let
|
||||||
domains = [
|
domains = [
|
||||||
"audiobookshelf"
|
"audiobookshelf"
|
||||||
|
"auth"
|
||||||
"cache"
|
"cache"
|
||||||
"gitea"
|
"gitea"
|
||||||
"gems"
|
"gems"
|
||||||
@@ -19,6 +20,8 @@ let
|
|||||||
acmeServices = map (domain: "acme-${domain}.tmmworkshop.com.service") domains;
|
acmeServices = map (domain: "acme-${domain}.tmmworkshop.com.service") domains;
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
|
imports = [ ./ingest.nix ];
|
||||||
|
|
||||||
networking.firewall.allowedTCPPorts = [
|
networking.firewall.allowedTCPPorts = [
|
||||||
80
|
80
|
||||||
443
|
443
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
{ pkgs, ... }:
|
||||||
|
let
|
||||||
|
haproxy_log_shipper = pkgs.callPackage ../../../rust/haproxy_log_shipper/package.nix { };
|
||||||
|
endpoint = "http://jeeves.taile39c31.ts.net:8444";
|
||||||
|
environment_file = "/var/lib/haproxy-log-shipper/environment";
|
||||||
|
in
|
||||||
|
{
|
||||||
|
services.journald.settings.Journal = {
|
||||||
|
Storage = "persistent";
|
||||||
|
SystemMaxUse = "512M";
|
||||||
|
MaxRetentionSec = "14day";
|
||||||
|
};
|
||||||
|
|
||||||
|
users = {
|
||||||
|
groups.haproxy-log-shipper = { };
|
||||||
|
users.haproxy-log-shipper = {
|
||||||
|
isSystemUser = true;
|
||||||
|
group = "haproxy-log-shipper";
|
||||||
|
extraGroups = [ "systemd-journal" ];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
systemd = {
|
||||||
|
services = {
|
||||||
|
haproxy = {
|
||||||
|
serviceConfig = {
|
||||||
|
StandardOutput = "journal";
|
||||||
|
StandardError = "journal";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
haproxy-log-shipper = {
|
||||||
|
description = "Ship ordered HAProxy journal logs to Jeeves";
|
||||||
|
documentation = [ "file://${../../../rust/haproxy_ingest_proto/README.md}" ];
|
||||||
|
after = [
|
||||||
|
"network-online.target"
|
||||||
|
"tailscaled-autoconnect.service"
|
||||||
|
];
|
||||||
|
wants = [
|
||||||
|
"network-online.target"
|
||||||
|
"tailscaled-autoconnect.service"
|
||||||
|
];
|
||||||
|
|
||||||
|
serviceConfig = {
|
||||||
|
Type = "oneshot";
|
||||||
|
User = "haproxy-log-shipper";
|
||||||
|
Group = "haproxy-log-shipper";
|
||||||
|
SupplementaryGroups = [ "systemd-journal" ];
|
||||||
|
ExecStart = "${haproxy_log_shipper}/bin/haproxy-log-shipper";
|
||||||
|
Environment = [
|
||||||
|
"HAPROXY_INGEST_ENDPOINT=${endpoint}"
|
||||||
|
"RUST_LOG=info"
|
||||||
|
];
|
||||||
|
EnvironmentFile = environment_file;
|
||||||
|
|
||||||
|
CapabilityBoundingSet = [ "" ];
|
||||||
|
DeviceAllow = [ "" ];
|
||||||
|
LockPersonality = true;
|
||||||
|
MemoryDenyWriteExecute = true;
|
||||||
|
NoNewPrivileges = true;
|
||||||
|
PrivateDevices = true;
|
||||||
|
PrivateTmp = true;
|
||||||
|
ProtectClock = true;
|
||||||
|
ProtectControlGroups = true;
|
||||||
|
ProtectHome = true;
|
||||||
|
ProtectHostname = true;
|
||||||
|
ProtectKernelLogs = true;
|
||||||
|
ProtectKernelModules = true;
|
||||||
|
ProtectKernelTunables = true;
|
||||||
|
ProtectSystem = "strict";
|
||||||
|
RemoveIPC = true;
|
||||||
|
RestrictAddressFamilies = [
|
||||||
|
"AF_INET"
|
||||||
|
"AF_UNIX"
|
||||||
|
];
|
||||||
|
RestrictNamespaces = true;
|
||||||
|
RestrictRealtime = true;
|
||||||
|
RestrictSUIDSGID = true;
|
||||||
|
SystemCallArchitectures = "native";
|
||||||
|
SystemCallFilter = [
|
||||||
|
"@system-service"
|
||||||
|
"~@privileged"
|
||||||
|
];
|
||||||
|
UMask = "0077";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
timers.haproxy-log-shipper = {
|
||||||
|
description = "Run the HAProxy log shipper after each completed catch-up cycle";
|
||||||
|
wantedBy = [ "timers.target" ];
|
||||||
|
timerConfig = {
|
||||||
|
OnBootSec = "30s";
|
||||||
|
OnUnitInactiveSec = "60s";
|
||||||
|
Unit = "haproxy-log-shipper.service";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
tmpfiles.rules = [
|
||||||
|
"d /var/lib/haproxy-log-shipper 0755 root root - -"
|
||||||
|
];
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in new issue
Block a user