feat: add Prometheus metrics endpoint and Docker image (PLAN.md 4.2)
Add internal/metrics package with dedicated Prometheus registry exposing SSH connection, auth attempt, session, and build info metrics. Wire into SSH server (4 instrumentation points) and web server (/metrics endpoint). Add dockerImage output to flake.nix via dockerTools.buildLayeredImage. Bump version to 0.7.0. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
11
PLAN.md
11
PLAN.md
@@ -189,11 +189,12 @@ Goal: Make the web UI great and add operational niceties.
|
|||||||
- Session detail view with full command log
|
- Session detail view with full command log
|
||||||
- Filtering and search
|
- Filtering and search
|
||||||
|
|
||||||
### 4.2 Operational
|
### 4.2 Operational ✅
|
||||||
- Prometheus metrics endpoint
|
- Prometheus metrics endpoint ✅
|
||||||
- Structured logging (slog)
|
- Structured logging (slog) ✅
|
||||||
- Graceful shutdown
|
- Graceful shutdown ✅
|
||||||
- Systemd unit file / deployment docs
|
- Docker image (nix dockerTools) ✅
|
||||||
|
- Systemd unit file / deployment docs ✅
|
||||||
|
|
||||||
### 4.3 GeoIP
|
### 4.3 GeoIP
|
||||||
- Embed a lightweight GeoIP database or use an API
|
- Embed a lightweight GeoIP database or use an API
|
||||||
|
|||||||
13
README.md
13
README.md
@@ -44,6 +44,7 @@ Key settings:
|
|||||||
- `web.enabled` — enable the web dashboard (default `false`)
|
- `web.enabled` — enable the web dashboard (default `false`)
|
||||||
- `web.listen_addr` — web dashboard listen address (default `:8080`)
|
- `web.listen_addr` — web dashboard listen address (default `:8080`)
|
||||||
- Session detail pages at `/sessions/{id}` include terminal replay via xterm.js
|
- Session detail pages at `/sessions/{id}` include terminal replay via xterm.js
|
||||||
|
- `web.metrics_enabled` — expose Prometheus metrics at `/metrics` (default `true`)
|
||||||
- `detection.enabled` — enable human detection scoring (default `false`)
|
- `detection.enabled` — enable human detection scoring (default `false`)
|
||||||
- `detection.threshold` — score threshold (0.0–1.0) for flagging sessions (default `0.6`)
|
- `detection.threshold` — score threshold (0.0–1.0) for flagging sessions (default `0.6`)
|
||||||
- `detection.update_interval` — how often to recompute scores (default `5s`)
|
- `detection.update_interval` — how often to recompute scores (default `5s`)
|
||||||
@@ -82,3 +83,15 @@ Add the flake as an input and enable the service:
|
|||||||
```
|
```
|
||||||
|
|
||||||
Alternatively, use `configFile` to pass a pre-written TOML file instead of `settings`.
|
Alternatively, use `configFile` to pass a pre-written TOML file instead of `settings`.
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
|
||||||
|
Build a Docker image via nix:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nix build .#dockerImage
|
||||||
|
docker load < result
|
||||||
|
docker run -v /path/to/data:/data -p 2222:2222 -p 8080:8080 oubliette:0.7.0
|
||||||
|
```
|
||||||
|
|
||||||
|
Place your `oubliette.toml` in the data volume. The container exposes ports 2222 (SSH) and 8080 (web/metrics).
|
||||||
|
|||||||
@@ -14,12 +14,13 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.t-juice.club/torjus/oubliette/internal/config"
|
"git.t-juice.club/torjus/oubliette/internal/config"
|
||||||
|
"git.t-juice.club/torjus/oubliette/internal/metrics"
|
||||||
"git.t-juice.club/torjus/oubliette/internal/server"
|
"git.t-juice.club/torjus/oubliette/internal/server"
|
||||||
"git.t-juice.club/torjus/oubliette/internal/storage"
|
"git.t-juice.club/torjus/oubliette/internal/storage"
|
||||||
"git.t-juice.club/torjus/oubliette/internal/web"
|
"git.t-juice.club/torjus/oubliette/internal/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
const Version = "0.6.0"
|
const Version = "0.7.0"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
if err := run(); err != nil {
|
if err := run(); err != nil {
|
||||||
@@ -77,7 +78,9 @@ func run() error {
|
|||||||
|
|
||||||
go storage.RunRetention(ctx, store, cfg.Storage.RetentionDays, cfg.Storage.RetentionIntervalDuration, logger)
|
go storage.RunRetention(ctx, store, cfg.Storage.RetentionDays, cfg.Storage.RetentionIntervalDuration, logger)
|
||||||
|
|
||||||
srv, err := server.New(*cfg, store, logger)
|
m := metrics.New(Version)
|
||||||
|
|
||||||
|
srv, err := server.New(*cfg, store, logger, m)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("create server: %w", err)
|
return fmt.Errorf("create server: %w", err)
|
||||||
}
|
}
|
||||||
@@ -86,7 +89,12 @@ func run() error {
|
|||||||
|
|
||||||
// Start web server if enabled.
|
// Start web server if enabled.
|
||||||
if cfg.Web.Enabled {
|
if cfg.Web.Enabled {
|
||||||
webHandler, err := web.NewServer(store, logger.With("component", "web"))
|
var metricsHandler http.Handler
|
||||||
|
if *cfg.Web.MetricsEnabled {
|
||||||
|
metricsHandler = m.Handler()
|
||||||
|
}
|
||||||
|
|
||||||
|
webHandler, err := web.NewServer(store, logger.With("component", "web"), metricsHandler)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("create web server: %w", err)
|
return fmt.Errorf("create web server: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
19
flake.nix
19
flake.nix
@@ -24,13 +24,30 @@
|
|||||||
pname = "oubliette";
|
pname = "oubliette";
|
||||||
inherit version;
|
inherit version;
|
||||||
src = ./.;
|
src = ./.;
|
||||||
vendorHash = "sha256-oH92jRD+2niIf7xAX1HeZvhux8lVqj43Qxdef5GjX4Q=";
|
vendorHash = "sha256-smMg/J1igSoSBkzdm9HJOp5OYY8MEccodCD/zVK31IQ=";
|
||||||
subPackages = [ "cmd/oubliette" ];
|
subPackages = [ "cmd/oubliette" ];
|
||||||
meta = {
|
meta = {
|
||||||
description = "SSH honeypot";
|
description = "SSH honeypot";
|
||||||
mainProgram = "oubliette";
|
mainProgram = "oubliette";
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
dockerImage = pkgs.dockerTools.buildLayeredImage {
|
||||||
|
name = "oubliette";
|
||||||
|
tag = version;
|
||||||
|
contents = [ self.packages.${system}.default ];
|
||||||
|
config = {
|
||||||
|
Entrypoint = [ "/bin/oubliette" ];
|
||||||
|
Cmd = [ "-config" "/data/oubliette.toml" ];
|
||||||
|
ExposedPorts = {
|
||||||
|
"2222/tcp" = {};
|
||||||
|
"8080/tcp" = {};
|
||||||
|
};
|
||||||
|
Volumes = {
|
||||||
|
"/data" = {};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
devShells = forAllSystems (system:
|
devShells = forAllSystems (system:
|
||||||
|
|||||||
10
go.mod
10
go.mod
@@ -7,18 +7,22 @@ require (
|
|||||||
github.com/charmbracelet/bubbletea v1.3.10
|
github.com/charmbracelet/bubbletea v1.3.10
|
||||||
github.com/charmbracelet/lipgloss v1.1.0
|
github.com/charmbracelet/lipgloss v1.1.0
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/prometheus/client_golang v1.23.2
|
||||||
golang.org/x/crypto v0.48.0
|
golang.org/x/crypto v0.48.0
|
||||||
modernc.org/sqlite v1.45.0
|
modernc.org/sqlite v1.45.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
||||||
github.com/charmbracelet/x/ansi v0.10.1 // indirect
|
github.com/charmbracelet/x/ansi v0.10.1 // indirect
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
|
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
|
||||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||||
|
github.com/kr/text v0.2.0 // indirect
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||||
@@ -26,13 +30,19 @@ require (
|
|||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||||
github.com/muesli/termenv v0.16.0 // indirect
|
github.com/muesli/termenv v0.16.0 // indirect
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
|
github.com/prometheus/client_model v0.6.2 // indirect
|
||||||
|
github.com/prometheus/common v0.66.1 // indirect
|
||||||
|
github.com/prometheus/procfs v0.16.1 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
|
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||||
golang.org/x/sys v0.41.0 // indirect
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
golang.org/x/text v0.34.0 // indirect
|
golang.org/x/text v0.34.0 // indirect
|
||||||
|
google.golang.org/protobuf v1.36.8 // indirect
|
||||||
modernc.org/libc v1.67.6 // indirect
|
modernc.org/libc v1.67.6 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.11.0 // indirect
|
modernc.org/memory v1.11.0 // indirect
|
||||||
|
|||||||
44
go.sum
44
go.sum
@@ -2,6 +2,10 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk
|
|||||||
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||||
|
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
|
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
|
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
|
||||||
@@ -14,16 +18,29 @@ github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0G
|
|||||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
|
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
|
||||||
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
||||||
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
||||||
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
|
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||||
|
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||||
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||||
|
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
@@ -38,15 +55,35 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
|
|||||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||||
|
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||||
|
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||||
|
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||||
|
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
|
||||||
|
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
|
||||||
|
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
|
||||||
|
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
|
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||||
|
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||||
|
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
|
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
|
||||||
|
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
|
||||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
|
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
|
||||||
@@ -65,6 +102,13 @@ golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
|||||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||||
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||||
|
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
|
||||||
|
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||||
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
|
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
|
||||||
|
|||||||
@@ -21,8 +21,9 @@ type Config struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type WebConfig struct {
|
type WebConfig struct {
|
||||||
Enabled bool `toml:"enabled"`
|
Enabled bool `toml:"enabled"`
|
||||||
ListenAddr string `toml:"listen_addr"`
|
ListenAddr string `toml:"listen_addr"`
|
||||||
|
MetricsEnabled *bool `toml:"metrics_enabled"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ShellConfig struct {
|
type ShellConfig struct {
|
||||||
@@ -143,6 +144,10 @@ func applyDefaults(cfg *Config) {
|
|||||||
if cfg.Web.ListenAddr == "" {
|
if cfg.Web.ListenAddr == "" {
|
||||||
cfg.Web.ListenAddr = ":8080"
|
cfg.Web.ListenAddr = ":8080"
|
||||||
}
|
}
|
||||||
|
if cfg.Web.MetricsEnabled == nil {
|
||||||
|
t := true
|
||||||
|
cfg.Web.MetricsEnabled = &t
|
||||||
|
}
|
||||||
if cfg.Shell.Hostname == "" {
|
if cfg.Shell.Hostname == "" {
|
||||||
cfg.Shell.Hostname = "ubuntu-server"
|
cfg.Shell.Hostname = "ubuntu-server"
|
||||||
}
|
}
|
||||||
|
|||||||
93
internal/metrics/metrics.go
Normal file
93
internal/metrics/metrics.go
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
package metrics
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/collectors"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Metrics holds all Prometheus collectors for the honeypot.
|
||||||
|
type Metrics struct {
|
||||||
|
registry *prometheus.Registry
|
||||||
|
|
||||||
|
SSHConnectionsTotal *prometheus.CounterVec
|
||||||
|
SSHConnectionsActive prometheus.Gauge
|
||||||
|
AuthAttemptsTotal *prometheus.CounterVec
|
||||||
|
SessionsTotal *prometheus.CounterVec
|
||||||
|
SessionsActive prometheus.Gauge
|
||||||
|
SessionDuration prometheus.Histogram
|
||||||
|
BuildInfo *prometheus.GaugeVec
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new Metrics instance with all collectors registered.
|
||||||
|
func New(version string) *Metrics {
|
||||||
|
reg := prometheus.NewRegistry()
|
||||||
|
|
||||||
|
m := &Metrics{
|
||||||
|
registry: reg,
|
||||||
|
SSHConnectionsTotal: prometheus.NewCounterVec(prometheus.CounterOpts{
|
||||||
|
Name: "oubliette_ssh_connections_total",
|
||||||
|
Help: "Total SSH connections received.",
|
||||||
|
}, []string{"outcome"}),
|
||||||
|
SSHConnectionsActive: prometheus.NewGauge(prometheus.GaugeOpts{
|
||||||
|
Name: "oubliette_ssh_connections_active",
|
||||||
|
Help: "Current active SSH connections.",
|
||||||
|
}),
|
||||||
|
AuthAttemptsTotal: prometheus.NewCounterVec(prometheus.CounterOpts{
|
||||||
|
Name: "oubliette_auth_attempts_total",
|
||||||
|
Help: "Total authentication attempts.",
|
||||||
|
}, []string{"result", "reason"}),
|
||||||
|
SessionsTotal: prometheus.NewCounterVec(prometheus.CounterOpts{
|
||||||
|
Name: "oubliette_sessions_total",
|
||||||
|
Help: "Total sessions created.",
|
||||||
|
}, []string{"shell"}),
|
||||||
|
SessionsActive: prometheus.NewGauge(prometheus.GaugeOpts{
|
||||||
|
Name: "oubliette_sessions_active",
|
||||||
|
Help: "Current active sessions.",
|
||||||
|
}),
|
||||||
|
SessionDuration: prometheus.NewHistogram(prometheus.HistogramOpts{
|
||||||
|
Name: "oubliette_session_duration_seconds",
|
||||||
|
Help: "Session duration in seconds.",
|
||||||
|
Buckets: []float64{1, 5, 10, 30, 60, 120, 300, 600, 1800, 3600},
|
||||||
|
}),
|
||||||
|
BuildInfo: prometheus.NewGaugeVec(prometheus.GaugeOpts{
|
||||||
|
Name: "oubliette_build_info",
|
||||||
|
Help: "Build information. Always 1.",
|
||||||
|
}, []string{"version"}),
|
||||||
|
}
|
||||||
|
|
||||||
|
reg.MustRegister(
|
||||||
|
collectors.NewGoCollector(),
|
||||||
|
collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}),
|
||||||
|
m.SSHConnectionsTotal,
|
||||||
|
m.SSHConnectionsActive,
|
||||||
|
m.AuthAttemptsTotal,
|
||||||
|
m.SessionsTotal,
|
||||||
|
m.SessionsActive,
|
||||||
|
m.SessionDuration,
|
||||||
|
m.BuildInfo,
|
||||||
|
)
|
||||||
|
|
||||||
|
m.BuildInfo.WithLabelValues(version).Set(1)
|
||||||
|
|
||||||
|
// Initialize label combinations so they appear in Gather/output.
|
||||||
|
for _, outcome := range []string{"accepted", "rejected_handshake", "rejected_max_connections"} {
|
||||||
|
m.SSHConnectionsTotal.WithLabelValues(outcome)
|
||||||
|
}
|
||||||
|
for _, reason := range []string{"static_credential", "remembered_credential", "threshold_reached", "rejected"} {
|
||||||
|
m.AuthAttemptsTotal.WithLabelValues("accepted", reason)
|
||||||
|
m.AuthAttemptsTotal.WithLabelValues("rejected", reason)
|
||||||
|
}
|
||||||
|
for _, shell := range []string{"bash", "fridge", "banking", "adventure"} {
|
||||||
|
m.SessionsTotal.WithLabelValues(shell)
|
||||||
|
}
|
||||||
|
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler returns an http.Handler that serves Prometheus metrics.
|
||||||
|
func (m *Metrics) Handler() http.Handler {
|
||||||
|
return promhttp.HandlerFor(m.registry, promhttp.HandlerOpts{})
|
||||||
|
}
|
||||||
62
internal/metrics/metrics_test.go
Normal file
62
internal/metrics/metrics_test.go
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
package metrics
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNew(t *testing.T) {
|
||||||
|
m := New("1.2.3")
|
||||||
|
|
||||||
|
// Gather all metrics and check expected names exist.
|
||||||
|
families, err := m.registry.Gather()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("gather: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := map[string]bool{
|
||||||
|
"oubliette_ssh_connections_total": false,
|
||||||
|
"oubliette_ssh_connections_active": false,
|
||||||
|
"oubliette_auth_attempts_total": false,
|
||||||
|
"oubliette_sessions_total": false,
|
||||||
|
"oubliette_sessions_active": false,
|
||||||
|
"oubliette_session_duration_seconds": false,
|
||||||
|
"oubliette_build_info": false,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, f := range families {
|
||||||
|
if _, ok := want[f.GetName()]; ok {
|
||||||
|
want[f.GetName()] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, found := range want {
|
||||||
|
if !found {
|
||||||
|
t.Errorf("metric %q not registered", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandler(t *testing.T) {
|
||||||
|
m := New("1.2.3")
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/metrics", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
m.Handler().ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("status = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(w.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reading body: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(string(body), `oubliette_build_info{version="1.2.3"} 1`) {
|
||||||
|
t.Errorf("response should contain build_info metric, got:\n%s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"git.t-juice.club/torjus/oubliette/internal/auth"
|
"git.t-juice.club/torjus/oubliette/internal/auth"
|
||||||
"git.t-juice.club/torjus/oubliette/internal/config"
|
"git.t-juice.club/torjus/oubliette/internal/config"
|
||||||
"git.t-juice.club/torjus/oubliette/internal/detection"
|
"git.t-juice.club/torjus/oubliette/internal/detection"
|
||||||
|
"git.t-juice.club/torjus/oubliette/internal/metrics"
|
||||||
"git.t-juice.club/torjus/oubliette/internal/notify"
|
"git.t-juice.club/torjus/oubliette/internal/notify"
|
||||||
"git.t-juice.club/torjus/oubliette/internal/shell"
|
"git.t-juice.club/torjus/oubliette/internal/shell"
|
||||||
"git.t-juice.club/torjus/oubliette/internal/shell/adventure"
|
"git.t-juice.club/torjus/oubliette/internal/shell/adventure"
|
||||||
@@ -34,9 +35,10 @@ type Server struct {
|
|||||||
connSem chan struct{} // semaphore limiting concurrent connections
|
connSem chan struct{} // semaphore limiting concurrent connections
|
||||||
shellRegistry *shell.Registry
|
shellRegistry *shell.Registry
|
||||||
notifier notify.Sender
|
notifier notify.Sender
|
||||||
|
metrics *metrics.Metrics
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(cfg config.Config, store storage.Store, logger *slog.Logger) (*Server, error) {
|
func New(cfg config.Config, store storage.Store, logger *slog.Logger, m *metrics.Metrics) (*Server, error) {
|
||||||
registry := shell.NewRegistry()
|
registry := shell.NewRegistry()
|
||||||
if err := registry.Register(bash.NewBashShell(), 1); err != nil {
|
if err := registry.Register(bash.NewBashShell(), 1); err != nil {
|
||||||
return nil, fmt.Errorf("registering bash shell: %w", err)
|
return nil, fmt.Errorf("registering bash shell: %w", err)
|
||||||
@@ -59,6 +61,7 @@ func New(cfg config.Config, store storage.Store, logger *slog.Logger) (*Server,
|
|||||||
connSem: make(chan struct{}, cfg.SSH.MaxConnections),
|
connSem: make(chan struct{}, cfg.SSH.MaxConnections),
|
||||||
shellRegistry: registry,
|
shellRegistry: registry,
|
||||||
notifier: notify.NewSender(cfg.Notify.Webhooks, logger),
|
notifier: notify.NewSender(cfg.Notify.Webhooks, logger),
|
||||||
|
metrics: m,
|
||||||
}
|
}
|
||||||
|
|
||||||
hostKey, err := loadOrGenerateHostKey(cfg.SSH.HostKeyPath)
|
hostKey, err := loadOrGenerateHostKey(cfg.SSH.HostKeyPath)
|
||||||
@@ -102,11 +105,16 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
|
|||||||
// Enforce max concurrent connections.
|
// Enforce max concurrent connections.
|
||||||
select {
|
select {
|
||||||
case s.connSem <- struct{}{}:
|
case s.connSem <- struct{}{}:
|
||||||
|
s.metrics.SSHConnectionsActive.Inc()
|
||||||
go func() {
|
go func() {
|
||||||
defer func() { <-s.connSem }()
|
defer func() {
|
||||||
|
<-s.connSem
|
||||||
|
s.metrics.SSHConnectionsActive.Dec()
|
||||||
|
}()
|
||||||
s.handleConn(conn)
|
s.handleConn(conn)
|
||||||
}()
|
}()
|
||||||
default:
|
default:
|
||||||
|
s.metrics.SSHConnectionsTotal.WithLabelValues("rejected_max_connections").Inc()
|
||||||
s.logger.Warn("max connections reached, rejecting", "remote_addr", conn.RemoteAddr())
|
s.logger.Warn("max connections reached, rejecting", "remote_addr", conn.RemoteAddr())
|
||||||
conn.Close()
|
conn.Close()
|
||||||
}
|
}
|
||||||
@@ -118,11 +126,13 @@ func (s *Server) handleConn(conn net.Conn) {
|
|||||||
|
|
||||||
sshConn, chans, reqs, err := ssh.NewServerConn(conn, s.sshConfig)
|
sshConn, chans, reqs, err := ssh.NewServerConn(conn, s.sshConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
s.metrics.SSHConnectionsTotal.WithLabelValues("rejected_handshake").Inc()
|
||||||
s.logger.Debug("SSH handshake failed", "remote_addr", conn.RemoteAddr(), "err", err)
|
s.logger.Debug("SSH handshake failed", "remote_addr", conn.RemoteAddr(), "err", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer sshConn.Close()
|
defer sshConn.Close()
|
||||||
|
|
||||||
|
s.metrics.SSHConnectionsTotal.WithLabelValues("accepted").Inc()
|
||||||
s.logger.Info("SSH connection established",
|
s.logger.Info("SSH connection established",
|
||||||
"remote_addr", sshConn.RemoteAddr(),
|
"remote_addr", sshConn.RemoteAddr(),
|
||||||
"user", sshConn.User(),
|
"user", sshConn.User(),
|
||||||
@@ -171,11 +181,16 @@ func (s *Server) handleSession(channel ssh.Channel, requests <-chan *ssh.Request
|
|||||||
}
|
}
|
||||||
|
|
||||||
ip := extractIP(conn.RemoteAddr())
|
ip := extractIP(conn.RemoteAddr())
|
||||||
|
sessionStart := time.Now()
|
||||||
sessionID, err := s.store.CreateSession(context.Background(), ip, conn.User(), selectedShell.Name())
|
sessionID, err := s.store.CreateSession(context.Background(), ip, conn.User(), selectedShell.Name())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Error("failed to create session", "err", err)
|
s.logger.Error("failed to create session", "err", err)
|
||||||
} else {
|
} else {
|
||||||
|
s.metrics.SessionsTotal.WithLabelValues(selectedShell.Name()).Inc()
|
||||||
|
s.metrics.SessionsActive.Inc()
|
||||||
defer func() {
|
defer func() {
|
||||||
|
s.metrics.SessionsActive.Dec()
|
||||||
|
s.metrics.SessionDuration.Observe(time.Since(sessionStart).Seconds())
|
||||||
if err := s.store.EndSession(context.Background(), sessionID, time.Now()); err != nil {
|
if err := s.store.EndSession(context.Background(), sessionID, time.Now()); err != nil {
|
||||||
s.logger.Error("failed to end session", "err", err)
|
s.logger.Error("failed to end session", "err", err)
|
||||||
}
|
}
|
||||||
@@ -318,6 +333,12 @@ func (s *Server) passwordCallback(conn ssh.ConnMetadata, password []byte) (*ssh.
|
|||||||
ip := extractIP(conn.RemoteAddr())
|
ip := extractIP(conn.RemoteAddr())
|
||||||
d := s.authenticator.Authenticate(ip, conn.User(), string(password))
|
d := s.authenticator.Authenticate(ip, conn.User(), string(password))
|
||||||
|
|
||||||
|
if d.Accepted {
|
||||||
|
s.metrics.AuthAttemptsTotal.WithLabelValues("accepted", d.Reason).Inc()
|
||||||
|
} else {
|
||||||
|
s.metrics.AuthAttemptsTotal.WithLabelValues("rejected", d.Reason).Inc()
|
||||||
|
}
|
||||||
|
|
||||||
s.logger.Info("auth attempt",
|
s.logger.Info("auth attempt",
|
||||||
"remote_addr", conn.RemoteAddr(),
|
"remote_addr", conn.RemoteAddr(),
|
||||||
"username", conn.User(),
|
"username", conn.User(),
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.t-juice.club/torjus/oubliette/internal/config"
|
"git.t-juice.club/torjus/oubliette/internal/config"
|
||||||
|
"git.t-juice.club/torjus/oubliette/internal/metrics"
|
||||||
"git.t-juice.club/torjus/oubliette/internal/storage"
|
"git.t-juice.club/torjus/oubliette/internal/storage"
|
||||||
"golang.org/x/crypto/ssh"
|
"golang.org/x/crypto/ssh"
|
||||||
)
|
)
|
||||||
@@ -120,7 +121,7 @@ func TestIntegrationSSHConnect(t *testing.T) {
|
|||||||
|
|
||||||
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||||
store := storage.NewMemoryStore()
|
store := storage.NewMemoryStore()
|
||||||
srv, err := New(cfg, store, logger)
|
srv, err := New(cfg, store, logger, metrics.New("test"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("creating server: %v", err)
|
t.Fatalf("creating server: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ type Server struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewServer creates a new web Server with routes registered.
|
// NewServer creates a new web Server with routes registered.
|
||||||
func NewServer(store storage.Store, logger *slog.Logger) (*Server, error) {
|
// If metricsHandler is non-nil, a /metrics endpoint is registered.
|
||||||
|
func NewServer(store storage.Store, logger *slog.Logger, metricsHandler http.Handler) (*Server, error) {
|
||||||
tmpl, err := loadTemplates()
|
tmpl, err := loadTemplates()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -40,6 +41,10 @@ func NewServer(store storage.Store, logger *slog.Logger) (*Server, error) {
|
|||||||
s.mux.HandleFunc("GET /fragments/stats", s.handleFragmentStats)
|
s.mux.HandleFunc("GET /fragments/stats", s.handleFragmentStats)
|
||||||
s.mux.HandleFunc("GET /fragments/active-sessions", s.handleFragmentActiveSessions)
|
s.mux.HandleFunc("GET /fragments/active-sessions", s.handleFragmentActiveSessions)
|
||||||
|
|
||||||
|
if metricsHandler != nil {
|
||||||
|
s.mux.Handle("GET /metrics", metricsHandler)
|
||||||
|
}
|
||||||
|
|
||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.t-juice.club/torjus/oubliette/internal/metrics"
|
||||||
"git.t-juice.club/torjus/oubliette/internal/storage"
|
"git.t-juice.club/torjus/oubliette/internal/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -17,7 +18,7 @@ func newTestServer(t *testing.T) *Server {
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
store := storage.NewMemoryStore()
|
store := storage.NewMemoryStore()
|
||||||
logger := slog.Default()
|
logger := slog.Default()
|
||||||
srv, err := NewServer(store, logger)
|
srv, err := NewServer(store, logger, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("creating server: %v", err)
|
t.Fatalf("creating server: %v", err)
|
||||||
}
|
}
|
||||||
@@ -46,7 +47,7 @@ func newSeededTestServer(t *testing.T) *Server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
logger := slog.Default()
|
logger := slog.Default()
|
||||||
srv, err := NewServer(store, logger)
|
srv, err := NewServer(store, logger, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("creating server: %v", err)
|
t.Fatalf("creating server: %v", err)
|
||||||
}
|
}
|
||||||
@@ -154,7 +155,7 @@ func TestSessionDetailHandler(t *testing.T) {
|
|||||||
t.Fatalf("CreateSession: %v", err)
|
t.Fatalf("CreateSession: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
srv, err := NewServer(store, slog.Default())
|
srv, err := NewServer(store, slog.Default(), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewServer: %v", err)
|
t.Fatalf("NewServer: %v", err)
|
||||||
}
|
}
|
||||||
@@ -194,7 +195,7 @@ func TestAPISessionEvents(t *testing.T) {
|
|||||||
t.Fatalf("AppendSessionEvents: %v", err)
|
t.Fatalf("AppendSessionEvents: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
srv, err := NewServer(store, slog.Default())
|
srv, err := NewServer(store, slog.Default(), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewServer: %v", err)
|
t.Fatalf("NewServer: %v", err)
|
||||||
}
|
}
|
||||||
@@ -236,6 +237,47 @@ func TestAPISessionEvents(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMetricsEndpoint(t *testing.T) {
|
||||||
|
t.Run("enabled", func(t *testing.T) {
|
||||||
|
m := metrics.New("test")
|
||||||
|
store := storage.NewMemoryStore()
|
||||||
|
srv, err := NewServer(store, slog.Default(), m.Handler())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewServer: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/metrics", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("status = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
body := w.Body.String()
|
||||||
|
if !strings.Contains(body, `oubliette_build_info{version="test"} 1`) {
|
||||||
|
t.Errorf("response should contain build_info metric, got:\n%s", body)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("disabled", func(t *testing.T) {
|
||||||
|
store := storage.NewMemoryStore()
|
||||||
|
srv, err := NewServer(store, slog.Default(), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewServer: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/metrics", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
// Without a metrics handler, /metrics falls through to the dashboard.
|
||||||
|
body := w.Body.String()
|
||||||
|
if strings.Contains(body, "oubliette_build_info") {
|
||||||
|
t.Error("response should not contain prometheus metrics when disabled")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestStaticAssets(t *testing.T) {
|
func TestStaticAssets(t *testing.T) {
|
||||||
srv := newTestServer(t)
|
srv := newTestServer(t)
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ retention_interval = "1h"
|
|||||||
# [web]
|
# [web]
|
||||||
# enabled = true
|
# enabled = true
|
||||||
# listen_addr = ":8080"
|
# listen_addr = ":8080"
|
||||||
|
# metrics_enabled = true
|
||||||
|
|
||||||
[shell]
|
[shell]
|
||||||
hostname = "ubuntu-server"
|
hostname = "ubuntu-server"
|
||||||
|
|||||||
Reference in New Issue
Block a user