chore: merge dev into main [skip ci] #19

Merged
jmiller merged 4 commits from dev into main 2026-05-21 02:07:42 +00:00
19 changed files with 0 additions and 2275 deletions
-186
View File
@@ -1,186 +0,0 @@
networks:
monitoring:
driver: bridge
volumes:
prometheus_data: null
grafana_data: null
services:
prometheus:
image: prom/prometheus:latest
container_name: prometheus
restart: unless-stopped
ports:
- 127.0.0.1:9091:9090
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
- ./targets:/etc/prometheus/targets:ro
- prometheus_data:/prometheus
command:
- --config.file=/etc/prometheus/prometheus.yml
- --storage.tsdb.path=/prometheus
- --storage.tsdb.retention.time=90d
- --web.enable-lifecycle
extra_hosts:
- host.docker.internal:host-gateway
networks:
- monitoring
healthcheck:
test:
- CMD
- wget
- -qO-
- http://localhost:9090/-/healthy
interval: 30s
timeout: 5s
retries: 3
node-exporter:
image: prom/node-exporter:latest
container_name: node-exporter
restart: unless-stopped
ports:
- 127.0.0.1:9100:9100
volumes:
- /proc:/host/proc:ro
- /sys:/host/sys:ro
- /:/rootfs:ro
- /var/run/dbus/system_bus_socket:/var/run/dbus/system_bus_socket:ro
- /var/lib/prometheus/node-exporter:/textfile:ro
command:
- --path.procfs=/host/proc
- --path.sysfs=/host/sys
- --path.rootfs=/rootfs
- --collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)
- --collector.netclass.ignored-devices=^(veth.*|br-.*|docker.*)$$
- --collector.diskstats.device-exclude=^(ram|loop|fd|dm-)\d+$$
- --collector.systemd
- --collector.systemd.unit-include=.+
- --collector.textfile.directory=/textfile
pid: host
security_opt:
- apparmor:unconfined
networks:
- monitoring
healthcheck:
test:
- CMD
- wget
- --spider
- -q
- http://localhost:9100/metrics
interval: 30s
timeout: 5s
retries: 3
cadvisor:
image: gcr.io/cadvisor/cadvisor:latest
container_name: cadvisor
restart: unless-stopped
ports:
- 127.0.0.1:8082:8080
volumes:
- /:/rootfs:ro
- /var/run:/var/run:ro
- /sys:/sys:ro
- /var/lib/docker/:/var/lib/docker:ro
- /dev/disk/:/dev/disk:ro
privileged: true
devices:
- /dev/kmsg
networks:
- monitoring
healthcheck:
test:
- CMD
- wget
- --spider
- -q
- http://localhost:8080/healthz
interval: 30s
timeout: 5s
retries: 3
nginx-exporter:
image: nginx/nginx-prometheus-exporter:latest
container_name: nginx-exporter
restart: unless-stopped
network_mode: host
command:
- --nginx.scrape-uri=http://127.0.0.1:8888/nginx_status
- --web.listen-address=0.0.0.0:9113
grafana:
image: grafana/grafana:latest
container_name: grafana
restart: unless-stopped
ports:
- 127.0.0.1:3001:3000
environment:
- GF_SECURITY_ADMIN_USER=jmiller
- GF_SECURITY_ADMIN_PASSWORD=#2918HeatherfieldDrive
- GF_SERVER_ROOT_URL=https://bench.mokoconsulting.tech/
- GF_SERVER_DOMAIN=bench.mokoconsulting.tech
- GF_AUTH_ANONYMOUS_ENABLED=true
- GF_AUTH_ANONYMOUS_ORG_ROLE=Viewer
- GF_AUTH_ANONYMOUS_ORG_ID=1
- GF_USERS_ALLOW_SIGN_UP=false
- GF_USERS_ALLOW_ORG_CREATE=false
- GF_SECURITY_COOKIE_SECURE=true
- GF_SECURITY_STRICT_TRANSPORT_SECURITY=true
- GF_SECURITY_X_CONTENT_TYPE_OPTIONS=true
- GF_SECURITY_X_XSS_PROTECTION=true
- GF_LOG_MODE=console
- GF_LOG_LEVEL=debug
- GF_USERS_DEFAULT_THEME=dark
- GF_BRANDING_APP_TITLE=Moko Bench
- GF_BRANDING_LOGIN_TITLE=Moko Consulting
- GF_BRANDING_LOGIN_SUBTITLE=Server Performance Dashboard
- GF_DATE_FORMATS_FULL_DATE=YYYY-MM-DD HH:mm:ss
- GF_INSTALL_PLUGINS=grafana-clock-panel,grafana-polystat-panel,yesoreyeram-infinity-datasource,natel-discrete-panel
- GF_AUTH_GOOGLE_ENABLED=true
- GF_AUTH_GOOGLE_CLIENT_ID=349391103517-oiq974b2gq4r3t9f9cf43im31gtruhml.apps.googleusercontent.com
- GF_AUTH_GOOGLE_CLIENT_SECRET=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
- GF_AUTH_GOOGLE_SCOPES=openid email profile
- GF_AUTH_GOOGLE_AUTH_URL=https://accounts.google.com/o/oauth2/v2/auth
- GF_AUTH_GOOGLE_TOKEN_URL=https://oauth2.googleapis.com/token
- GF_AUTH_GOOGLE_ALLOWED_DOMAINS=mokoconsulting.tech
- GF_AUTH_GOOGLE_ALLOW_SIGN_UP=true
- GF_AUTH_GOOGLE_AUTO_LOGIN=false
- GF_AUTH_GOOGLE_SKIP_ORG_ROLE_SYNC=true
- GF_USERS_AUTO_ASSIGN_ORG_ROLE=Admin
volumes:
- grafana_data:/var/lib/grafana
- ./grafana/provisioning:/etc/grafana/provisioning:ro
- ./grafana/custom.ini:/etc/grafana/grafana.ini:ro
- ./grafana/dashboards:/var/lib/grafana/dashboards:ro
networks:
- monitoring
depends_on:
prometheus:
condition: service_healthy
healthcheck:
test:
- CMD
- wget
- --spider
- -q
- http://localhost:3000/api/health
interval: 30s
timeout: 5s
retries: 3
mysqld-exporter:
image: prom/mysqld-exporter:latest
container_name: mysqld-exporter
restart: unless-stopped
network_mode: host
volumes:
- /opt/gitea-server-setup/docker/monitoring/.mysqld-exporter.cnf:/cfg/.my.cnf:ro
environment:
MYSQLD_EXPORTER_PASSWORD: exporter_moko_2026
command:
- --config.my-cnf=/cfg/.my.cnf
- --web.listen-address=127.0.0.1:9104
healthcheck:
test:
- CMD-SHELL
- wget -q --spider http://localhost:9104/metrics || exit 1
interval: 30s
timeout: 10s
retries: 3
start_period: 15s
@@ -1,89 +0,0 @@
{
"name": "CPU Usage",
"kind": 1,
"model": {
"description": "",
"fieldConfig": {
"defaults": {
"custom": {
"fillOpacity": 20,
"gradientMode": "scheme",
"lineWidth": 2
},
"max": 1,
"min": 0,
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "yellow",
"value": 0.7
},
{
"color": "red",
"value": 0.9
}
]
},
"unit": "percentunit"
},
"overrides": [
{
"matcher": {
"id": "byName",
"options": "Yesterday CPU"
},
"properties": [
{
"id": "custom.lineStyle",
"value": {
"dash": [
10,
10
],
"fill": "dash"
}
},
{
"id": "custom.fillOpacity",
"value": 0
},
{
"id": "custom.lineWidth",
"value": 1
},
{
"id": "color",
"value": {
"fixedColor": "rgba(255,255,255,0.35)",
"mode": "fixed"
}
}
]
}
]
},
"targets": [
{
"expr": "1 - avg(rate(node_cpu_seconds_total{mode=\"idle\"}[5m]))",
"legendFormat": "Total CPU",
"refId": "A"
},
{
"expr": "1 - avg(rate(node_cpu_seconds_total{mode=\"idle\"}[5m] offset 1d))",
"legendFormat": "Yesterday CPU",
"refId": "B"
}
],
"options": {
"legend": { "displayMode": "list", "placement": "right", "calcs": [] },
"tooltip": { "mode": "multi" }
},
"title": "CPU Usage %",
"type": "timeseries"
}
}
@@ -1,45 +0,0 @@
{
"name": "Disk Usage",
"kind": 1,
"model": {
"description": "",
"fieldConfig": {
"defaults": {
"max": 1,
"min": 0,
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "yellow",
"value": 0.75
},
{
"color": "red",
"value": 0.9
}
]
},
"unit": "percentunit"
}
},
"targets": [
{
"expr": "1 - (node_filesystem_avail_bytes{mountpoint=\"/\",fstype!=\"tmpfs\"} / node_filesystem_size_bytes{mountpoint=\"/\",fstype!=\"tmpfs\"})",
"legendFormat": "/ (root)",
"refId": "A"
},
{
"expr": "1 - (node_filesystem_avail_bytes{mountpoint=\"/mnt/backup\",fstype!=\"tmpfs\"} / node_filesystem_size_bytes{mountpoint=\"/mnt/backup\",fstype!=\"tmpfs\"})",
"legendFormat": "/mnt/backup",
"refId": "B"
}
],
"title": "Filesystem Usage",
"type": "gauge"
}
}
@@ -1,32 +0,0 @@
{
"name": "Docker Container CPU",
"kind": 1,
"model": {
"description": "",
"fieldConfig": {
"defaults": {
"custom": {
"fillOpacity": 20,
"lineWidth": 2,
"stacking": {
"mode": "normal"
}
},
"unit": "percentunit"
}
},
"targets": [
{
"expr": "rate(container_cpu_usage_seconds_total{name!=\"\",name!~\".*POD.*\"}[5m])",
"legendFormat": "{{name}}",
"refId": "A"
}
],
"options": {
"legend": { "displayMode": "list", "placement": "right", "calcs": [] },
"tooltip": { "mode": "multi" }
},
"title": "CPU Usage by Container",
"type": "timeseries"
}
}
@@ -1,32 +0,0 @@
{
"name": "Docker Container Memory",
"kind": 1,
"model": {
"description": "",
"fieldConfig": {
"defaults": {
"custom": {
"fillOpacity": 20,
"lineWidth": 2,
"stacking": {
"mode": "normal"
}
},
"unit": "bytes"
}
},
"targets": [
{
"expr": "container_memory_usage_bytes{name!=\"\",name!~\".*POD.*\"}",
"legendFormat": "{{name}}",
"refId": "A"
}
],
"options": {
"legend": { "displayMode": "list", "placement": "right", "calcs": [] },
"tooltip": { "mode": "multi" }
},
"title": "Memory Usage by Container",
"type": "timeseries"
}
}
@@ -1,48 +0,0 @@
{
"name": "Memory Usage",
"kind": 1,
"model": {
"description": "",
"fieldConfig": {
"defaults": {
"custom": {
"fillOpacity": 30,
"lineWidth": 2,
"stacking": {
"group": "A",
"mode": "normal"
}
},
"unit": "bytes"
}
},
"targets": [
{
"expr": "node_memory_MemTotal_bytes - node_memory_MemFree_bytes - node_memory_Buffers_bytes - node_memory_Cached_bytes - node_memory_SReclaimable_bytes",
"legendFormat": "Used",
"refId": "A"
},
{
"expr": "node_memory_Buffers_bytes",
"legendFormat": "Buffers",
"refId": "B"
},
{
"expr": "node_memory_Cached_bytes + node_memory_SReclaimable_bytes",
"legendFormat": "Cached",
"refId": "C"
},
{
"expr": "node_memory_MemFree_bytes",
"legendFormat": "Free",
"refId": "D"
}
],
"options": {
"legend": { "displayMode": "list", "placement": "right", "calcs": [] },
"tooltip": { "mode": "multi" }
},
"title": "Memory Usage",
"type": "timeseries"
}
}
@@ -1,38 +0,0 @@
{
"name": "MySQL Connections",
"kind": 1,
"model": {
"description": "",
"fieldConfig": {
"defaults": {
"custom": {
"fillOpacity": 15,
"lineWidth": 2
}
}
},
"targets": [
{
"expr": "mysql_global_status_threads_connected",
"legendFormat": "Connected",
"refId": "A"
},
{
"expr": "mysql_global_status_threads_running",
"legendFormat": "Running",
"refId": "B"
},
{
"expr": "mysql_global_variables_max_connections",
"legendFormat": "Max",
"refId": "C"
}
],
"options": {
"legend": { "displayMode": "list", "placement": "right", "calcs": [] },
"tooltip": { "mode": "multi" }
},
"title": "Connections",
"type": "timeseries"
}
}
@@ -1,69 +0,0 @@
{
"name": "MySQL Queries per Second",
"kind": 1,
"model": {
"description": "",
"fieldConfig": {
"defaults": {
"custom": {
"fillOpacity": 20,
"lineWidth": 2
},
"unit": "ops"
},
"overrides": [
{
"matcher": {
"id": "byName",
"options": "Yesterday"
},
"properties": [
{
"id": "custom.lineStyle",
"value": {
"dash": [
10,
10
],
"fill": "dash"
}
},
{
"id": "custom.fillOpacity",
"value": 0
},
{
"id": "custom.lineWidth",
"value": 1
},
{
"id": "color",
"value": {
"fixedColor": "rgba(255,255,255,0.35)",
"mode": "fixed"
}
}
]
}
]
},
"targets": [
{
"expr": "rate(mysql_global_status_queries[5m])",
"legendFormat": "Queries/s",
"refId": "A"
},
{
"expr": "rate(mysql_global_status_queries[5m] offset 1d)",
"legendFormat": "Yesterday",
"refId": "B"
}
],
"options": {
"legend": { "displayMode": "list", "placement": "right", "calcs": [] },
"tooltip": { "mode": "multi" }
},
"title": "Queries per Second",
"type": "timeseries"
}
}
@@ -1,79 +0,0 @@
{
"name": "Network Traffic",
"kind": 1,
"model": {
"description": "",
"fieldConfig": {
"defaults": {
"custom": {
"fillOpacity": 20,
"lineWidth": 2
},
"unit": "bps"
},
"overrides": [
{
"matcher": {
"id": "byRegexp",
"options": "/Yesterday.*/"
},
"properties": [
{
"id": "custom.lineStyle",
"value": {
"dash": [
10,
10
],
"fill": "dash"
}
},
{
"id": "custom.fillOpacity",
"value": 0
},
{
"id": "custom.lineWidth",
"value": 1
},
{
"id": "color",
"value": {
"fixedColor": "rgba(255,255,255,0.35)",
"mode": "fixed"
}
}
]
}
]
},
"targets": [
{
"expr": "rate(node_network_receive_bytes_total{device!~\"lo|veth.*|br-.*|docker.*\"}[5m]) * 8",
"legendFormat": "RX {{device}}",
"refId": "A"
},
{
"expr": "-rate(node_network_transmit_bytes_total{device!~\"lo|veth.*|br-.*|docker.*\"}[5m]) * 8",
"legendFormat": "TX {{device}}",
"refId": "B"
},
{
"expr": "rate(node_network_receive_bytes_total{device!~\"lo|veth.*|br-.*|docker.*\"}[5m] offset 1d) * 8",
"legendFormat": "Yesterday RX {{device}}",
"refId": "C"
},
{
"expr": "-rate(node_network_transmit_bytes_total{device!~\"lo|veth.*|br-.*|docker.*\"}[5m] offset 1d) * 8",
"legendFormat": "Yesterday TX {{device}}",
"refId": "D"
}
],
"options": {
"legend": { "displayMode": "list", "placement": "right", "calcs": [] },
"tooltip": { "mode": "multi" }
},
"title": "Network Traffic",
"type": "timeseries"
}
}
@@ -1,43 +0,0 @@
{
"name": "Nginx Connections",
"kind": 1,
"model": {
"description": "",
"fieldConfig": {
"defaults": {
"custom": {
"fillOpacity": 20,
"lineWidth": 2
}
}
},
"targets": [
{
"expr": "nginx_connections_active",
"legendFormat": "Active",
"refId": "A"
},
{
"expr": "nginx_connections_reading",
"legendFormat": "Reading",
"refId": "B"
},
{
"expr": "nginx_connections_writing",
"legendFormat": "Writing",
"refId": "C"
},
{
"expr": "nginx_connections_waiting",
"legendFormat": "Waiting",
"refId": "D"
}
],
"options": {
"legend": { "displayMode": "list", "placement": "right", "calcs": [] },
"tooltip": { "mode": "multi" }
},
"title": "Connections Over Time",
"type": "timeseries"
}
}
@@ -1,46 +0,0 @@
{
"name": "Nginx Request Rate",
"kind": 1,
"model": {
"description": "",
"fieldConfig": {
"defaults": {
"custom": {
"fillOpacity": 25,
"gradientMode": "scheme",
"lineWidth": 2
},
"thresholds": {
"steps": [
{
"color": "green",
"value": null
},
{
"color": "yellow",
"value": 50
},
{
"color": "red",
"value": 200
}
]
},
"unit": "reqps"
}
},
"targets": [
{
"expr": "rate(nginx_http_requests_total[5m])",
"legendFormat": "Requests/s",
"refId": "A"
}
],
"options": {
"legend": { "displayMode": "list", "placement": "right", "calcs": [] },
"tooltip": { "mode": "multi" }
},
"title": "Request Rate",
"type": "timeseries"
}
}
@@ -1,45 +0,0 @@
{
"name": "Response Time",
"kind": 1,
"model": {
"datasource": {
"type": "prometheus",
"uid": "PBFA97CFB590B2093"
},
"description": "",
"fieldConfig": {
"defaults": {
"unit": "s"
}
},
"options": {
"legend": {
"displayMode": "list",
"placement": "right"
},
"tooltip": {
"mode": "multi"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "PBFA97CFB590B2093"
},
"expr": "probe_http_duration_seconds{site_name=~\"$site\", job=\"blackbox-http\", phase=\"transfer\"}",
"legendFormat": "{{site_name}} transfer"
},
{
"datasource": {
"type": "prometheus",
"uid": "PBFA97CFB590B2093"
},
"expr": "probe_http_duration_seconds{site_name=~\"$site\", job=\"blackbox-http\", phase=\"processing\"}",
"legendFormat": "{{site_name}} processing"
}
],
"title": "Response Time",
"type": "timeseries"
}
}
@@ -1,64 +0,0 @@
{
"name": "Service Health",
"kind": 1,
"model": {
"description": "",
"fieldConfig": {
"defaults": {},
"overrides": [
{
"matcher": {
"id": "byName",
"options": "Value"
},
"properties": [
{
"id": "mappings",
"value": [
{
"options": {
"0": {
"color": "red",
"text": "DOWN"
},
"1": {
"color": "green",
"text": "UP"
}
},
"type": "value"
}
]
}
]
}
]
},
"targets": [
{
"expr": "up",
"format": "table",
"instant": true,
"refId": "A"
}
],
"title": "Scrape Target Health",
"transformations": [
{
"id": "organize",
"options": {
"excludeByName": {
"Time": true,
"__name__": true
},
"renameByName": {
"Value": "Status",
"instance": "Instance",
"job": "Service"
}
}
}
],
"type": "table"
}
}
@@ -1,68 +0,0 @@
{
"name": "SSL Certificate Expiry",
"kind": 1,
"model": {
"description": "",
"fieldConfig": {
"defaults": {
"custom": {
"drawStyle": "line",
"fillOpacity": 10,
"lineWidth": 2,
"spanNulls": false,
"thresholdsStyle": {
"mode": "area"
}
},
"decimals": 0,
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "red",
"value": null
},
{
"color": "yellow",
"value": 7
},
{
"color": "green",
"value": 30
}
]
},
"unit": "short"
},
"overrides": []
},
"options": {
"legend": {
"calcs": [
"lastNotNull",
"min"
],
"displayMode": "table",
"placement": "right"
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
},
"targets": [
{
"expr": "moko_ssl_cert_expiry_seconds{domain=\"git.mokoconsulting.tech\"} / 86400",
"legendFormat": "git.mokoconsulting.tech \u2014 days",
"refId": "A"
},
{
"expr": "moko_ssl_cert_expiry_seconds{domain=\"ntfy.mokoconsulting.tech\"} / 86400",
"legendFormat": "ntfy.mokoconsulting.tech \u2014 days",
"refId": "B"
}
],
"title": "Certificate Expiry Timeline",
"type": "timeseries"
}
}
@@ -1,60 +0,0 @@
{
"name": "Uptime Availability",
"kind": 1,
"model": {
"datasource": {
"type": "prometheus",
"uid": "PBFA97CFB590B2093"
},
"description": "",
"fieldConfig": {
"defaults": {
"custom": {
"fillOpacity": 10,
"lineWidth": 2
},
"max": 1,
"min": 0,
"thresholds": {
"steps": [
{
"color": "red",
"value": 0
},
{
"color": "yellow",
"value": 0.95
},
{
"color": "green",
"value": 0.99
}
]
},
"unit": "percentunit"
}
},
"options": {
"legend": {
"displayMode": "list",
"placement": "right",
"calcs": []
},
"tooltip": {
"mode": "single"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "PBFA97CFB590B2093"
},
"expr": "avg_over_time(probe_success{site_name=~\"$site\", job=\"blackbox-http\"}[1h])",
"legendFormat": "{{site_name}}"
}
],
"title": "Availability (30d)",
"type": "timeseries"
}
}
-394
View File
@@ -1,394 +0,0 @@
{
"description": "MokoWaaS endpoint monitoring — site health, Joomla core & extensions, backups, performance, and uptime",
"editable": true,
"panels": [
{
"collapsed": false,
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 0
},
"id": 100,
"title": "Site Status",
"type": "row"
},
{
"datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" },
"fieldConfig": {
"defaults": {
"noValue": "—",
"custom": { "align": "center", "cellOptions": { "type": "auto" } }
},
"overrides": [
{
"matcher": { "id": "byName", "options": "Site" },
"properties": [
{ "id": "custom.width", "value": 300 },
{ "id": "custom.align", "value": "left" },
{ "id": "links", "value": [{ "title": "Open Site", "url": "${__value.text}", "targetBlank": true }] }
]
},
{
"matcher": { "id": "byName", "options": "Online" },
"properties": [
{ "id": "mappings", "value": [{ "options": { "0": { "color": "orange", "text": "OFFLINE" }, "1": { "color": "green", "text": "ONLINE" } }, "type": "value" }] },
{ "id": "thresholds", "value": { "mode": "absolute", "steps": [{ "color": "orange", "value": null }, { "color": "green", "value": 1 }] } },
{ "id": "custom.cellOptions", "value": { "type": "color-background", "mode": "basic" } }
]
},
{
"matcher": { "id": "byName", "options": "Status" },
"properties": [
{ "id": "mappings", "value": [{ "options": { "0": { "color": "red", "text": "DOWN" }, "1": { "color": "green", "text": "UP" } }, "type": "value" }] },
{ "id": "thresholds", "value": { "mode": "absolute", "steps": [{ "color": "red", "value": null }, { "color": "green", "value": 1 }] } },
{ "id": "custom.cellOptions", "value": { "type": "color-background", "mode": "basic" } }
]
},
{
"matcher": { "id": "byName", "options": "HTTP" },
"properties": [
{ "id": "thresholds", "value": { "mode": "absolute", "steps": [{ "color": "red", "value": null }, { "color": "green", "value": 200 }, { "color": "yellow", "value": 300 }, { "color": "orange", "value": 400 }, { "color": "red", "value": 500 }] } },
{ "id": "custom.cellOptions", "value": { "type": "color-background", "mode": "basic" } },
{ "id": "links", "value": [{ "title": "HTTP Status Code Reference", "url": "https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#${__value.raw}", "targetBlank": true }] }
]
},
{
"matcher": { "id": "byName", "options": "API" },
"properties": [
{ "id": "mappings", "value": [{ "options": { "0": { "color": "red", "text": "NO" }, "1": { "color": "green", "text": "OK" } }, "type": "value" }] },
{ "id": "thresholds", "value": { "mode": "absolute", "steps": [{ "color": "red", "value": null }, { "color": "green", "value": 1 }] } },
{ "id": "custom.cellOptions", "value": { "type": "color-background", "mode": "basic" } }
]
},
{
"matcher": { "id": "byName", "options": "SSL" },
"properties": [
{ "id": "decimals", "value": 0 },
{ "id": "unit", "value": "d" },
{ "id": "thresholds", "value": { "mode": "absolute", "steps": [{ "color": "red", "value": null }, { "color": "yellow", "value": 14 }, { "color": "green", "value": 30 }] } },
{ "id": "custom.cellOptions", "value": { "type": "color-text" } }
]
},
{
"matcher": { "id": "byName", "options": "Last Scrape" },
"properties": [
{ "id": "unit", "value": "dateTimeFromNow" }
]
}
]
},
"gridPos": { "x": 0, "y": 1, "w": 24, "h": 8 },
"id": 1,
"options": { "showHeader": true, "cellHeight": "sm", "footer": { "show": false } },
"targets": [
{
"datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" },
"expr": "probe_success{site_name=~\"$site\", job=\"blackbox-http\"}",
"instant": true, "format": "table", "refId": "STATUS"
},
{
"datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" },
"expr": "label_replace(probe_http_status_code{site_name=~\"$site\", job=\"blackbox-http\"}, \"site_url\", \"$1\", \"instance\", \"(.+)\")",
"instant": true, "format": "table", "refId": "HTTP"
},
{
"datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" },
"expr": "label_replace(joomla_site_online{site=~\"$site\"}, \"site_name\", \"$1\", \"site\", \"(.+)\")",
"instant": true, "format": "table", "refId": "ONLINE"
},
{
"datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" },
"expr": "label_replace(joomla_site_api_reachable{site=~\"$site\"}, \"site_name\", \"$1\", \"site\", \"(.+)\")",
"instant": true, "format": "table", "refId": "API"
},
{
"datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" },
"expr": "(probe_ssl_earliest_cert_expiry{site_name=~\"$site\"} - time()) / 86400",
"instant": true, "format": "table", "refId": "SSL"
},
{
"datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" },
"expr": "label_replace(joomla_monitor_last_scrape{site=~\"$site\"} * 1000, \"site_name\", \"$1\", \"site\", \"(.+)\")",
"instant": true, "format": "table", "refId": "LASTSCRAPE"
}
],
"transformations": [
{ "id": "joinByField", "options": { "byField": "site_name", "mode": "outer" } },
{
"id": "filterFieldsByName",
"options": {
"include": { "pattern": "^(site_url|Value #).*" }
}
},
{
"id": "organize",
"options": {
"renameByName": {
"site_url": "Site",
"Value #ONLINE": "Online",
"Value #STATUS": "Status",
"Value #HTTP": "HTTP",
"Value #API": "API",
"Value #SSL": "SSL",
"Value #LASTSCRAPE": "Last Scrape"
},
"indexByName": {
"site_url": 0,
"Value #ONLINE": 1,
"Value #STATUS": 2,
"Value #HTTP": 3,
"Value #API": 4,
"Value #SSL": 5,
"Value #LASTSCRAPE": 6
}
}
},
{ "id": "sortBy", "options": { "sort": [{ "field": "Site", "desc": false }] } }
],
"title": "Site Health",
"type": "table"
},
{
"collapsed": true,
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 25
},
"id": 101,
"title": "Joomla Core & Updates",
"type": "row",
"panels": [
{
"datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" },
"fieldConfig": {
"defaults": {
"noValue": "—",
"custom": { "align": "center", "cellOptions": { "type": "auto" } }
},
"overrides": [
{
"matcher": { "id": "byName", "options": "Site" },
"properties": [
{ "id": "custom.width", "value": 300 },
{ "id": "custom.align", "value": "left" },
{ "id": "links", "value": [{ "title": "Open Admin", "url": "${__value.text}/administrator/", "targetBlank": true }] }
]
},
{
"matcher": { "id": "byName", "options": "System" },
"properties": [
{ "id": "mappings", "value": [{ "options": { "0": { "color": "green", "text": "CURRENT" }, "1": { "color": "red", "text": "UPDATE" } }, "type": "value" }] },
{ "id": "thresholds", "value": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "red", "value": 1 }] } },
{ "id": "custom.cellOptions", "value": { "type": "color-background", "mode": "basic" } }
]
},
{
"matcher": { "id": "byName", "options": "Ext Updates" },
"properties": [
{ "id": "thresholds", "value": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "red", "value": 1 }] } },
{ "id": "custom.cellOptions", "value": { "type": "color-text" } },
{ "id": "mappings", "value": [{ "options": { "0": { "text": "—" } }, "type": "value" }] }
]
},
{
"matcher": { "id": "byName", "options": "Total" },
"properties": [
{ "id": "mappings", "value": [{ "options": { "0": { "text": "—" } }, "type": "value" }] }
]
},
{
"matcher": { "id": "byName", "options": "Enabled" },
"properties": [
{ "id": "mappings", "value": [{ "options": { "0": { "text": "—" } }, "type": "value" }] },
{ "id": "thresholds", "value": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] } },
{ "id": "custom.cellOptions", "value": { "type": "color-text" } }
]
},
{
"matcher": { "id": "byName", "options": "Disabled" },
"properties": [
{ "id": "mappings", "value": [{ "options": { "0": { "text": "—" } }, "type": "value" }] },
{ "id": "thresholds", "value": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "yellow", "value": 5 }, { "color": "orange", "value": 20 }] } },
{ "id": "custom.cellOptions", "value": { "type": "color-text" } }
]
}
]
},
"gridPos": { "x": 0, "y": 26, "w": 24, "h": 8 },
"id": 10,
"options": { "showHeader": true, "cellHeight": "sm", "footer": { "show": false } },
"targets": [
{
"datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" },
"expr": "label_replace(joomla_core_version_info{site=~\"$site\"}, \"site_url\", \"$1\", \"exported_instance\", \"(.+)\")",
"instant": true, "format": "table", "refId": "VERSION"
},
{
"datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" },
"expr": "max by (site) (joomla_core_update_available{site=~\"$site\"})",
"instant": true, "format": "table", "refId": "SYSTEM"
},
{
"datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" },
"expr": "joomla_extensions_update_count{site=~\"$site\"} or joomla_extensions_total{site=~\"$site\"} * 0",
"instant": true, "format": "table", "refId": "EXTUPDATES"
},
{
"datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" },
"expr": "joomla_extensions_total{site=~\"$site\"}",
"instant": true, "format": "table", "refId": "TOTAL"
},
{
"datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" },
"expr": "joomla_extensions_enabled{site=~\"$site\"}",
"instant": true, "format": "table", "refId": "ENABLED"
},
{
"datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" },
"expr": "joomla_extensions_disabled{site=~\"$site\"}",
"instant": true, "format": "table", "refId": "DISABLED"
}
],
"transformations": [
{ "id": "joinByField", "options": { "byField": "site", "mode": "outer" } },
{ "id": "filterFieldsByName", "options": { "include": { "pattern": "^(site_url|version|Value #(SYSTEM|EXTUPDATES|TOTAL|ENABLED|DISABLED))$" } } },
{
"id": "organize",
"options": {
"renameByName": {
"site_url": "Site",
"version": "Version",
"Value #SYSTEM": "System",
"Value #EXTUPDATES": "Ext Updates",
"Value #TOTAL": "Total",
"Value #ENABLED": "Enabled",
"Value #DISABLED": "Disabled"
}
}
},
{ "id": "sortBy", "options": { "sort": [{ "field": "Site", "desc": false }] } }
],
"title": "Joomla Core & Extensions",
"type": "table"
}
]
},
{
"collapsed": false,
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 46
},
"id": 102,
"title": "Performance",
"type": "row"
},
{
"datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" },
"fieldConfig": { "defaults": { "unit": "s" } },
"gridPos": { "h": 8, "w": 16, "x": 0, "y": 47 },
"id": 20,
"options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } },
"targets": [
{ "datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" }, "expr": "probe_http_duration_seconds{site_name=~\"$site\", job=\"blackbox-http\", phase=\"transfer\"}", "legendFormat": "{{site_name}} transfer" },
{ "datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" }, "expr": "probe_http_duration_seconds{site_name=~\"$site\", job=\"blackbox-http\", phase=\"processing\"}", "legendFormat": "{{site_name}} processing" }
],
"title": "Response Time",
"type": "timeseries"
},
{
"datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" },
"fieldConfig": { "defaults": { "max": 10, "min": 0, "thresholds": { "steps": [{ "color": "green", "value": 0 }, { "color": "yellow", "value": 2 }, { "color": "red", "value": 5 }] }, "unit": "s" } },
"gridPos": { "h": 8, "w": 8, "x": 16, "y": 47 },
"id": 22,
"targets": [
{ "datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" }, "expr": "probe_duration_seconds{site_name=~\"$site\", job=\"blackbox-http\"}", "legendFormat": "{{site_name}}" }
],
"title": "Total Duration",
"type": "gauge"
},
{
"collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 55 },
"id": 104,
"title": "Backup Status",
"type": "row"
},
{
"datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" },
"fieldConfig": {
"defaults": { "noValue": "—", "custom": { "align": "center", "cellOptions": { "type": "auto" } } },
"overrides": [
{ "matcher": { "id": "byName", "options": "Site" }, "properties": [{ "id": "custom.width", "value": 300 }, { "id": "custom.align", "value": "left" }, { "id": "links", "value": [{ "title": "Manage Backups", "url": "${__value.text}/administrator/index.php?option=com_akeebabackup&view=Manage", "targetBlank": true }] }] },
{ "matcher": { "id": "byName", "options": "Status" }, "properties": [{ "id": "mappings", "value": [{ "options": { "0": { "color": "red", "text": "FAILED" }, "1": { "color": "green", "text": "OK" } }, "type": "value" }] }, { "id": "thresholds", "value": { "mode": "absolute", "steps": [{ "color": "red", "value": null }, { "color": "green", "value": 1 }] } }, { "id": "custom.cellOptions", "value": { "type": "color-background", "mode": "basic" } }] },
{ "matcher": { "id": "byName", "options": "Age" }, "properties": [{ "id": "unit", "value": "s" }, { "id": "thresholds", "value": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "yellow", "value": 172800 }, { "color": "red", "value": 604800 }] } }, { "id": "custom.cellOptions", "value": { "type": "color-text" } }, { "id": "mappings", "value": [{ "options": { "-1": { "text": "—" } }, "type": "value" }] }] },
{ "matcher": { "id": "byName", "options": "Records" }, "properties": [{ "id": "mappings", "value": [{ "options": { "0": { "text": "—" } }, "type": "value" }] }] }
]
},
"gridPos": { "x": 0, "y": 56, "w": 24, "h": 8 },
"id": 40,
"options": { "showHeader": true, "cellHeight": "sm", "footer": { "show": false } },
"targets": [
{ "datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" }, "expr": "label_replace(max by (site, exported_instance) (joomla_backup_status{site=~\"$site\"}), \"site_url\", \"$1\", \"exported_instance\", \"(.+)\")", "instant": true, "format": "table", "refId": "STATUS" },
{ "datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" }, "expr": "joomla_backup_age_seconds{site=~\"$site\"}", "instant": true, "format": "table", "refId": "AGE" },
{ "datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" }, "expr": "joomla_backup_records_total{site=~\"$site\"}", "instant": true, "format": "table", "refId": "RECORDS" }
],
"transformations": [
{ "id": "joinByField", "options": { "byField": "site", "mode": "outer" } },
{ "id": "filterFieldsByName", "options": { "include": { "pattern": "^(site_url|Value #).*" } } },
{ "id": "organize", "options": { "renameByName": { "site_url": "Site", "Value #STATUS": "Status", "Value #AGE": "Age", "Value #RECORDS": "Records" } } },
{ "id": "sortBy", "options": { "sort": [{ "field": "Site", "desc": false }] } }
],
"title": "Backup Status",
"type": "table"
},
{
"collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 68 },
"id": 103,
"title": "Uptime History",
"type": "row"
},
{
"datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" },
"fieldConfig": { "defaults": { "custom": { "fillOpacity": 10, "lineWidth": 2 }, "max": 1, "min": 0, "thresholds": { "steps": [{ "color": "red", "value": 0 }, { "color": "yellow", "value": 0.95 }, { "color": "green", "value": 0.99 }] }, "unit": "percentunit" } },
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 69 },
"id": 30,
"options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "single" } },
"targets": [
{ "datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" }, "expr": "avg_over_time(probe_success{site_name=~\"$site\", job=\"blackbox-http\"}[1h])", "legendFormat": "{{site_name}}" }
],
"title": "Availability (30d)",
"type": "timeseries"
}
],
"refresh": "5m",
"tags": ["mokowaas", "joomla", "endpoints", "monitoring"],
"templating": {
"list": [
{
"current": { "text": "All", "value": "$__all" },
"datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" },
"includeAll": true,
"label": "Site",
"multi": true,
"name": "site",
"query": "label_values(probe_success{job=\"blackbox-http\", site_type=~\"mokowaas.*\"}, site_name)",
"refresh": 2,
"sort": 1,
"type": "query"
}
]
},
"time": { "from": "now-24h", "to": "now" },
"timezone": "browser",
"title": "MokoWaaS",
"uid": "mokowaas",
"version": 1
}
-330
View File
@@ -1,330 +0,0 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* This file is part of a Moko Consulting project.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: MokoStandards.Scripts.Monitoring
* INGROUP: MokoStandards
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /monitoring/joomla-version-audit.php
* VERSION: 01.00.00
* BRIEF: Audit Joomla core and extension versions across sites via the Joomla API
*/
declare(strict_types=1);
final class JoomlaVersionAudit
{
/** @var array<string, mixed> */
private array $args = [];
/** @var array<array{url: string, joomlaVersion: string, behind: bool, extensions: array<array{name: string, version: string}>, error: string}> */
private array $results = [];
public function run(): int
{
$this->args = $this->parseArgs();
$sites = $this->resolveSites();
if (count($sites) === 0) {
$this->log('No sites provided. Use --sites <json-file>.');
return 1;
}
$latestVersion = $this->args['latest'] ?? null;
foreach ($sites as $site) {
$this->log("Auditing: {$site['url']}");
$entry = $this->auditSite($site, $latestVersion);
$this->results[] = $entry;
}
if (!empty($this->args['json'])) {
$this->outputJson();
} else {
$this->outputTable();
}
$hasBehind = false;
foreach ($this->results as $r) {
if ($r['behind'] || $r['error'] !== '') {
$hasBehind = true;
break;
}
}
return $hasBehind ? 1 : 0;
}
/**
* @return array<string, mixed>
*/
private function parseArgs(): array
{
$args = [
'sites' => null,
'json' => false,
'latest' => null,
];
$argv = $_SERVER['argv'] ?? [];
$argc = count($argv);
for ($i = 1; $i < $argc; $i++) {
switch ($argv[$i]) {
case '--sites':
$args['sites'] = $argv[++$i] ?? null;
break;
case '--json':
$args['json'] = true;
break;
case '--latest':
$args['latest'] = $argv[++$i] ?? null;
break;
}
}
return $args;
}
/**
* @return list<array{url: string, token: string}>
*/
private function resolveSites(): array
{
$file = $this->args['sites'] ?? null;
if (empty($file)) {
return [];
}
if (!is_file($file) || !is_readable($file)) {
$this->log("Cannot read sites file: {$file}");
return [];
}
$raw = file_get_contents($file);
if ($raw === false) {
$this->log("Failed to read sites file: {$file}");
return [];
}
$data = json_decode($raw, true);
if (!is_array($data)) {
$this->log("Sites file is not valid JSON: {$file}");
return [];
}
$sites = [];
foreach ($data as $item) {
if (is_array($item) && isset($item['url'], $item['token'])) {
$sites[] = [
'url' => rtrim((string) $item['url'], '/'),
'token' => (string) $item['token'],
];
}
}
return $sites;
}
/**
* @param array{url: string, token: string} $site
* @return array{url: string, joomlaVersion: string, behind: bool, extensions: array<array{name: string, version: string}>, error: string}
*/
private function auditSite(array $site, ?string $latestVersion): array
{
$entry = [
'url' => $site['url'],
'joomlaVersion' => '',
'behind' => false,
'extensions' => [],
'error' => '',
];
// Fetch Joomla version from config/application endpoint
$configData = $this->apiGet($site['url'] . '/api/index.php/v1/config/application', $site['token']);
if ($configData === null) {
$entry['error'] = 'Failed to fetch site configuration';
return $entry;
}
// The Joomla API returns config attributes; extract version from the response
$joomlaVersion = $this->extractJoomlaVersion($configData);
$entry['joomlaVersion'] = $joomlaVersion;
// Compare against latest known version
if ($latestVersion !== null && $joomlaVersion !== '') {
if (version_compare($joomlaVersion, $latestVersion, '<')) {
$entry['behind'] = true;
}
}
// Fetch extensions list
$extData = $this->apiGet($site['url'] . '/api/index.php/v1/extensions', $site['token']);
if ($extData !== null && isset($extData['data']) && is_array($extData['data'])) {
foreach ($extData['data'] as $ext) {
$attrs = $ext['attributes'] ?? $ext;
$name = $attrs['name'] ?? $attrs['element'] ?? 'unknown';
$version = $attrs['version'] ?? '?';
// Filter to meaningful extensions (components, modules, plugins, templates)
$type = $attrs['type'] ?? '';
if (in_array($type, ['component', 'module', 'plugin', 'template', 'package', 'library'], true)) {
$entry['extensions'][] = [
'name' => (string) $name,
'version' => (string) $version,
];
}
}
}
return $entry;
}
/**
* @return array<string, mixed>|null
*/
private function apiGet(string $url, string $token): ?array
{
$context = stream_context_create([
'http' => [
'method' => 'GET',
'timeout' => 30,
'ignore_errors' => true,
'follow_location' => 1,
'max_redirects' => 3,
'header' => implode("\r\n", [
"X-Joomla-Token: {$token}",
'Accept: application/vnd.api+json',
'Content-Type: application/json',
]),
],
'ssl' => [
'verify_peer' => true,
'verify_peer_name' => true,
],
]);
$body = @file_get_contents($url, false, $context);
if ($body === false) {
$this->log(" API request failed: {$url}");
return null;
}
$data = json_decode($body, true);
if (!is_array($data)) {
$this->log(" Invalid JSON response from: {$url}");
return null;
}
return $data;
}
/**
* @param array<string, mixed> $configData
*/
private function extractJoomlaVersion(array $configData): string
{
// Joomla's Web Services API returns config data in a JSON:API structure.
// The version may appear in data.attributes or in meta.
if (isset($configData['meta']['cms-version'])) {
return (string) $configData['meta']['cms-version'];
}
// Fallback: look through data attributes for a version-like field
if (isset($configData['data']) && is_array($configData['data'])) {
foreach ($configData['data'] as $item) {
$attrs = $item['attributes'] ?? [];
// Some Joomla API responses put the version in the attributes
if (isset($attrs['cms-version'])) {
return (string) $attrs['cms-version'];
}
}
}
// Second fallback: look for X-Powered-By or similar in response
if (isset($configData['data']['attributes'])) {
$attrs = $configData['data']['attributes'];
foreach ($attrs as $key => $value) {
if (stripos($key, 'version') !== false && is_string($value)) {
return $value;
}
}
}
return 'unknown';
}
private function outputTable(): void
{
$latestVersion = $this->args['latest'] ?? null;
foreach ($this->results as $r) {
$this->log('');
$this->log(str_repeat('=', 70));
$this->log("Site: {$r['url']}");
if ($r['error'] !== '') {
$this->log(" Error: {$r['error']}");
continue;
}
$versionDisplay = $r['joomlaVersion'];
if ($r['behind'] && $latestVersion !== null) {
$versionDisplay .= " (BEHIND, latest: {$latestVersion})";
}
$this->log(" Joomla Version: {$versionDisplay}");
if (count($r['extensions']) === 0) {
$this->log(' Extensions: none found');
continue;
}
// Extension table
$colName = 4;
$colVersion = 7;
foreach ($r['extensions'] as $ext) {
$colName = max($colName, strlen($ext['name']));
$colVersion = max($colVersion, strlen($ext['version']));
}
$colName = min($colName, 50);
$colVersion = min($colVersion, 20);
$fmt = " %-{$colName}s | %-{$colVersion}s";
$this->log('');
$this->log(sprintf($fmt, 'Name', 'Version'));
$this->log(' ' . str_repeat('-', $colName + $colVersion + 4));
foreach ($r['extensions'] as $ext) {
$nameDisplay = strlen($ext['name']) > 50 ? substr($ext['name'], 0, 47) . '...' : $ext['name'];
$this->log(sprintf($fmt, $nameDisplay, $ext['version']));
}
}
$this->log('');
}
private function outputJson(): void
{
echo json_encode($this->results, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n";
}
private function log(string $message): void
{
fwrite(STDERR, $message . "\n");
}
}
$audit = new JoomlaVersionAudit();
exit($audit->run());
-312
View File
@@ -1,312 +0,0 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* This file is part of a Moko Consulting project.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: MokoStandards.Scripts.Monitoring
* INGROUP: MokoStandards
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /monitoring/ssl-check.php
* VERSION: 01.00.00
* BRIEF: Check SSL certificate expiry dates for a list of domains
*/
declare(strict_types=1);
final class SslCheck
{
/** @var array<string, mixed> */
private array $args = [];
/** @var array<array{domain: string, issuer: string, expires: string, daysLeft: int, status: string, error: string}> */
private array $results = [];
public function run(): int
{
$this->args = $this->parseArgs();
$domains = $this->resolveDomains();
if (count($domains) === 0) {
$this->log('No domains provided. Use --domains <file> or --domain <single>.');
return 1;
}
$warnDays = (int) ($this->args['warn-days'] ?? 30);
foreach ($domains as $domain) {
$this->log("Checking: {$domain}");
$entry = $this->checkCert($domain, $warnDays);
$this->results[] = $entry;
}
$hasIssue = false;
foreach ($this->results as $r) {
if ($r['status'] !== 'OK') {
$hasIssue = true;
break;
}
}
if (!empty($this->args['json'])) {
$this->outputJson();
} else {
$this->outputTable();
}
if ($hasIssue && !empty($this->args['notify'])) {
$this->sendNotification();
}
return $hasIssue ? 1 : 0;
}
/**
* @return array<string, mixed>
*/
private function parseArgs(): array
{
$args = [
'domains' => null,
'domain' => null,
'warn-days' => 30,
'notify' => null,
'json' => false,
];
$argv = $_SERVER['argv'] ?? [];
$argc = count($argv);
for ($i = 1; $i < $argc; $i++) {
switch ($argv[$i]) {
case '--domains':
$args['domains'] = $argv[++$i] ?? null;
break;
case '--domain':
$args['domain'] = $argv[++$i] ?? null;
break;
case '--warn-days':
$args['warn-days'] = (int) ($argv[++$i] ?? 30);
break;
case '--notify':
$args['notify'] = $argv[++$i] ?? null;
break;
case '--json':
$args['json'] = true;
break;
}
}
return $args;
}
/**
* @return list<string>
*/
private function resolveDomains(): array
{
$domains = [];
if (!empty($this->args['domains'])) {
$file = $this->args['domains'];
if (!is_file($file) || !is_readable($file)) {
$this->log("Cannot read domains file: {$file}");
return [];
}
$lines = file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
if ($lines !== false) {
foreach ($lines as $line) {
$line = trim($line);
if ($line !== '' && $line[0] !== '#') {
$domains[] = $line;
}
}
}
}
if (!empty($this->args['domain'])) {
$domains[] = trim($this->args['domain']);
}
return $domains;
}
/**
* @return array{domain: string, issuer: string, expires: string, daysLeft: int, status: string, error: string}
*/
private function checkCert(string $domain, int $warnDays): array
{
$entry = [
'domain' => $domain,
'issuer' => '',
'expires' => '',
'daysLeft' => 0,
'status' => 'EXPIRED',
'error' => '',
];
$context = stream_context_create([
'ssl' => [
'capture_peer_cert' => true,
'verify_peer' => true,
'verify_peer_name' => true,
'SNI_enabled' => true,
'peer_name' => $domain,
],
]);
$errno = 0;
$errstr = '';
$client = @stream_socket_client(
"ssl://{$domain}:443",
$errno,
$errstr,
15,
STREAM_CLIENT_CONNECT,
$context
);
if ($client === false) {
$entry['error'] = "Connection failed: {$errstr} ({$errno})";
return $entry;
}
$params = stream_context_get_params($client);
fclose($client);
if (!isset($params['options']['ssl']['peer_certificate'])) {
$entry['error'] = 'No peer certificate captured';
return $entry;
}
$certResource = $params['options']['ssl']['peer_certificate'];
$certInfo = openssl_x509_parse($certResource);
if ($certInfo === false) {
$entry['error'] = 'Failed to parse certificate';
return $entry;
}
// Issuer
$issuerParts = $certInfo['issuer'] ?? [];
$entry['issuer'] = $issuerParts['O'] ?? $issuerParts['CN'] ?? 'Unknown';
// Expiry
$validTo = $certInfo['validTo_time_t'] ?? 0;
$entry['expires'] = date('Y-m-d', $validTo);
$now = time();
$daysLeft = (int) floor(($validTo - $now) / 86400);
$entry['daysLeft'] = $daysLeft;
if ($daysLeft < 0) {
$entry['status'] = 'EXPIRED';
} elseif ($daysLeft <= $warnDays) {
$entry['status'] = 'WARN';
} else {
$entry['status'] = 'OK';
}
return $entry;
}
private function outputTable(): void
{
$colDomain = 6;
$colIssuer = 6;
$colExpires = 10;
$colDays = 9;
$colStatus = 7;
foreach ($this->results as $r) {
$colDomain = max($colDomain, strlen($r['domain']));
$colIssuer = max($colIssuer, strlen($r['issuer']));
}
$colDomain = min($colDomain, 40);
$colIssuer = min($colIssuer, 30);
$fmt = "%-{$colDomain}s | %-{$colIssuer}s | %-{$colExpires}s | %-{$colDays}s | %-{$colStatus}s";
$this->log('');
$this->log(sprintf($fmt, 'Domain', 'Issuer', 'Expires', 'Days Left', 'Status'));
$this->log(str_repeat('-', $colDomain + $colIssuer + $colExpires + $colDays + $colStatus + 13));
foreach ($this->results as $r) {
$domainDisplay = strlen($r['domain']) > 40 ? substr($r['domain'], 0, 37) . '...' : $r['domain'];
$issuerDisplay = strlen($r['issuer']) > 30 ? substr($r['issuer'], 0, 27) . '...' : $r['issuer'];
$daysStr = $r['error'] !== '' ? 'N/A' : (string) $r['daysLeft'];
$expiresStr = $r['expires'] ?: 'N/A';
$this->log(sprintf($fmt, $domainDisplay, $issuerDisplay, $expiresStr, $daysStr, $r['status']));
if ($r['error'] !== '') {
$this->log(" Error: {$r['error']}");
}
}
$this->log('');
}
private function outputJson(): void
{
echo json_encode($this->results, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n";
}
private function sendNotification(): void
{
$ntfyUrl = $this->args['notify'];
if (empty($ntfyUrl)) {
return;
}
$issues = [];
foreach ($this->results as $r) {
if ($r['status'] !== 'OK') {
$issues[] = $r['domain'] . ' — ' . $r['status']
. ($r['daysLeft'] > 0 ? " ({$r['daysLeft']} days left)" : '')
. ($r['error'] !== '' ? " [{$r['error']}]" : '');
}
}
$message = "SSL certificate issues:\n" . implode("\n", $issues);
$ch = curl_init($ntfyUrl);
if ($ch === false) {
$this->log('Failed to initialise curl for notification.');
return;
}
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $message,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_HTTPHEADER => [
'Title: SSL Certificate Alert',
'Priority: high',
],
]);
$result = curl_exec($ch);
if ($result === false) {
$this->log('Notification failed: ' . curl_error($ch));
} else {
$this->log('Notification sent.');
}
curl_close($ch);
}
private function log(string $message): void
{
fwrite(STDERR, $message . "\n");
}
}
$check = new SslCheck();
exit($check->run());
-295
View File
@@ -1,295 +0,0 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* This file is part of a Moko Consulting project.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: MokoStandards.Scripts.Monitoring
* INGROUP: MokoStandards
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /monitoring/uptime-probe.php
* VERSION: 01.00.00
* BRIEF: Check uptime and response time for a list of URLs
*/
declare(strict_types=1);
final class UptimeProbe
{
/** @var array<string, mixed> */
private array $args = [];
/** @var array<array{url: string, status: int, time: float, result: string, error: string}> */
private array $results = [];
public function run(): int
{
$this->args = $this->parseArgs();
$urls = $this->resolveUrls();
if (count($urls) === 0) {
$this->log('No URLs provided. Use --urls <file> or --url <single-url>.');
return 1;
}
$timeout = (int) ($this->args['timeout'] ?? 15);
foreach ($urls as $url) {
$this->log("Probing: {$url}");
$entry = $this->probe($url, $timeout);
$this->results[] = $entry;
}
$hasFailure = false;
foreach ($this->results as $r) {
if ($r['result'] === 'FAIL') {
$hasFailure = true;
break;
}
}
if (!empty($this->args['json'])) {
$this->outputJson();
} else {
$this->outputTable();
}
if ($hasFailure && !empty($this->args['notify'])) {
$this->sendNotification();
}
return $hasFailure ? 1 : 0;
}
/**
* @return array<string, mixed>
*/
private function parseArgs(): array
{
$args = [
'urls' => null,
'url' => null,
'timeout' => 15,
'notify' => null,
'json' => false,
];
$argv = $_SERVER['argv'] ?? [];
$argc = count($argv);
for ($i = 1; $i < $argc; $i++) {
switch ($argv[$i]) {
case '--urls':
$args['urls'] = $argv[++$i] ?? null;
break;
case '--url':
$args['url'] = $argv[++$i] ?? null;
break;
case '--timeout':
$args['timeout'] = (int) ($argv[++$i] ?? 15);
break;
case '--notify':
$args['notify'] = $argv[++$i] ?? null;
break;
case '--json':
$args['json'] = true;
break;
}
}
return $args;
}
/**
* @return list<string>
*/
private function resolveUrls(): array
{
$urls = [];
if (!empty($this->args['urls'])) {
$file = $this->args['urls'];
if (!is_file($file) || !is_readable($file)) {
$this->log("Cannot read URL file: {$file}");
return [];
}
$lines = file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
if ($lines !== false) {
foreach ($lines as $line) {
$line = trim($line);
if ($line !== '' && $line[0] !== '#') {
$urls[] = $line;
}
}
}
}
if (!empty($this->args['url'])) {
$urls[] = trim($this->args['url']);
}
return $urls;
}
/**
* @return array{url: string, status: int, time: float, result: string, error: string}
*/
private function probe(string $url, int $timeout): array
{
$entry = [
'url' => $url,
'status' => 0,
'time' => 0.0,
'result' => 'FAIL',
'error' => '',
];
$context = stream_context_create([
'http' => [
'method' => 'GET',
'timeout' => $timeout,
'follow_location' => 1,
'max_redirects' => 5,
'ignore_errors' => true,
],
'ssl' => [
'verify_peer' => true,
'verify_peer_name' => true,
],
]);
$start = microtime(true);
$body = @file_get_contents($url, false, $context);
$elapsed = round(microtime(true) - $start, 3);
$entry['time'] = $elapsed;
if ($body === false) {
$entry['error'] = 'Connection failed or timed out';
return $entry;
}
// Parse status code from $http_response_header
$statusCode = 0;
if (isset($http_response_header) && is_array($http_response_header)) {
foreach ($http_response_header as $header) {
if (preg_match('/^HTTP\/[\d.]+ (\d{3})/', $header, $m)) {
$statusCode = (int) $m[1];
}
}
}
$entry['status'] = $statusCode;
// Check for PHP fatal errors in the response body
$fatalPatterns = [
'Fatal error:',
'Parse error:',
'Uncaught Exception',
'Uncaught Error',
'Stack trace:',
];
foreach ($fatalPatterns as $pattern) {
if (stripos($body, $pattern) !== false) {
$entry['error'] = "PHP fatal error detected in response body";
return $entry;
}
}
if ($statusCode >= 200 && $statusCode < 400) {
$entry['result'] = 'PASS';
} else {
$entry['error'] = "HTTP {$statusCode}";
}
return $entry;
}
private function outputTable(): void
{
$colUrl = 4;
$colStatus = 6;
$colTime = 6;
$colResult = 6;
foreach ($this->results as $r) {
$colUrl = max($colUrl, strlen($r['url']));
}
$colUrl = min($colUrl, 60);
$fmt = "%-{$colUrl}s | %-{$colStatus}s | %-{$colTime}s | %-{$colResult}s";
$this->log('');
$this->log(sprintf($fmt, 'URL', 'Status', 'Time', 'Result'));
$this->log(str_repeat('-', $colUrl + $colStatus + $colTime + $colResult + 10));
foreach ($this->results as $r) {
$urlDisplay = strlen($r['url']) > 60 ? substr($r['url'], 0, 57) . '...' : $r['url'];
$timeStr = $r['time'] . 's';
$statusStr = $r['status'] > 0 ? (string) $r['status'] : 'ERR';
$this->log(sprintf($fmt, $urlDisplay, $statusStr, $timeStr, $r['result']));
}
$this->log('');
}
private function outputJson(): void
{
echo json_encode($this->results, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n";
}
private function sendNotification(): void
{
$ntfyUrl = $this->args['notify'];
if (empty($ntfyUrl)) {
return;
}
$failures = [];
foreach ($this->results as $r) {
if ($r['result'] === 'FAIL') {
$failures[] = $r['url'] . ' (' . ($r['error'] ?: 'HTTP ' . $r['status']) . ')';
}
}
$message = "Uptime probe failures:\n" . implode("\n", $failures);
$ch = curl_init($ntfyUrl);
if ($ch === false) {
$this->log('Failed to initialise curl for notification.');
return;
}
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $message,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_HTTPHEADER => [
'Title: Uptime Probe Alert',
'Priority: high',
],
]);
$result = curl_exec($ch);
if ($result === false) {
$this->log('Notification failed: ' . curl_error($ch));
} else {
$this->log('Notification sent.');
}
curl_close($ch);
}
private function log(string $message): void
{
fwrite(STDERR, $message . "\n");
}
}
$probe = new UptimeProbe();
exit($probe->run());