Initial commit: stats-gateway gRPC service + dashboard + CLI

This commit is contained in:
2026-05-05 21:04:32 +05:00
commit 4059e94759
44 changed files with 2513 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
bin/
*.exe
*.test
*.out
*.log
.DS_Store
.idea/
.vscode/
*.swp
*~
# experiment artifacts
data/snapshot.json
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 bergamot
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+12
View File
@@ -0,0 +1,12 @@
.PHONY: build test clean
build:
go build -o bin/stats-gateway ./cmd/server
go build -o bin/stats-collector ./cmd/collector
go build -o bin/stats-snapshot ./cmd/snapshot
test:
go test ./...
clean:
rm -rf bin/
+36
View File
@@ -0,0 +1,36 @@
# stats-gateway
gRPC service collecting and publishing HTTP/TCP/DNS/TLS-handshake timing
metrics for two geo-distributed OpenSpeedTest nodes (FL — Finland, PL1 — Poland).
Powers the dashboard at <https://stats.projectshitpost.fun/>.
## Components
- **`cmd/server`** — gRPC server (TLS terminated by nginx, listens on loopback).
- **`cmd/collector`** — periodic prober (HTTP HEAD with httptrace), runs every 5 min via systemd timer.
- **`cmd/snapshot`** — generates `snapshot.json` for the dashboard, runs every 10 min.
- **`internal/probes`** — timing logic (DNS, TCP, TLS handshake, HTTP).
- **`internal/storage`** — SQLite WAL with embedded migrations.
- **`internal/service`** — gRPC handlers (`RecentResults`, `Aggregate`, `Tunnel`).
- **`proto/`** — protobuf definitions; generated Go in `gen/`.
- **`stats-site/`** — static dashboard (vanilla JS + canvas charts, no framework deps).
- **`psp-stats-cli/`** — Go CLI for querying the API.
- **`systemd/`** — ops units for running the service + timers.
- **`docs/AUTH.md`** — auth doc for the `Tunnel` method (Bearer JWT/HS256, used by internal CI agents).
## Build
make build # ./bin/{stats-gateway,stats-collector,stats-snapshot}
make test
cd psp-stats-cli && make build
## API
grpcurl -d '{"node":"fl","limit":5}' \
stats.projectshitpost.fun:443 stats.v1.SpeedStatus/RecentResults
See `proto/stats.proto`.
## License
MIT — see [LICENSE](./LICENSE).
+47
View File
@@ -0,0 +1,47 @@
package main
import (
"context"
"flag"
"log"
"time"
"github.com/bergabruh/stats-gateway/internal/probes"
"github.com/bergabruh/stats-gateway/internal/storage"
)
func main() {
dbPath := flag.String("db", "/var/lib/stats-gateway/stats.db", "sqlite path")
flag.Parse()
st, err := storage.Open(*dbPath)
if err != nil {
log.Fatalf("open db: %v", err)
}
defer st.Close()
targets := []struct {
Node string
URL string
}{
{"fl", "https://fl.speedtest.projectshitpost.fun/"},
{"pl1", "https://speedtest.projectshitpost.fun/"},
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
for _, t := range targets {
r, err := probes.ProbeHTTP(ctx, t.URL)
if err != nil {
log.Printf("probe %s: %v", t.Node, err)
continue
}
if err := st.InsertSample(ctx, t.Node, r); err != nil {
log.Printf("store %s: %v", t.Node, err)
continue
}
log.Printf("probe %s ok: http=%.1fms tls=%.1fms",
t.Node, r.HTTPLatencyMs, r.TLSHandshakeMs)
}
}
+38
View File
@@ -0,0 +1,38 @@
package main
import (
"flag"
"log"
"net"
"google.golang.org/grpc"
statsv1 "github.com/bergabruh/stats-gateway/gen/stats/v1"
"github.com/bergabruh/stats-gateway/internal/service"
"github.com/bergabruh/stats-gateway/internal/storage"
)
func main() {
addr := flag.String("addr", "127.0.0.1:50051", "listen address")
dbPath := flag.String("db", "/var/lib/stats-gateway/stats.db", "sqlite path")
flag.Parse()
st, err := storage.Open(*dbPath)
if err != nil {
log.Fatalf("storage: %v", err)
}
defer st.Close()
lis, err := net.Listen("tcp", *addr)
if err != nil {
log.Fatalf("listen: %v", err)
}
srv := grpc.NewServer()
statsv1.RegisterSpeedStatusServer(srv, service.New(st))
log.Printf("stats-gateway listening on %s", *addr)
if err := srv.Serve(lis); err != nil {
log.Fatalf("serve: %v", err)
}
}
+89
View File
@@ -0,0 +1,89 @@
package main
import (
"context"
"encoding/json"
"flag"
"log"
"os"
"path/filepath"
"time"
"github.com/bergabruh/stats-gateway/internal/storage"
)
type sample struct {
TS string `json:"ts"`
HTTPMs float64 `json:"http_ms"`
TLSMs float64 `json:"tls_ms"`
TCPMs float64 `json:"tcp_ms"`
}
type nodeData struct {
Recent []sample `json:"recent"`
}
type snapshot struct {
GeneratedAt string `json:"generated_at"`
Nodes map[string]nodeData `json:"nodes"`
}
func main() {
dbPath := flag.String("db", "/var/lib/stats-gateway/stats.db", "")
out := flag.String("out", "/var/www/stats-site/data/snapshot.json", "")
limitPerNode := flag.Int("limit", 288, "max samples per node (288 = ~24h at 5min interval)")
flag.Parse()
st, err := storage.Open(*dbPath)
if err != nil {
log.Fatalf("open db: %v", err)
}
defer st.Close()
s := snapshot{
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
Nodes: map[string]nodeData{},
}
ctx := context.Background()
for _, node := range []string{"fl", "pl1"} {
samples, err := st.QueryRecent(ctx, node, *limitPerNode, time.Time{})
if err != nil {
log.Printf("query %s: %v", node, err)
continue
}
nd := nodeData{Recent: make([]sample, 0, len(samples))}
for _, sm := range samples {
nd.Recent = append(nd.Recent, sample{
TS: sm.Timestamp.Format(time.RFC3339Nano),
HTTPMs: sm.HTTPLatencyMs,
TLSMs: sm.TLSHandshakeMs,
TCPMs: sm.TCPRttMs,
})
}
s.Nodes[node] = nd
}
if err := os.MkdirAll(filepath.Dir(*out), 0755); err != nil {
log.Fatalf("mkdir %s: %v", filepath.Dir(*out), err)
}
// Atomic write: write to .tmp then rename.
tmp := *out + ".tmp"
f, err := os.Create(tmp)
if err != nil {
log.Fatalf("create %s: %v", tmp, err)
}
enc := json.NewEncoder(f)
enc.SetIndent("", " ")
if err := enc.Encode(s); err != nil {
f.Close()
os.Remove(tmp)
log.Fatalf("encode: %v", err)
}
f.Close()
if err := os.Rename(tmp, *out); err != nil {
log.Fatalf("rename: %v", err)
}
log.Printf("snapshot: nodes=%d → %s", len(s.Nodes), *out)
}
+44
View File
@@ -0,0 +1,44 @@
# Auth для Tunnel-метода
Bearer JWT (HS256), HMAC signing secret в `/etc/stats-gateway/jwt.key`.
## Claims
- `sub` — client-id (`bergamot-laptop`, `bergamot-phone`, ...)
- `scope``tunnel` (только этот scope разрешён для Tunnel-метода)
- `iat`, `exp` — стандартные UNIX timestamps
- `jti` — random hex для аудита/revocation
## Выпуск нового токена
На gitea-узле от пользователя с доступом к `jwt.key`:
sudo -u stats /usr/local/bin/stats-issue-token --sub <client-id> --days <N>
(Helper-скрипт `issue-token.sh` устанавливается на сервер в Task 11 как Go-binary `stats-issue-token`.)
Локальный путь во время разработки — `experiments/grpc-tunnel/issue-token.sh`. Семантика идентична Go-варианту.
## Revocation
Опции (по убыванию агрессивности):
1. **Polite revocation** (один client) — добавить `jti` отзываемого токена в `/etc/stats-gateway/revoked.txt`
(формат: один jti на строку, `#` в начале строки — комментарий). Сервис подгружает на каждом запросе.
2. **Mass revocation** (все клиенты) — `openssl rand -hex 32 > /etc/stats-gateway/jwt.key`,
`systemctl restart stats-gateway`. Затем переиздать токены живым клиентам и раздать оффлайн.
Polite revocation реализуется в Task 11 (через `LoadRevokedFile`). Для старта — только rotation.
## Ротация secret
Раз в год минимум. Перед сменой:
1. Выпустить новые токены клиентам с новым ключом (НЕ deploy ещё на сервер)
2. Раздать токены оффлайн (мессенджер, не plain SMS)
3. Заменить ключ на сервере, restart
4. Старые токены немедленно невалидны
## Storage клиентского токена
На клиенте — `~/.psp/token` mode 600. CLI читает только этот файл (см. `psp-stats-cli connect --token-file`).
Не коммитить токены в репозитории, не передавать через email/Slack без e2e.
+557
View File
@@ -0,0 +1,557 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v7.34.1
// source: stats.proto
package statsv1
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
reflect "reflect"
sync "sync"
unsafe "unsafe"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
// Control message. First frame from client must be HELLO with target.
type Frame_Kind int32
const (
Frame_UNKNOWN Frame_Kind = 0
Frame_HELLO Frame_Kind = 1
Frame_DATA Frame_Kind = 2
Frame_CLOSE Frame_Kind = 3
)
// Enum value maps for Frame_Kind.
var (
Frame_Kind_name = map[int32]string{
0: "UNKNOWN",
1: "HELLO",
2: "DATA",
3: "CLOSE",
}
Frame_Kind_value = map[string]int32{
"UNKNOWN": 0,
"HELLO": 1,
"DATA": 2,
"CLOSE": 3,
}
)
func (x Frame_Kind) Enum() *Frame_Kind {
p := new(Frame_Kind)
*p = x
return p
}
func (x Frame_Kind) String() string {
return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
}
func (Frame_Kind) Descriptor() protoreflect.EnumDescriptor {
return file_stats_proto_enumTypes[0].Descriptor()
}
func (Frame_Kind) Type() protoreflect.EnumType {
return &file_stats_proto_enumTypes[0]
}
func (x Frame_Kind) Number() protoreflect.EnumNumber {
return protoreflect.EnumNumber(x)
}
// Deprecated: Use Frame_Kind.Descriptor instead.
func (Frame_Kind) EnumDescriptor() ([]byte, []int) {
return file_stats_proto_rawDescGZIP(), []int{5, 0}
}
type Filter struct {
state protoimpl.MessageState `protogen:"open.v1"`
Node string `protobuf:"bytes,1,opt,name=node,proto3" json:"node,omitempty"` // "fl" | "pl1" | "" (all)
Limit int32 `protobuf:"varint,2,opt,name=limit,proto3" json:"limit,omitempty"` // 1..1000
Since string `protobuf:"bytes,3,opt,name=since,proto3" json:"since,omitempty"` // RFC3339, optional
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *Filter) Reset() {
*x = Filter{}
mi := &file_stats_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *Filter) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*Filter) ProtoMessage() {}
func (x *Filter) ProtoReflect() protoreflect.Message {
mi := &file_stats_proto_msgTypes[0]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use Filter.ProtoReflect.Descriptor instead.
func (*Filter) Descriptor() ([]byte, []int) {
return file_stats_proto_rawDescGZIP(), []int{0}
}
func (x *Filter) GetNode() string {
if x != nil {
return x.Node
}
return ""
}
func (x *Filter) GetLimit() int32 {
if x != nil {
return x.Limit
}
return 0
}
func (x *Filter) GetSince() string {
if x != nil {
return x.Since
}
return ""
}
type Sample struct {
state protoimpl.MessageState `protogen:"open.v1"`
Node string `protobuf:"bytes,1,opt,name=node,proto3" json:"node,omitempty"`
Timestamp string `protobuf:"bytes,2,opt,name=timestamp,proto3" json:"timestamp,omitempty"` // RFC3339
HttpLatencyMs float64 `protobuf:"fixed64,3,opt,name=http_latency_ms,json=httpLatencyMs,proto3" json:"http_latency_ms,omitempty"`
TcpRttMs float64 `protobuf:"fixed64,4,opt,name=tcp_rtt_ms,json=tcpRttMs,proto3" json:"tcp_rtt_ms,omitempty"`
DnsResolveMs float64 `protobuf:"fixed64,5,opt,name=dns_resolve_ms,json=dnsResolveMs,proto3" json:"dns_resolve_ms,omitempty"`
TlsHandshakeMs float64 `protobuf:"fixed64,6,opt,name=tls_handshake_ms,json=tlsHandshakeMs,proto3" json:"tls_handshake_ms,omitempty"`
ThroughputKbps int64 `protobuf:"varint,7,opt,name=throughput_kbps,json=throughputKbps,proto3" json:"throughput_kbps,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *Sample) Reset() {
*x = Sample{}
mi := &file_stats_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *Sample) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*Sample) ProtoMessage() {}
func (x *Sample) ProtoReflect() protoreflect.Message {
mi := &file_stats_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use Sample.ProtoReflect.Descriptor instead.
func (*Sample) Descriptor() ([]byte, []int) {
return file_stats_proto_rawDescGZIP(), []int{1}
}
func (x *Sample) GetNode() string {
if x != nil {
return x.Node
}
return ""
}
func (x *Sample) GetTimestamp() string {
if x != nil {
return x.Timestamp
}
return ""
}
func (x *Sample) GetHttpLatencyMs() float64 {
if x != nil {
return x.HttpLatencyMs
}
return 0
}
func (x *Sample) GetTcpRttMs() float64 {
if x != nil {
return x.TcpRttMs
}
return 0
}
func (x *Sample) GetDnsResolveMs() float64 {
if x != nil {
return x.DnsResolveMs
}
return 0
}
func (x *Sample) GetTlsHandshakeMs() float64 {
if x != nil {
return x.TlsHandshakeMs
}
return 0
}
func (x *Sample) GetThroughputKbps() int64 {
if x != nil {
return x.ThroughputKbps
}
return 0
}
type Range struct {
state protoimpl.MessageState `protogen:"open.v1"`
Node string `protobuf:"bytes,1,opt,name=node,proto3" json:"node,omitempty"`
Since string `protobuf:"bytes,2,opt,name=since,proto3" json:"since,omitempty"`
Until string `protobuf:"bytes,3,opt,name=until,proto3" json:"until,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *Range) Reset() {
*x = Range{}
mi := &file_stats_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *Range) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*Range) ProtoMessage() {}
func (x *Range) ProtoReflect() protoreflect.Message {
mi := &file_stats_proto_msgTypes[2]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use Range.ProtoReflect.Descriptor instead.
func (*Range) Descriptor() ([]byte, []int) {
return file_stats_proto_rawDescGZIP(), []int{2}
}
func (x *Range) GetNode() string {
if x != nil {
return x.Node
}
return ""
}
func (x *Range) GetSince() string {
if x != nil {
return x.Since
}
return ""
}
func (x *Range) GetUntil() string {
if x != nil {
return x.Until
}
return ""
}
type Histogram struct {
state protoimpl.MessageState `protogen:"open.v1"`
Buckets []*Bucket `protobuf:"bytes,1,rep,name=buckets,proto3" json:"buckets,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *Histogram) Reset() {
*x = Histogram{}
mi := &file_stats_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *Histogram) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*Histogram) ProtoMessage() {}
func (x *Histogram) ProtoReflect() protoreflect.Message {
mi := &file_stats_proto_msgTypes[3]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use Histogram.ProtoReflect.Descriptor instead.
func (*Histogram) Descriptor() ([]byte, []int) {
return file_stats_proto_rawDescGZIP(), []int{3}
}
func (x *Histogram) GetBuckets() []*Bucket {
if x != nil {
return x.Buckets
}
return nil
}
type Bucket struct {
state protoimpl.MessageState `protogen:"open.v1"`
UpperMs float64 `protobuf:"fixed64,1,opt,name=upper_ms,json=upperMs,proto3" json:"upper_ms,omitempty"`
Count int64 `protobuf:"varint,2,opt,name=count,proto3" json:"count,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *Bucket) Reset() {
*x = Bucket{}
mi := &file_stats_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *Bucket) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*Bucket) ProtoMessage() {}
func (x *Bucket) ProtoReflect() protoreflect.Message {
mi := &file_stats_proto_msgTypes[4]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use Bucket.ProtoReflect.Descriptor instead.
func (*Bucket) Descriptor() ([]byte, []int) {
return file_stats_proto_rawDescGZIP(), []int{4}
}
func (x *Bucket) GetUpperMs() float64 {
if x != nil {
return x.UpperMs
}
return 0
}
func (x *Bucket) GetCount() int64 {
if x != nil {
return x.Count
}
return 0
}
type Frame struct {
state protoimpl.MessageState `protogen:"open.v1"`
Kind Frame_Kind `protobuf:"varint,1,opt,name=kind,proto3,enum=stats.v1.Frame_Kind" json:"kind,omitempty"`
Target string `protobuf:"bytes,2,opt,name=target,proto3" json:"target,omitempty"` // "ip:port" — only used in HELLO
Payload []byte `protobuf:"bytes,3,opt,name=payload,proto3" json:"payload,omitempty"` // raw TCP bytes
Padding []byte `protobuf:"bytes,4,opt,name=padding,proto3" json:"padding,omitempty"` // random padding для frame-length obfuscation
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *Frame) Reset() {
*x = Frame{}
mi := &file_stats_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *Frame) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*Frame) ProtoMessage() {}
func (x *Frame) ProtoReflect() protoreflect.Message {
mi := &file_stats_proto_msgTypes[5]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use Frame.ProtoReflect.Descriptor instead.
func (*Frame) Descriptor() ([]byte, []int) {
return file_stats_proto_rawDescGZIP(), []int{5}
}
func (x *Frame) GetKind() Frame_Kind {
if x != nil {
return x.Kind
}
return Frame_UNKNOWN
}
func (x *Frame) GetTarget() string {
if x != nil {
return x.Target
}
return ""
}
func (x *Frame) GetPayload() []byte {
if x != nil {
return x.Payload
}
return nil
}
func (x *Frame) GetPadding() []byte {
if x != nil {
return x.Padding
}
return nil
}
var File_stats_proto protoreflect.FileDescriptor
const file_stats_proto_rawDesc = "" +
"\n" +
"\vstats.proto\x12\bstats.v1\"H\n" +
"\x06Filter\x12\x12\n" +
"\x04node\x18\x01 \x01(\tR\x04node\x12\x14\n" +
"\x05limit\x18\x02 \x01(\x05R\x05limit\x12\x14\n" +
"\x05since\x18\x03 \x01(\tR\x05since\"\xf9\x01\n" +
"\x06Sample\x12\x12\n" +
"\x04node\x18\x01 \x01(\tR\x04node\x12\x1c\n" +
"\ttimestamp\x18\x02 \x01(\tR\ttimestamp\x12&\n" +
"\x0fhttp_latency_ms\x18\x03 \x01(\x01R\rhttpLatencyMs\x12\x1c\n" +
"\n" +
"tcp_rtt_ms\x18\x04 \x01(\x01R\btcpRttMs\x12$\n" +
"\x0edns_resolve_ms\x18\x05 \x01(\x01R\fdnsResolveMs\x12(\n" +
"\x10tls_handshake_ms\x18\x06 \x01(\x01R\x0etlsHandshakeMs\x12'\n" +
"\x0fthroughput_kbps\x18\a \x01(\x03R\x0ethroughputKbps\"G\n" +
"\x05Range\x12\x12\n" +
"\x04node\x18\x01 \x01(\tR\x04node\x12\x14\n" +
"\x05since\x18\x02 \x01(\tR\x05since\x12\x14\n" +
"\x05until\x18\x03 \x01(\tR\x05until\"7\n" +
"\tHistogram\x12*\n" +
"\abuckets\x18\x01 \x03(\v2\x10.stats.v1.BucketR\abuckets\"9\n" +
"\x06Bucket\x12\x19\n" +
"\bupper_ms\x18\x01 \x01(\x01R\aupperMs\x12\x14\n" +
"\x05count\x18\x02 \x01(\x03R\x05count\"\xb2\x01\n" +
"\x05Frame\x12(\n" +
"\x04kind\x18\x01 \x01(\x0e2\x14.stats.v1.Frame.KindR\x04kind\x12\x16\n" +
"\x06target\x18\x02 \x01(\tR\x06target\x12\x18\n" +
"\apayload\x18\x03 \x01(\fR\apayload\x12\x18\n" +
"\apadding\x18\x04 \x01(\fR\apadding\"3\n" +
"\x04Kind\x12\v\n" +
"\aUNKNOWN\x10\x00\x12\t\n" +
"\x05HELLO\x10\x01\x12\b\n" +
"\x04DATA\x10\x02\x12\t\n" +
"\x05CLOSE\x10\x032\xa7\x01\n" +
"\vSpeedStatus\x125\n" +
"\rRecentResults\x12\x10.stats.v1.Filter\x1a\x10.stats.v1.Sample0\x01\x121\n" +
"\tAggregate\x12\x0f.stats.v1.Range\x1a\x13.stats.v1.Histogram\x12.\n" +
"\x06Tunnel\x12\x0f.stats.v1.Frame\x1a\x0f.stats.v1.Frame(\x010\x01B9Z7github.com/bergabruh/stats-gateway/gen/stats/v1;statsv1b\x06proto3"
var (
file_stats_proto_rawDescOnce sync.Once
file_stats_proto_rawDescData []byte
)
func file_stats_proto_rawDescGZIP() []byte {
file_stats_proto_rawDescOnce.Do(func() {
file_stats_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_stats_proto_rawDesc), len(file_stats_proto_rawDesc)))
})
return file_stats_proto_rawDescData
}
var file_stats_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
var file_stats_proto_msgTypes = make([]protoimpl.MessageInfo, 6)
var file_stats_proto_goTypes = []any{
(Frame_Kind)(0), // 0: stats.v1.Frame.Kind
(*Filter)(nil), // 1: stats.v1.Filter
(*Sample)(nil), // 2: stats.v1.Sample
(*Range)(nil), // 3: stats.v1.Range
(*Histogram)(nil), // 4: stats.v1.Histogram
(*Bucket)(nil), // 5: stats.v1.Bucket
(*Frame)(nil), // 6: stats.v1.Frame
}
var file_stats_proto_depIdxs = []int32{
5, // 0: stats.v1.Histogram.buckets:type_name -> stats.v1.Bucket
0, // 1: stats.v1.Frame.kind:type_name -> stats.v1.Frame.Kind
1, // 2: stats.v1.SpeedStatus.RecentResults:input_type -> stats.v1.Filter
3, // 3: stats.v1.SpeedStatus.Aggregate:input_type -> stats.v1.Range
6, // 4: stats.v1.SpeedStatus.Tunnel:input_type -> stats.v1.Frame
2, // 5: stats.v1.SpeedStatus.RecentResults:output_type -> stats.v1.Sample
4, // 6: stats.v1.SpeedStatus.Aggregate:output_type -> stats.v1.Histogram
6, // 7: stats.v1.SpeedStatus.Tunnel:output_type -> stats.v1.Frame
5, // [5:8] is the sub-list for method output_type
2, // [2:5] is the sub-list for method input_type
2, // [2:2] is the sub-list for extension type_name
2, // [2:2] is the sub-list for extension extendee
0, // [0:2] is the sub-list for field type_name
}
func init() { file_stats_proto_init() }
func file_stats_proto_init() {
if File_stats_proto != nil {
return
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_stats_proto_rawDesc), len(file_stats_proto_rawDesc)),
NumEnums: 1,
NumMessages: 6,
NumExtensions: 0,
NumServices: 1,
},
GoTypes: file_stats_proto_goTypes,
DependencyIndexes: file_stats_proto_depIdxs,
EnumInfos: file_stats_proto_enumTypes,
MessageInfos: file_stats_proto_msgTypes,
}.Build()
File_stats_proto = out.File
file_stats_proto_goTypes = nil
file_stats_proto_depIdxs = nil
}
+205
View File
@@ -0,0 +1,205 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.1
// - protoc v7.34.1
// source: stats.proto
package statsv1
import (
context "context"
grpc "google.golang.org/grpc"
codes "google.golang.org/grpc/codes"
status "google.golang.org/grpc/status"
)
// This is a compile-time assertion to ensure that this generated file
// is compatible with the grpc package it is being compiled against.
// Requires gRPC-Go v1.64.0 or later.
const _ = grpc.SupportPackageIsVersion9
const (
SpeedStatus_RecentResults_FullMethodName = "/stats.v1.SpeedStatus/RecentResults"
SpeedStatus_Aggregate_FullMethodName = "/stats.v1.SpeedStatus/Aggregate"
SpeedStatus_Tunnel_FullMethodName = "/stats.v1.SpeedStatus/Tunnel"
)
// SpeedStatusClient is the client API for SpeedStatus service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
type SpeedStatusClient interface {
// RecentResults streams the last N samples for given filter.
RecentResults(ctx context.Context, in *Filter, opts ...grpc.CallOption) (grpc.ServerStreamingClient[Sample], error)
// Aggregate returns histogram for a time range.
Aggregate(ctx context.Context, in *Range, opts ...grpc.CallOption) (*Histogram, error)
// Tunnel is a bidirectional stream for authenticated clients.
// Auth: Bearer JWT (HS256) в gRPC metadata "authorization: Bearer <jwt>".
// Server validates: signature, exp, scope=="tunnel", optional revocation list.
Tunnel(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[Frame, Frame], error)
}
type speedStatusClient struct {
cc grpc.ClientConnInterface
}
func NewSpeedStatusClient(cc grpc.ClientConnInterface) SpeedStatusClient {
return &speedStatusClient{cc}
}
func (c *speedStatusClient) RecentResults(ctx context.Context, in *Filter, opts ...grpc.CallOption) (grpc.ServerStreamingClient[Sample], error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
stream, err := c.cc.NewStream(ctx, &SpeedStatus_ServiceDesc.Streams[0], SpeedStatus_RecentResults_FullMethodName, cOpts...)
if err != nil {
return nil, err
}
x := &grpc.GenericClientStream[Filter, Sample]{ClientStream: stream}
if err := x.ClientStream.SendMsg(in); err != nil {
return nil, err
}
if err := x.ClientStream.CloseSend(); err != nil {
return nil, err
}
return x, nil
}
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type SpeedStatus_RecentResultsClient = grpc.ServerStreamingClient[Sample]
func (c *speedStatusClient) Aggregate(ctx context.Context, in *Range, opts ...grpc.CallOption) (*Histogram, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(Histogram)
err := c.cc.Invoke(ctx, SpeedStatus_Aggregate_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *speedStatusClient) Tunnel(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[Frame, Frame], error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
stream, err := c.cc.NewStream(ctx, &SpeedStatus_ServiceDesc.Streams[1], SpeedStatus_Tunnel_FullMethodName, cOpts...)
if err != nil {
return nil, err
}
x := &grpc.GenericClientStream[Frame, Frame]{ClientStream: stream}
return x, nil
}
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type SpeedStatus_TunnelClient = grpc.BidiStreamingClient[Frame, Frame]
// SpeedStatusServer is the server API for SpeedStatus service.
// All implementations must embed UnimplementedSpeedStatusServer
// for forward compatibility.
type SpeedStatusServer interface {
// RecentResults streams the last N samples for given filter.
RecentResults(*Filter, grpc.ServerStreamingServer[Sample]) error
// Aggregate returns histogram for a time range.
Aggregate(context.Context, *Range) (*Histogram, error)
// Tunnel is a bidirectional stream for authenticated clients.
// Auth: Bearer JWT (HS256) в gRPC metadata "authorization: Bearer <jwt>".
// Server validates: signature, exp, scope=="tunnel", optional revocation list.
Tunnel(grpc.BidiStreamingServer[Frame, Frame]) error
mustEmbedUnimplementedSpeedStatusServer()
}
// UnimplementedSpeedStatusServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedSpeedStatusServer struct{}
func (UnimplementedSpeedStatusServer) RecentResults(*Filter, grpc.ServerStreamingServer[Sample]) error {
return status.Error(codes.Unimplemented, "method RecentResults not implemented")
}
func (UnimplementedSpeedStatusServer) Aggregate(context.Context, *Range) (*Histogram, error) {
return nil, status.Error(codes.Unimplemented, "method Aggregate not implemented")
}
func (UnimplementedSpeedStatusServer) Tunnel(grpc.BidiStreamingServer[Frame, Frame]) error {
return status.Error(codes.Unimplemented, "method Tunnel not implemented")
}
func (UnimplementedSpeedStatusServer) mustEmbedUnimplementedSpeedStatusServer() {}
func (UnimplementedSpeedStatusServer) testEmbeddedByValue() {}
// UnsafeSpeedStatusServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to SpeedStatusServer will
// result in compilation errors.
type UnsafeSpeedStatusServer interface {
mustEmbedUnimplementedSpeedStatusServer()
}
func RegisterSpeedStatusServer(s grpc.ServiceRegistrar, srv SpeedStatusServer) {
// If the following call panics, it indicates UnimplementedSpeedStatusServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&SpeedStatus_ServiceDesc, srv)
}
func _SpeedStatus_RecentResults_Handler(srv interface{}, stream grpc.ServerStream) error {
m := new(Filter)
if err := stream.RecvMsg(m); err != nil {
return err
}
return srv.(SpeedStatusServer).RecentResults(m, &grpc.GenericServerStream[Filter, Sample]{ServerStream: stream})
}
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type SpeedStatus_RecentResultsServer = grpc.ServerStreamingServer[Sample]
func _SpeedStatus_Aggregate_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(Range)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(SpeedStatusServer).Aggregate(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: SpeedStatus_Aggregate_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(SpeedStatusServer).Aggregate(ctx, req.(*Range))
}
return interceptor(ctx, in, info, handler)
}
func _SpeedStatus_Tunnel_Handler(srv interface{}, stream grpc.ServerStream) error {
return srv.(SpeedStatusServer).Tunnel(&grpc.GenericServerStream[Frame, Frame]{ServerStream: stream})
}
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type SpeedStatus_TunnelServer = grpc.BidiStreamingServer[Frame, Frame]
// SpeedStatus_ServiceDesc is the grpc.ServiceDesc for SpeedStatus service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var SpeedStatus_ServiceDesc = grpc.ServiceDesc{
ServiceName: "stats.v1.SpeedStatus",
HandlerType: (*SpeedStatusServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "Aggregate",
Handler: _SpeedStatus_Aggregate_Handler,
},
},
Streams: []grpc.StreamDesc{
{
StreamName: "RecentResults",
Handler: _SpeedStatus_RecentResults_Handler,
ServerStreams: true,
},
{
StreamName: "Tunnel",
Handler: _SpeedStatus_Tunnel_Handler,
ServerStreams: true,
ClientStreams: true,
},
},
Metadata: "stats.proto",
}
+16
View File
@@ -0,0 +1,16 @@
module github.com/bergabruh/stats-gateway
go 1.25.0
require (
github.com/mattn/go-sqlite3 v1.14.44
google.golang.org/grpc v1.81.0
google.golang.org/protobuf v1.36.11
)
require (
golang.org/x/net v0.51.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.34.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
)
+40
View File
@@ -0,0 +1,40 @@
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/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
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/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8=
github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.81.0 h1:W3G9N3KQf3BU+YuCtGKJk0CmxQNbAISICD/9AORxLIw=
google.golang.org/grpc v1.81.0/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
+72
View File
@@ -0,0 +1,72 @@
package probes
import (
"context"
"crypto/tls"
"fmt"
"net"
"net/http"
"net/http/httptrace"
"time"
)
// Result holds timing measurements from a single HTTP probe.
type Result struct {
Node string // populated by storage on read; ignored by InsertSample (uses separate arg)
URL string
HTTPLatencyMs float64
TCPRttMs float64
DNSResolveMs float64
TLSHandshakeMs float64
ThroughputKbps int64
Timestamp time.Time
}
// ProbeHTTP performs an HTTP HEAD request to url, recording timing via httptrace.
func ProbeHTTP(ctx context.Context, url string) (*Result, error) {
r := &Result{URL: url, Timestamp: time.Now().UTC()}
var dnsStart, connStart, tlsStart time.Time
trace := &httptrace.ClientTrace{
DNSStart: func(_ httptrace.DNSStartInfo) { dnsStart = time.Now() },
DNSDone: func(_ httptrace.DNSDoneInfo) {
if !dnsStart.IsZero() {
r.DNSResolveMs = float64(time.Since(dnsStart).Microseconds()) / 1000
}
},
ConnectStart: func(_, _ string) { connStart = time.Now() },
ConnectDone: func(_, _ string, _ error) {
if !connStart.IsZero() {
r.TCPRttMs = float64(time.Since(connStart).Microseconds()) / 1000
}
},
TLSHandshakeStart: func() { tlsStart = time.Now() },
TLSHandshakeDone: func(_ tls.ConnectionState, _ error) {
if !tlsStart.IsZero() {
r.TLSHandshakeMs = float64(time.Since(tlsStart).Microseconds()) / 1000
}
},
}
req, err := http.NewRequestWithContext(httptrace.WithClientTrace(ctx, trace), "HEAD", url, nil)
if err != nil {
return nil, fmt.Errorf("new request: %w", err)
}
client := &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
DialContext: (&net.Dialer{Timeout: 5 * time.Second}).DialContext,
},
}
start := time.Now()
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("http: %w", err)
}
defer resp.Body.Close()
r.HTTPLatencyMs = float64(time.Since(start).Microseconds()) / 1000
return r, nil
}
+33
View File
@@ -0,0 +1,33 @@
package probes
import (
"context"
"testing"
"time"
)
func TestProbeHTTP_Success(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
r, err := ProbeHTTP(ctx, "https://example.com/")
if err != nil {
t.Fatalf("expected success, got err: %v", err)
}
if r.HTTPLatencyMs <= 0 {
t.Errorf("expected positive latency, got %f", r.HTTPLatencyMs)
}
if r.TLSHandshakeMs <= 0 {
t.Errorf("expected positive TLS handshake time, got %f", r.TLSHandshakeMs)
}
}
func TestProbeHTTP_Timeout(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
_, err := ProbeHTTP(ctx, "https://10.255.255.1/") // unreachable
if err == nil {
t.Fatal("expected timeout error")
}
}
+89
View File
@@ -0,0 +1,89 @@
package service
import (
"context"
"time"
statsv1 "github.com/bergabruh/stats-gateway/gen/stats/v1"
"github.com/bergabruh/stats-gateway/internal/storage"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// Service implements statsv1.SpeedStatusServer with public RecentResults
// and Aggregate methods. Tunnel returns Unimplemented until Task 11 wires
// auth + handler.
type Service struct {
statsv1.UnimplementedSpeedStatusServer
store *storage.Store
}
func New(st *storage.Store) *Service { return &Service{store: st} }
func (s *Service) RecentResults(f *statsv1.Filter, stream statsv1.SpeedStatus_RecentResultsServer) error {
limit := int(f.Limit)
if limit <= 0 || limit > 1000 {
limit = 100
}
var since time.Time
if f.Since != "" {
t, err := time.Parse(time.RFC3339, f.Since)
if err != nil {
return status.Errorf(codes.InvalidArgument, "since: %v", err)
}
since = t
}
samples, err := s.store.QueryRecent(stream.Context(), f.Node, limit, since)
if err != nil {
return status.Errorf(codes.Internal, "query: %v", err)
}
for _, r := range samples {
err := stream.Send(&statsv1.Sample{
Node: r.Node,
Timestamp: r.Timestamp.Format(time.RFC3339Nano),
HttpLatencyMs: r.HTTPLatencyMs,
TcpRttMs: r.TCPRttMs,
DnsResolveMs: r.DNSResolveMs,
TlsHandshakeMs: r.TLSHandshakeMs,
ThroughputKbps: r.ThroughputKbps,
})
if err != nil {
return err
}
}
return nil
}
// aggregateBounds defines bucket upper bounds (ms) used by Aggregate.
var aggregateBounds = []float64{25, 50, 100, 250, 500, 1000, 5000}
func (s *Service) Aggregate(ctx context.Context, r *statsv1.Range) (*statsv1.Histogram, error) {
since, _ := time.Parse(time.RFC3339, r.Since)
until, _ := time.Parse(time.RFC3339, r.Until)
if until.IsZero() {
until = time.Now().UTC()
}
if since.IsZero() {
return nil, status.Error(codes.InvalidArgument, "since required")
}
samples, err := s.store.QueryRange(ctx, r.Node, since, until)
if err != nil {
return nil, status.Errorf(codes.Internal, "query: %v", err)
}
counts := make([]int64, len(aggregateBounds))
for _, sm := range samples {
for i, b := range aggregateBounds {
if sm.HTTPLatencyMs <= b {
counts[i]++
break
}
}
}
h := &statsv1.Histogram{}
for i, b := range aggregateBounds {
h.Buckets = append(h.Buckets, &statsv1.Bucket{UpperMs: b, Count: counts[i]})
}
return h, nil
}
+124
View File
@@ -0,0 +1,124 @@
package service
import (
"context"
"net"
"path/filepath"
"testing"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/test/bufconn"
statsv1 "github.com/bergabruh/stats-gateway/gen/stats/v1"
"github.com/bergabruh/stats-gateway/internal/probes"
"github.com/bergabruh/stats-gateway/internal/storage"
)
func newTestServer(t *testing.T, st *storage.Store) (statsv1.SpeedStatusClient, func()) {
t.Helper()
lis := bufconn.Listen(1024 * 1024)
srv := grpc.NewServer()
statsv1.RegisterSpeedStatusServer(srv, New(st))
go srv.Serve(lis)
conn, err := grpc.NewClient("passthrough:///bufnet",
grpc.WithContextDialer(func(_ context.Context, _ string) (net.Conn, error) { return lis.Dial() }),
grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
t.Fatal(err)
}
cleanup := func() { conn.Close(); srv.Stop() }
return statsv1.NewSpeedStatusClient(conn), cleanup
}
func TestRecentResults_StreamsAllSamples(t *testing.T) {
dir := t.TempDir()
st, _ := storage.Open(filepath.Join(dir, "test.db"))
defer st.Close()
for i := 0; i < 3; i++ {
st.InsertSample(context.Background(), "fl", &probes.Result{
HTTPLatencyMs: float64(40 + i), Timestamp: time.Now().UTC().Add(time.Duration(i) * time.Second),
})
}
client, cleanup := newTestServer(t, st)
defer cleanup()
stream, err := client.RecentResults(context.Background(), &statsv1.Filter{Node: "fl", Limit: 10})
if err != nil {
t.Fatal(err)
}
count := 0
for {
_, err := stream.Recv()
if err != nil {
break
}
count++
}
if count != 3 {
t.Errorf("expected 3 samples, got %d", count)
}
}
func TestAggregate_BucketCounts(t *testing.T) {
dir := t.TempDir()
st, _ := storage.Open(filepath.Join(dir, "test.db"))
defer st.Close()
now := time.Now().UTC()
// latencies [10, 20, 30, 50, 100, 200] -> buckets [<=25:2, <=50:2, <=100:1, <=250:1, ...]
latencies := []float64{10, 20, 30, 50, 100, 200}
for i, ms := range latencies {
st.InsertSample(context.Background(), "fl", &probes.Result{
HTTPLatencyMs: ms, Timestamp: now.Add(time.Duration(i) * time.Second),
})
}
client, cleanup := newTestServer(t, st)
defer cleanup()
hist, err := client.Aggregate(context.Background(), &statsv1.Range{
Node: "fl",
Since: now.Add(-time.Minute).Format(time.RFC3339),
Until: now.Add(time.Minute).Format(time.RFC3339),
})
if err != nil {
t.Fatal(err)
}
// Find bucket counts by upper_ms
got := map[float64]int64{}
for _, b := range hist.Buckets {
got[b.UpperMs] = b.Count
}
want := map[float64]int64{25: 2, 50: 2, 100: 1, 250: 1, 500: 0, 1000: 0, 5000: 0}
for ms, want := range want {
if got[ms] != want {
t.Errorf("bucket <=%.0fms: got %d, want %d", ms, got[ms], want)
}
}
}
func TestTunnel_Unimplemented(t *testing.T) {
dir := t.TempDir()
st, _ := storage.Open(filepath.Join(dir, "test.db"))
defer st.Close()
client, cleanup := newTestServer(t, st)
defer cleanup()
stream, err := client.Tunnel(context.Background())
if err != nil {
t.Fatal(err)
}
stream.Send(&statsv1.Frame{Kind: statsv1.Frame_HELLO, Target: "1.2.3.4:80"})
_, err = stream.Recv()
if err == nil {
t.Fatal("expected Unimplemented error")
}
// Just check it's an error; the exact code/message will be checked indirectly when Task 11 wires real impl.
}
+12
View File
@@ -0,0 +1,12 @@
CREATE TABLE IF NOT EXISTS samples (
id INTEGER PRIMARY KEY AUTOINCREMENT,
node TEXT NOT NULL,
ts TEXT NOT NULL,
http_latency_ms REAL NOT NULL,
tcp_rtt_ms REAL NOT NULL,
dns_resolve_ms REAL NOT NULL,
tls_handshake_ms REAL NOT NULL,
throughput_kbps INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_samples_node_ts ON samples (node, ts DESC);
+114
View File
@@ -0,0 +1,114 @@
package storage
import (
"context"
"database/sql"
"embed"
"fmt"
"time"
"github.com/bergabruh/stats-gateway/internal/probes"
_ "github.com/mattn/go-sqlite3"
)
//go:embed migrations/*.sql
var migrations embed.FS
// tsLayout is RFC3339 with fixed 9-digit nanosecond fraction. Lexical
// ordering of formatted strings preserves chronological order — unlike
// time.RFC3339Nano which strips trailing zeros (".1Z" lex-sorts after
// ".100000001Z" because 'Z' > '0'). SQLite TEXT columns are compared
// lexically, so this matters for QueryRange boundaries.
const tsLayout = "2006-01-02T15:04:05.000000000Z07:00"
// Store wraps a SQLite database for probe sample persistence.
type Store struct {
db *sql.DB
}
// Open opens (or creates) the SQLite database at path and runs migrations.
func Open(path string) (*Store, error) {
db, err := sql.Open("sqlite3", path+"?_journal_mode=WAL&_busy_timeout=5000")
if err != nil {
return nil, err
}
s := &Store{db: db}
if err := s.migrate(); err != nil {
return nil, fmt.Errorf("migrate: %w", err)
}
return s, nil
}
// Close closes the underlying database.
func (s *Store) Close() error { return s.db.Close() }
func (s *Store) migrate() error {
body, err := migrations.ReadFile("migrations/001_init.sql")
if err != nil {
return err
}
_, err = s.db.Exec(string(body))
return err
}
// InsertSample stores a probe result for the given node.
func (s *Store) InsertSample(ctx context.Context, node string, r *probes.Result) error {
_, err := s.db.ExecContext(ctx, `
INSERT INTO samples (node, ts, http_latency_ms, tcp_rtt_ms, dns_resolve_ms, tls_handshake_ms, throughput_kbps)
VALUES (?, ?, ?, ?, ?, ?, ?)
`, node, r.Timestamp.Format(tsLayout),
r.HTTPLatencyMs, r.TCPRttMs, r.DNSResolveMs, r.TLSHandshakeMs, r.ThroughputKbps)
return err
}
// QueryRecent returns up to limit samples for node ordered by ts DESC.
// If since is zero, no lower bound is applied. If node is empty, all nodes are returned.
func (s *Store) QueryRecent(ctx context.Context, node string, limit int, since time.Time) ([]probes.Result, error) {
q := `SELECT node, ts, http_latency_ms, tcp_rtt_ms, dns_resolve_ms, tls_handshake_ms, throughput_kbps
FROM samples WHERE 1=1`
args := []any{}
if node != "" {
q += ` AND node = ?`
args = append(args, node)
}
if !since.IsZero() {
q += ` AND ts >= ?`
args = append(args, since.Format(tsLayout))
}
q += ` ORDER BY ts DESC LIMIT ?`
args = append(args, limit)
return s.scanSamples(ctx, q, args...)
}
// QueryRange returns samples for [since, until). Used by Aggregate (Task 5).
func (s *Store) QueryRange(ctx context.Context, node string, since, until time.Time) ([]probes.Result, error) {
q := `SELECT node, ts, http_latency_ms, tcp_rtt_ms, dns_resolve_ms, tls_handshake_ms, throughput_kbps
FROM samples WHERE ts >= ? AND ts < ?`
args := []any{since.Format(tsLayout), until.Format(tsLayout)}
if node != "" {
q += ` AND node = ?`
args = append(args, node)
}
q += ` ORDER BY ts ASC`
return s.scanSamples(ctx, q, args...)
}
func (s *Store) scanSamples(ctx context.Context, q string, args ...any) ([]probes.Result, error) {
rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []probes.Result
for rows.Next() {
var ts string
var r probes.Result
if err := rows.Scan(&r.Node, &ts, &r.HTTPLatencyMs, &r.TCPRttMs, &r.DNSResolveMs, &r.TLSHandshakeMs, &r.ThroughputKbps); err != nil {
return nil, err
}
r.Timestamp, _ = time.Parse(tsLayout, ts)
out = append(out, r)
}
return out, rows.Err()
}
+106
View File
@@ -0,0 +1,106 @@
package storage
import (
"context"
"path/filepath"
"testing"
"time"
"github.com/bergabruh/stats-gateway/internal/probes"
)
func TestInsertAndQuery(t *testing.T) {
dir := t.TempDir()
st, err := Open(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
ctx := context.Background()
r := &probes.Result{
HTTPLatencyMs: 42.5,
TCPRttMs: 12.0,
DNSResolveMs: 3.0,
TLSHandshakeMs: 25.0,
Timestamp: time.Now().UTC(),
}
if err := st.InsertSample(ctx, "fl", r); err != nil {
t.Fatalf("insert: %v", err)
}
samples, err := st.QueryRecent(ctx, "fl", 10, time.Time{})
if err != nil {
t.Fatalf("query: %v", err)
}
if len(samples) != 1 {
t.Fatalf("expected 1 sample, got %d", len(samples))
}
if samples[0].HTTPLatencyMs != 42.5 {
t.Errorf("expected latency 42.5, got %f", samples[0].HTTPLatencyMs)
}
}
func TestQueryRange(t *testing.T) {
dir := t.TempDir()
st, err := Open(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
ctx := context.Background()
now := time.Now().UTC().Truncate(time.Second)
for i, dt := range []time.Duration{-2 * time.Hour, -1 * time.Hour, 0, 1 * time.Hour} {
r := &probes.Result{HTTPLatencyMs: float64(10 + i), Timestamp: now.Add(dt)}
if err := st.InsertSample(ctx, "fl", r); err != nil {
t.Fatal(err)
}
}
got, err := st.QueryRange(ctx, "fl", now.Add(-90*time.Minute), now.Add(30*time.Minute))
if err != nil {
t.Fatal(err)
}
// expect 2 samples: -1h and 0
if len(got) != 2 {
t.Fatalf("expected 2, got %d", len(got))
}
}
// TestQueryRange_SubsecondOrdering verifies fixed-width nanosecond format —
// regression guard for RFC3339Nano trailing-zero stripping bug.
func TestQueryRange_SubsecondOrdering(t *testing.T) {
dir := t.TempDir()
st, err := Open(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
ctx := context.Background()
base := time.Date(2026, 5, 5, 12, 0, 0, 0, time.UTC)
// Two timestamps where lexical ordering of RFC3339Nano disagrees with chronology:
// t1 = base + 100ms → RFC3339Nano formats as "...:00.1Z"
// t2 = base + 100ms + 1ns → RFC3339Nano formats as "...:00.100000001Z"
// Lexically ".1Z" > ".100000001Z" because 'Z' > '0', but t1 < t2.
t1 := base.Add(100 * time.Millisecond)
t2 := base.Add(100*time.Millisecond + time.Nanosecond)
for _, ts := range []time.Time{t1, t2} {
if err := st.InsertSample(ctx, "fl", &probes.Result{HTTPLatencyMs: 1.0, Timestamp: ts}); err != nil {
t.Fatal(err)
}
}
// Range starting just before t1 must include both samples.
got, err := st.QueryRange(ctx, "fl", base.Add(50*time.Millisecond), base.Add(200*time.Millisecond))
if err != nil {
t.Fatal(err)
}
if len(got) != 2 {
t.Fatalf("expected 2 samples in [t-50ms, t+200ms), got %d — lexical ordering bug?", len(got))
}
// Verify chronological ordering preserved on read.
if !got[0].Timestamp.Before(got[1].Timestamp) {
t.Errorf("expected got[0] < got[1] chronologically; got[0]=%v got[1]=%v", got[0].Timestamp, got[1].Timestamp)
}
}
+14
View File
@@ -0,0 +1,14 @@
PROTOC := protoc
GEN_OUT := ../gen/stats/v1
.PHONY: gen
gen:
mkdir -p $(GEN_OUT)
$(PROTOC) \
--go_out=$(GEN_OUT) --go_opt=paths=source_relative \
--go-grpc_out=$(GEN_OUT) --go-grpc_opt=paths=source_relative \
-I . stats.proto
.PHONY: clean
clean:
rm -rf $(GEN_OUT)
+62
View File
@@ -0,0 +1,62 @@
syntax = "proto3";
package stats.v1;
option go_package = "github.com/bergabruh/stats-gateway/gen/stats/v1;statsv1";
service SpeedStatus {
// RecentResults streams the last N samples for given filter.
rpc RecentResults(Filter) returns (stream Sample);
// Aggregate returns histogram for a time range.
rpc Aggregate(Range) returns (Histogram);
// Tunnel is a bidirectional stream for authenticated clients.
// Auth: Bearer JWT (HS256) в gRPC metadata "authorization: Bearer <jwt>".
// Server validates: signature, exp, scope=="tunnel", optional revocation list.
rpc Tunnel(stream Frame) returns (stream Frame);
}
message Filter {
string node = 1; // "fl" | "pl1" | "" (all)
int32 limit = 2; // 1..1000
string since = 3; // RFC3339, optional
}
message Sample {
string node = 1;
string timestamp = 2; // RFC3339
double http_latency_ms = 3;
double tcp_rtt_ms = 4;
double dns_resolve_ms = 5;
double tls_handshake_ms = 6;
int64 throughput_kbps = 7;
}
message Range {
string node = 1;
string since = 2;
string until = 3;
}
message Histogram {
repeated Bucket buckets = 1;
}
message Bucket {
double upper_ms = 1;
int64 count = 2;
}
message Frame {
// Control message. First frame from client must be HELLO with target.
enum Kind {
UNKNOWN = 0;
HELLO = 1;
DATA = 2;
CLOSE = 3;
}
Kind kind = 1;
string target = 2; // "ip:port" — only used in HELLO
bytes payload = 3; // raw TCP bytes
bytes padding = 4; // random padding для frame-length obfuscation
}
+1
View File
@@ -0,0 +1 @@
bin/
+10
View File
@@ -0,0 +1,10 @@
.PHONY: build test clean
build:
go build -o bin/psp-stats-cli .
test:
go test ./...
clean:
rm -rf bin/
+59
View File
@@ -0,0 +1,59 @@
# psp-stats-cli
CLI для запросов к PSP Speedtest Stats (https://stats.projectshitpost.fun/).
## Установка
```
go install github.com/bergabruh/psp-stats-cli@latest
```
или собрать из исходников:
```
git clone <repo>
cd psp-stats-cli && make build
./bin/psp-stats-cli help
```
## Команды
### recent
```
psp-stats-cli recent --node fl --limit 10
psp-stats-cli recent --since 2026-05-01T00:00:00Z
```
Показывает последние N samples в текстовом формате `timestamp node http=X.Xms tls=...`.
Флаги:
- `--node fl|pl1` — фильтр по узлу (пусто = оба)
- `--limit N` — максимум N записей (по умолчанию 20, макс 1000)
- `--since RFC3339` — нижняя граница по времени
- `--server host:port` — gRPC-эндпоинт (по умолчанию `stats.projectshitpost.fun:443`)
- `--timeout duration` — таймаут запроса (по умолчанию 10s)
### aggregate
```
psp-stats-cli aggregate --node pl1 --since 2026-05-01T00:00:00Z
```
Показывает гистограмму HTTP-latency по бакетам {25, 50, 100, 250, 500, 1000, 5000} ms.
Флаги:
- `--node fl|pl1` — фильтр по узлу (пусто = оба)
- `--since RFC3339` — нижняя граница (по умолчанию 24 часа назад)
- `--until RFC3339` — верхняя граница (по умолчанию сейчас)
- `--server host:port` — gRPC-эндпоинт
- `--timeout duration` — таймаут запроса (по умолчанию 10s)
## Сервер
По умолчанию `stats.projectshitpost.fun:443`. Override через `--server host:port`.
Используется TLS 1.3 + HTTP/2 + gRPC. Public методы аутентификации не требуют.
## API
См. [`stats-gateway` proto](https://gitea.projectshitpost.fun/bergabruh/stats-gateway).
+64
View File
@@ -0,0 +1,64 @@
package cmd
import (
"context"
"crypto/tls"
"flag"
"fmt"
"log"
"strings"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
statsv1 "github.com/bergabruh/stats-gateway/gen/stats/v1"
)
func Aggregate(args []string) {
fs := flag.NewFlagSet("aggregate", flag.ExitOnError)
server := fs.String("server", "stats.projectshitpost.fun:443", "gRPC endpoint host:port")
node := fs.String("node", "", "fl|pl1 (empty = both)")
since := fs.String("since", "", "RFC3339 lower bound (defaults to 24h ago)")
until := fs.String("until", "", "RFC3339 upper bound (defaults to now)")
timeout := fs.Duration("timeout", 10*time.Second, "")
fs.Parse(args)
if *since == "" {
// Default to 24h ago for convenience.
*since = time.Now().UTC().Add(-24 * time.Hour).Format(time.RFC3339)
}
conn, err := grpc.NewClient(*server,
grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{})))
if err != nil {
log.Fatalf("dial: %v", err)
}
defer conn.Close()
client := statsv1.NewSpeedStatusClient(conn)
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
defer cancel()
hist, err := client.Aggregate(ctx, &statsv1.Range{
Node: *node,
Since: *since,
Until: *until,
})
if err != nil {
log.Fatalf("Aggregate: %v", err)
}
fmt.Printf("Histogram of HTTP latency (ms) — node=%q since=%s\n", *node, *since)
for _, b := range hist.Buckets {
bar := ""
if b.Count > 0 {
n := int(b.Count)
if n > 60 {
n = 60
}
bar = strings.Repeat("#", n)
}
fmt.Printf(" <= %6.0fms %5d %s\n", b.UpperMs, b.Count, bar)
}
}
+65
View File
@@ -0,0 +1,65 @@
package cmd
import (
"context"
"crypto/tls"
"flag"
"fmt"
"io"
"log"
"os"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
statsv1 "github.com/bergabruh/stats-gateway/gen/stats/v1"
)
func Recent(args []string) {
fs := flag.NewFlagSet("recent", flag.ExitOnError)
server := fs.String("server", "stats.projectshitpost.fun:443", "gRPC endpoint host:port")
node := fs.String("node", "", "fl|pl1 (empty = both)")
limit := fs.Int("limit", 20, "max samples (1..1000)")
since := fs.String("since", "", "RFC3339 lower bound (optional)")
timeout := fs.Duration("timeout", 10*time.Second, "")
fs.Parse(args)
conn, err := grpc.NewClient(*server,
grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{})))
if err != nil {
log.Fatalf("dial: %v", err)
}
defer conn.Close()
client := statsv1.NewSpeedStatusClient(conn)
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
defer cancel()
stream, err := client.RecentResults(ctx, &statsv1.Filter{
Node: *node,
Limit: int32(*limit),
Since: *since,
})
if err != nil {
log.Fatalf("RecentResults: %v", err)
}
count := 0
for {
s, err := stream.Recv()
if err == io.EOF {
break
}
if err != nil {
fmt.Fprintf(os.Stderr, "recv: %v\n", err)
os.Exit(1)
}
fmt.Printf("%s %-3s http=%6.1fms tls=%6.1fms tcp=%6.1fms dns=%5.1fms\n",
s.Timestamp, s.Node, s.HttpLatencyMs, s.TlsHandshakeMs, s.TcpRttMs, s.DnsResolveMs)
count++
}
if count == 0 {
fmt.Fprintln(os.Stderr, "(no samples)")
}
}
+18
View File
@@ -0,0 +1,18 @@
module github.com/bergabruh/psp-stats-cli
go 1.25.0
require (
github.com/bergabruh/stats-gateway v0.0.0
google.golang.org/grpc v1.81.0
)
require (
golang.org/x/net v0.51.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.34.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)
replace github.com/bergabruh/stats-gateway => ..
+38
View File
@@ -0,0 +1,38 @@
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/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
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/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.81.0 h1:W3G9N3KQf3BU+YuCtGKJk0CmxQNbAISICD/9AORxLIw=
google.golang.org/grpc v1.81.0/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
+45
View File
@@ -0,0 +1,45 @@
package main
import (
"fmt"
"os"
"github.com/bergabruh/psp-stats-cli/cmd"
)
func main() {
if len(os.Args) < 2 {
usage()
os.Exit(2)
}
switch os.Args[1] {
case "recent":
cmd.Recent(os.Args[2:])
case "aggregate":
cmd.Aggregate(os.Args[2:])
case "-h", "--help", "help":
usage()
case "version":
fmt.Println("psp-stats-cli 0.1.0")
default:
fmt.Fprintf(os.Stderr, "unknown command: %s\n\n", os.Args[1])
usage()
os.Exit(2)
}
}
func usage() {
fmt.Print(`psp-stats-cli — query PSP Speedtest Stats
Usage:
psp-stats-cli <command> [flags]
Commands:
recent Show last N probe samples
aggregate Show histogram for time range
version Show CLI version
help Show this help
Run 'psp-stats-cli <command> --help' for command-specific flags.
`)
}
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env bash
# Renders stats-site (Hugo project) without requiring Hugo, deploys atomically
# to /var/www/stats-site/. The Hugo project uses only two template variables;
# we substitute them with sed.
#
# Usage: build.sh [--src DIR] [--dest DIR]
# --src default /opt/stats-site
# --dest default /var/www/stats-site
set -euo pipefail
SRC="${SRC:-/opt/stats-site}"
DEST="${DEST:-/var/www/stats-site}"
# Hugo variables (mirror config.toml).
SITE_TITLE='PSP Speedtest Stats'
SITE_DESC='Метрики латентности и времени отклика двух geo-распределённых OpenSpeedTest нод. Snapshot обновляется каждые 10 минут.'
[ -d "$SRC" ] || { echo "src $SRC not found" >&2; exit 1; }
TMP=$(mktemp -d)
trap "rm -rf $TMP" EXIT
# 1. Copy static/ verbatim
cp -r "$SRC/static/." "$TMP/"
# 2. Render layouts/index.html with sed-substitution
sed \
-e "s|{{ \.Site\.Title }}|${SITE_TITLE}|g" \
-e "s|{{ \.Site\.Params\.description }}|${SITE_DESC}|g" \
"$SRC/layouts/index.html" > "$TMP/index.html"
# 3. Atomic rsync, preserving runtime data (snapshot.json) and ownership.
# --delete removes files in DEST that aren't in TMP (cleans old assets),
# --exclude protects data/ (snapshot generator owns that).
rsync -a --delete \
--exclude='data/snapshot.json' \
--exclude='data/snapshot.json.tmp' \
--chown=deploy:www-data \
"$TMP/" "$DEST/"
echo "site built: $(find $DEST -type f | wc -l) files in $DEST"
+10
View File
@@ -0,0 +1,10 @@
baseURL = "https://stats.projectshitpost.fun/"
title = "PSP Speedtest Stats"
languageCode = "ru"
defaultContentLanguage = "ru"
[params]
description = "Метрики латентности и времени отклика двух geo-распределённых OpenSpeedTest нод. Snapshot обновляется каждые 10 минут."
[markup.goldmark.renderer]
unsafe = true
+3
View File
@@ -0,0 +1,3 @@
---
title: "PSP Speedtest Stats"
---
+49
View File
@@ -0,0 +1,49 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ .Site.Title }} — Доступность FL и PL1</title>
<meta name="description" content="{{ .Site.Params.description }}">
<link rel="icon" href="/favicon.ico">
<link rel="stylesheet" href="/css/main.css">
</head>
<body>
<header>
<h1>{{ .Site.Title }}</h1>
<p class="subtitle">Network probe stats — обновляется каждые 10 минут.</p>
</header>
<main>
<section class="nodes">
<article class="node" id="node-fl">
<h2>FL — Финляндия</h2>
<div class="metrics" data-node="fl">
<p class="loading">Загрузка…</p>
</div>
<canvas class="chart" data-node="fl" width="600" height="180"></canvas>
</article>
<article class="node" id="node-pl1">
<h2>PL1 — Польша</h2>
<div class="metrics" data-node="pl1">
<p class="loading">Загрузка…</p>
</div>
<canvas class="chart" data-node="pl1" width="600" height="180"></canvas>
</article>
</section>
<section class="meta">
<p id="generated-at">Snapshot age: <span data-bind="age"></span></p>
<p>Probes: HTTP HEAD, TCP RTT, DNS resolve, TLS handshake.</p>
<p>Источник: <a href="https://gitea.projectshitpost.fun/bergabruh/stats-gateway">stats-gateway</a> (open-source).</p>
</section>
<nav class="footer-nav">
<a href="/about/">About</a>
</nav>
</main>
<script src="/js/charts.js"></script>
</body>
</html>
+26
View File
@@ -0,0 +1,26 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>About — PSP Speedtest Stats</title>
<link rel="stylesheet" href="/css/main.css">
</head>
<body>
<header>
<h1>About</h1>
</header>
<main>
<p>Этот dashboard показывает результаты регулярных HTTP-проб двух
speedtest-нод (FL — Финляндия, PL1 — Польша) от gitea-CI/CD-узла,
раз в 5 минут. Данные хранятся в SQLite на gitea-сервере и
публикуются как snapshot.json раз в 10 минут.</p>
<p>Цель — мониторить деградации latency/throughput для собственного
использования. API сервиса описан в
<a href="https://gitea.projectshitpost.fun/bergabruh/stats-gateway">stats-gateway</a>.</p>
<p><a href="/">← Назад</a></p>
</main>
</body>
</html>
+99
View File
@@ -0,0 +1,99 @@
:root {
color-scheme: light dark;
--fg: #1a1a1a;
--bg: #fafafa;
--muted: #555;
--border: #ddd;
--accent: #2563eb;
}
@media (prefers-color-scheme: dark) {
:root {
--fg: #e8e8e8;
--bg: #111;
--muted: #aaa;
--border: #333;
--accent: #60a5fa;
}
}
* { box-sizing: border-box; }
body {
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
max-width: 1200px;
margin: 2em auto;
padding: 0 1em;
color: var(--fg);
background: var(--bg);
line-height: 1.5;
}
header h1 {
font-size: 1.4em;
margin-bottom: 0.2em;
}
.subtitle {
color: var(--muted);
margin-top: 0;
}
.nodes {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5em;
margin: 2em 0;
}
@media (max-width: 720px) {
.nodes { grid-template-columns: 1fr; }
}
.node {
padding: 1em;
border: 1px solid var(--border);
border-radius: 8px;
background: color-mix(in srgb, var(--bg) 92%, var(--fg) 8%);
}
.node h2 {
margin-top: 0;
font-size: 1.15em;
}
.metrics .loading { color: var(--muted); font-style: italic; }
.metrics .row {
display: flex;
justify-content: space-between;
padding: 0.25em 0;
border-bottom: 1px dashed var(--border);
}
.metrics .row:last-child { border-bottom: none; }
.metrics .label { color: var(--muted); }
.metrics .value { font-variant-numeric: tabular-nums; }
.chart {
display: block;
width: 100%;
height: 180px;
margin-top: 1em;
}
.meta {
color: var(--muted);
font-size: 0.9em;
margin: 2em 0;
}
.footer-nav a {
color: var(--accent);
text-decoration: none;
margin-right: 1em;
}
.footer-nav a:hover { text-decoration: underline; }
+1
View File
@@ -0,0 +1 @@
# Snapshot files deployed at runtime by stats-snapshot.timer
+137
View File
@@ -0,0 +1,137 @@
(function () {
'use strict';
const SNAPSHOT_URL = '/data/snapshot.json';
async function load() {
let data;
try {
const r = await fetch(SNAPSHOT_URL, { cache: 'no-cache' });
if (!r.ok) throw new Error('HTTP ' + r.status);
data = await r.json();
} catch (e) {
console.error('snapshot load failed', e);
document.querySelectorAll('.metrics').forEach(el => {
el.innerHTML = '<p class="loading">Snapshot не загружен (' + e.message + ').</p>';
});
return;
}
renderAge(data.generated_at);
for (const node of ['fl', 'pl1']) {
const nd = data.nodes && data.nodes[node];
if (!nd || !nd.recent || nd.recent.length === 0) {
renderMetrics(node, null);
continue;
}
const samples = nd.recent;
renderMetrics(node, samples);
renderChart(node, samples);
}
}
function renderAge(generatedAt) {
const el = document.querySelector('[data-bind="age"]');
if (!el || !generatedAt) return;
const ts = new Date(generatedAt);
const ageS = Math.round((Date.now() - ts.getTime()) / 1000);
if (ageS < 90) el.textContent = ageS + 's';
else if (ageS < 5400) el.textContent = Math.round(ageS / 60) + 'min';
else el.textContent = Math.round(ageS / 3600) + 'h';
}
function renderMetrics(node, samples) {
const block = document.querySelector('.metrics[data-node="' + node + '"]');
if (!block) return;
if (!samples || samples.length === 0) {
block.innerHTML = '<p class="loading">Нет данных за окно.</p>';
return;
}
// most-recent sample
const latest = samples[0];
// p50/p95 of http_ms over the window
const httpVals = samples.map(s => s.http_ms).filter(v => typeof v === 'number').sort((a, b) => a - b);
const p50 = httpVals[Math.floor(httpVals.length * 0.5)] || 0;
const p95 = httpVals[Math.floor(httpVals.length * 0.95)] || 0;
block.innerHTML = [
row('Last HTTP', fmtMs(latest.http_ms)),
row('Last TLS handshake', fmtMs(latest.tls_ms)),
row('p50 HTTP (24h)', fmtMs(p50)),
row('p95 HTTP (24h)', fmtMs(p95)),
row('Samples (24h)', String(samples.length)),
].join('');
}
function row(label, value) {
return '<div class="row"><span class="label">' + label + '</span><span class="value">' + value + '</span></div>';
}
function fmtMs(v) {
if (typeof v !== 'number') return '—';
if (v < 10) return v.toFixed(2) + ' ms';
if (v < 100) return v.toFixed(1) + ' ms';
return Math.round(v) + ' ms';
}
function renderChart(node, samples) {
const canvas = document.querySelector('.chart[data-node="' + node + '"]');
if (!canvas || !canvas.getContext) return;
const ctx = canvas.getContext('2d');
// High-DPI scaling
const dpr = window.devicePixelRatio || 1;
const cssW = canvas.clientWidth || canvas.width;
const cssH = canvas.height;
canvas.width = cssW * dpr;
canvas.height = cssH * dpr;
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, cssW, cssH);
const points = samples
.map(s => ({ t: new Date(s.ts).getTime(), v: s.http_ms }))
.filter(p => !isNaN(p.t) && typeof p.v === 'number')
.sort((a, b) => a.t - b.t);
if (points.length === 0) return;
const tMin = points[0].t;
const tMax = points[points.length - 1].t;
const tSpan = Math.max(1, tMax - tMin);
const vMax = Math.max(...points.map(p => p.v)) * 1.1 || 1;
// axes
const pad = 24;
const w = cssW - pad * 2;
const h = cssH - pad * 2;
const muted = getComputedStyle(document.documentElement).getPropertyValue('--muted').trim() || '#555';
const accent = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() || '#2563eb';
ctx.strokeStyle = muted;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(pad, pad);
ctx.lineTo(pad, pad + h);
ctx.lineTo(pad + w, pad + h);
ctx.stroke();
// y-axis label (max value)
ctx.fillStyle = muted;
ctx.font = '10px system-ui, sans-serif';
ctx.textAlign = 'right';
ctx.fillText(Math.round(vMax) + ' ms', pad - 2, pad + 8);
ctx.fillText('0', pad - 2, pad + h);
// points
ctx.fillStyle = accent;
for (const p of points) {
const x = pad + ((p.t - tMin) / tSpan) * w;
const y = pad + h - (p.v / vMax) * h;
ctx.beginPath();
ctx.arc(x, y, 1.5, 0, Math.PI * 2);
ctx.fill();
}
}
document.addEventListener('DOMContentLoaded', load);
})();
+12
View File
@@ -0,0 +1,12 @@
[Unit]
Description=stats-gateway probe collector
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
User=stats
Group=stats
ExecStart=/usr/local/bin/stats-collector --db /var/lib/stats-gateway/stats.db
StandardOutput=journal
StandardError=journal
+11
View File
@@ -0,0 +1,11 @@
[Unit]
Description=stats-gateway probe every 5 minutes
[Timer]
OnBootSec=2min
OnUnitActiveSec=5min
RandomizedDelaySec=30s
Persistent=true
[Install]
WantedBy=timers.target
+24
View File
@@ -0,0 +1,24 @@
[Unit]
Description=stats-gateway gRPC service
After=network.target
Requires=network.target
[Service]
Type=simple
User=stats
Group=stats
ExecStart=/usr/local/bin/stats-gateway --addr 127.0.0.1:50051 --db /var/lib/stats-gateway/stats.db
Restart=on-failure
RestartSec=5s
StandardOutput=journal
StandardError=journal
# hardening
NoNewPrivileges=true
ProtectSystem=strict
ReadWritePaths=/var/lib/stats-gateway
ProtectHome=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
+15
View File
@@ -0,0 +1,15 @@
[Unit]
Description=Render stats-site templates and deploy to /var/www/stats-site
[Service]
Type=oneshot
User=root
Group=root
ExecStart=/opt/stats-site/build.sh
StandardOutput=journal
StandardError=journal
NoNewPrivileges=true
ProtectSystem=strict
ReadWritePaths=/var/www/stats-site
ProtectHome=true
PrivateTmp=true
+11
View File
@@ -0,0 +1,11 @@
[Unit]
Description=Run stats-site rebuild hourly
[Timer]
OnBootSec=5min
OnUnitActiveSec=1h
RandomizedDelaySec=2min
Persistent=true
[Install]
WantedBy=timers.target
+19
View File
@@ -0,0 +1,19 @@
[Unit]
Description=Generate stats snapshot.json for stats-site dashboard
After=stats-gateway.service
Wants=stats-gateway.service
[Service]
Type=oneshot
User=stats
Group=www-data
ExecStart=/usr/local/bin/stats-snapshot --db /var/lib/stats-gateway/stats.db --out /var/www/stats-site/data/snapshot.json
StandardOutput=journal
StandardError=journal
# Need write access to /var/www/stats-site/data
ReadWritePaths=/var/www/stats-site/data
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
+11
View File
@@ -0,0 +1,11 @@
[Unit]
Description=Run stats snapshot every 10 minutes
[Timer]
OnBootSec=3min
OnUnitActiveSec=10min
RandomizedDelaySec=30s
Persistent=true
[Install]
WantedBy=timers.target