feat(haproxy): add initial implementation for HAProxy log ingestion
This commit is contained in:
1 parent
876683066c
commit
5fb721346f
5 files changed
+207
No files matched your search
@@ -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;
|
||||
Reference in new issue
Block a user