SSLScout is a TLS certificate expiry monitor written in Go, using the standard
library only and with zero external dependencies. It reads a list of
domains, opens a TLS connection to each of them in parallel, writes the result
to public/report.json and fires alerts through Slack, Microsoft Teams and
e-mail (SMTP) when a certificate is close to expiring, has already expired or
could not be verified. A single-file static dashboard (public/index.html)
reads that JSON and shows the state of the fleet.
Both the dashboard and the alerts are translatable. They ship in English and also come with Brazilian Portuguese — see Languages.
Live preview: https://dbaio.github.io/sslscout/ — the dashboard running
against this repository's own domains.txt, so you can click through it before
installing anything. It is a preview of the interface only, not a service; see
Live preview.
- Dashboard
- Requirements
- Install and build
- Quick start
- Command-line flags
- Configuration file
- Languages (i18n)
- Format of
domains.txt - Format of
report.json - Serving the dashboard
- Docker
- Scheduling
- Notifications
- Alert repetition
- Security
- Troubleshooting
- Development
- License
The dashboard is a single file, public/index.html, with no CDN, no remote
fonts and no external request whatsoever: it works offline, and the only thing
it fetches is the report.json sitting next to it, by relative path. From that
JSON it renders the summary per state, one record per monitored domain and the
time the report was generated.
Rows are ordered by shortest time left first, so whatever is closest to
expiring — or already past it — sits at the top. Rows with no deadline at all
(a failed connection, say) sink to the bottom, and stay there even when the
order is reversed: they say nothing about how close anything is to expiring.
Clicking any column header re-sorts, and clicking the active one flips the
direction. All of it happens in the browser, so the ordering inside
report.json is irrelevant to what you see.
Because it is a static file, anything can serve it: the built-in -serve mode,
nginx, Apache, GitHub Pages or an object bucket.
https://dbaio.github.io/sslscout/
A real dashboard, published from this repository by
.github/workflows/pages.yml. It exists so you can see the
interface without building anything — it is a preview only, not a service,
and not somewhere to monitor your own certificates:
- The data comes from the
domains.txtcommitted here: a handful of public hosts, three of them from badssl.com that are deliberately expired, name-mismatched and self-signed. That way every status the dashboard can render is on screen at once — the red rows are the demo working, not something breaking. - It is regenerated once a week, while the dashboard flags any report older than 24 h — so the preview almost always wears a Report out of date badge. That is the badge doing its job on a weekly demo, not a failure. Your own instance refreshes as often as you schedule it.
- Nothing on the page is configurable and nothing you do there is stored: you cannot add a domain to it.
To watch your own fleet, run SSLScout yourself — Quick start takes about a minute.
- Go 1.21 or newer to build (that is the version declared in
go.mod). - No external dependencies: there is no
go.sum, no vendoring, no third-party library. - Outbound TCP to the ports of the monitored domains (usually 443).
- The system CA root certificates. On an ordinary Linux machine they are already installed; on minimal containers they have to be installed explicitly (see Docker).
You do not need Go installed to run the binary. For a fully static binary
(useful in scratch/distroless containers), build with CGO_ENABLED=0 —
which is what the Dockerfile does.
With a Go toolchain available, the shortest route is go install:
go install github.com/dbaio/sslscout/cmd/sslscout@latestThat drops the sslscout binary into $(go env GOPATH)/bin. The version and
the commit reported by -version come from the build information the toolchain
records, so no extra flags are needed.
From a checkout:
git clone https://github.com/dbaio/sslscout.git
cd sslscout
go build ./cmd/sslscoutThat produces the sslscout executable in the current directory.
Alternatively, through the Makefile — which injects the version (taken from
git describe) into the main.version variable, the same one the -version
flag prints:
make build # version derived from git
make build VERSION=1.0.0 # fixed versionCross-compiling works normally, since there is no CGO:
GOOS=linux GOARCH=amd64 go build -o sslscout-linux-amd64 ./cmd/sslscout
GOOS=freebsd GOARCH=amd64 go build -o sslscout-freebsd-amd64 ./cmd/sslscout# 1. List the domains you want to monitor (one per line).
$EDITOR domains.txt
# 2. Create your configuration from the example.
cp config.example.json config.json
chmod 600 config.json
$EDITOR config.json
# 3. Run the check. This writes public/report.json.
go build ./cmd/sslscout
./sslscout
# 4. Open the dashboard.
./sslscout -serve :8080
# then browse to http://localhost:8080If you just want to see the report once, with no notifications and no server:
./sslscout -notify=false -out /tmp/report.jsonIf you would rather run everything in containers, jump to Docker Compose.
sslscout [flags]
| Flag | Type | Default | Description |
|---|---|---|---|
-domains |
string | domains.txt |
File with the domain list. |
-config |
string | config.json |
Configuration file. |
-out |
string | public/report.json |
Path of the generated JSON report. |
-timeout |
duration | 10s |
Per-connection timeout (e.g. 5s, 1m). |
-concurrency |
int | 20 |
Number of simultaneous checks. |
-retries |
int | 3 |
Attempts per domain on transient failures. |
-threshold |
int | 15 |
Overrides alert_threshold_days. Only takes effect when given explicitly. |
-critical |
int | 7 |
Overrides critical_threshold_days. Only takes effect when given explicitly. |
-lang |
string | en |
Language of the notifications: en or pt-BR. Overrides language. |
-dashboard-url |
string | (empty) | Public URL where the report is published. When set, every alert ends with a link to it. Overrides dashboard_url. |
-notify |
bool | true |
Sends notifications. Use -notify=false to turn them off. |
-state |
string | state.json |
File remembering what was already alerted, so a repeated run stays quiet. Empty disables the de-duplication. |
-repeat |
duration | 24h |
Re-sends an unchanged alert only after this long. 0 never repeats it. |
-serve |
string | (empty) | After checking, serves the report directory on this address (e.g. :8080). |
-interval |
duration | 0 |
Re-runs the check on this interval. 0 runs once. |
-fail-on |
string | none |
Exits non-zero if any result is at this level or worse: none, warning, critical, invalid, error. |
-quiet |
bool | false |
Suppresses the informational output. |
-version |
bool | false |
Prints the version and exits. |
Notes that matter:
- Configuration precedence: an explicit flag on the command line beats the environment variable, which beats the configuration file, which beats the built-in default.
-notifyis a boolean flag defaulting totrue. Writing just-notifydoes not turn it off; to turn it off you need-notify=false(the standard Go boolean flag syntax).-stateis what keeps an hourly cron from sending the same warning every hour — see Alert repetition. It must not point inside the directory-servepublishes: it lists the domains that currently have a problem.-serveserves the directory containing the-outfile. With the defaults that ispublic/, i.e.index.htmlandreport.jsontogether.report.jsonis served withCache-Control: no-storeso the browser does not show a stale report.-intervalpairs well with-serve: the process stays up serving the dashboard and re-running the check periodically, with no cron needed.-fail-onuses the same severity scale as the report states, so-fail-on warningalso fails oncritical,expired,invalidanderror. It does not apply while the process stays up because of-serve.- An explicit
-configpointing at a nonexistent file is an error. A missing defaultconfig.jsonis not: in that case the built-in defaults plus the environment variables apply. -langonly changes the notifications. The terminal output is always in English — it is written for whoever is reading a log, not for whoever receives the alert.-dashboard-urlis the address your users reach, not the port the process listens on. Behind a reverse proxy that ishttps://sslscout.example.com, nothttp://localhost:8080. It must be an absolutehttp://orhttps://URL; anything else is rejected at start-up, because a link nobody can follow is worse than no link.
| Code | Meaning |
|---|---|
0 |
Success. |
1 |
Execution error: invalid configuration, missing file, failure to write the report, failure to bind the -serve port. |
2 |
The -fail-on threshold was reached. |
Keeping those two apart matters in CI: 1 means "the tool could not run",
2 means "it ran and found what you told it to look for".
The default path is config.json in the current directory, adjustable with
-config. Use config.example.json as the starting point. The real file
must never be committed — it holds the SMTP password and the webhook URLs,
which are secrets.
{
"alert_threshold_days": 15,
"critical_threshold_days": 7,
"timeout_seconds": 10,
"concurrency": 20,
"retries": 3,
"language": "en",
"dashboard_url": "",
"slack_webhook_url": "",
"teams_webhook_url": "",
"smtp": {
"enabled": false,
"host": "smtp.example.com",
"port": 587,
"username": "your_username",
"password": "your_password",
"from": "alerts@example.com",
"to": ["sre@example.com"],
"tls": "starttls"
}
}| Field | Type | Default | Description |
|---|---|---|---|
alert_threshold_days |
int | 15 |
Days remaining at or below which a certificate becomes warning. |
critical_threshold_days |
int | 7 |
Days remaining at or below which a certificate becomes critical. Must be lower than alert_threshold_days. |
timeout_seconds |
int | 10 |
Per-connection timeout, in seconds. Equivalent to -timeout. |
concurrency |
int | 20 |
Simultaneous checks. Equivalent to -concurrency. |
retries |
int | 3 |
Attempts per domain on transient failures. Equivalent to -retries. |
state_file |
string | "state.json" |
Where the alert history is kept. Empty disables the de-duplication. Equivalent to -state. |
repeat_hours |
int | 24 |
How long an unchanged problem stays quiet. 0 never repeats it. Equivalent to -repeat. |
language |
string | "en" |
Language of the notifications: en or pt-BR. Equivalent to -lang. |
dashboard_url |
string | "" |
Public URL where the report is published. When set, alerts end with a link to it. Empty omits the line. Equivalent to -dashboard-url. |
slack_webhook_url |
string | "" |
Slack incoming webhook. Empty disables the channel. |
teams_webhook_url |
string | "" |
Microsoft Teams incoming webhook. Empty disables the channel. |
smtp |
object | — | E-mail configuration (below). |
| Field | Type | Description |
|---|---|---|
enabled |
bool | Turns e-mail delivery on or off. With false the rest of the object is ignored. |
host |
string | SMTP server. |
port |
int | SMTP port. Typically 587 for STARTTLS, 465 for implicit TLS, 25 for an internal relay without TLS. |
username |
string | Authentication user. Leave empty on relays that do not require authentication. |
password |
string | Authentication password. Prefer the environment variable. |
from |
string | Sender address. |
to |
array of string | Recipients. |
tls |
string | TLS mode: starttls, implicit or none. |
| Value | When to use it | How it works |
|---|---|---|
starttls |
The default. Port 587. | Connects in the clear and upgrades to TLS with the STARTTLS command before authenticating. |
implicit |
Port 465 (SMTPS). | The connection starts inside TLS, with no STARTTLS. |
none |
A trusted internal relay, port 25. | No encryption. Only use it on a network you control — the password, if any, travels in the clear. |
These variables override the corresponding value from the configuration
file. They are the recommended way to supply secrets, because they let you keep
a config.json with nothing sensitive inside:
| Variable | Overrides |
|---|---|
SSLSCOUT_SLACK_WEBHOOK_URL |
slack_webhook_url |
SSLSCOUT_TEAMS_WEBHOOK_URL |
teams_webhook_url |
SSLSCOUT_SMTP_USERNAME |
smtp.username |
SSLSCOUT_SMTP_PASSWORD |
smtp.password |
SSLSCOUT_LANG |
language |
SSLSCOUT_DASHBOARD_URL |
dashboard_url |
Example:
export SSLSCOUT_SLACK_WEBHOOK_URL='https://hooks.slack.com/services/T00000000/B00000000/your-webhook-token'
export SSLSCOUT_SMTP_PASSWORD='app-password'
./sslscoutTwo things can be translated, and they are chosen independently:
| What | Who chooses | Where | Default |
|---|---|---|---|
| The alerts (Slack, Teams, e-mail) | whoever runs SSLScout | language in config.json, SSLSCOUT_LANG, or -lang |
en |
| The dashboard | whoever opens the page in a browser | the picker in the header, remembered per browser | en |
The dashboard link added by dashboard_url is translated too — see
Linking back to the dashboard.
Everything else — the terminal output, the flag help, the error messages, the
report.json field names and the CSV export header — is always in English.
Those are machine and operator surfaces; translating them would only make logs
harder to search and reports harder to diff.
Languages available today: English (en) and Brazilian Portuguese
(pt-BR).
The default is English. To send them in Brazilian Portuguese, pick whichever fits your setup:
# in config.json
{ "language": "pt-BR" }
# or in the environment (handy in systemd and Docker)
export SSLSCOUT_LANG=pt-BR
# or per run
./sslscout -lang pt-BRAn unsupported language is a configuration error, not a silent fallback:
sslscout: invalid configuration:
unsupported language "klingon" (use one of: en, pt-BR)
Region variants without a catalog of their own fall back to the closest base
language, so pt-PT uses the pt-BR catalog and en-GB uses en.
The language changes the wording, the plural forms and the date format:
en expired.badssl.com:443 — expired 4164 days ago, on 2015-04-12
pt-BR expired.badssl.com:443 — expirado há 4164 dias, em 12/04/2015
The dashboard picks its language in this order, first match wins:
?lang=pt-BRin the URL — useful for a bookmark or a link in a runbook;- what the visitor last chose in the header picker (kept in
localStorage, so it is per browser and never leaves the machine); - the deployment default, from
<html data-default-lang="…">inpublic/index.html— shipped asen; en.
Setting the deployment default to auto makes the page follow the browser's
Accept-Language instead, falling back to English when it has no catalog for
it:
<html lang="en" data-theme="" data-default-lang="auto">Dates, times and numbers follow the chosen language too (Sep 5, 2026 versus
5 de set. de 2026, 4,164 versus 4.164).
Two independent places, both self-contained.
Alerts — pkg/i18n/i18n.go. Add a Lang constant and one entry to
catalogs; Supported(), the -lang help text and the validation error pick
it up automatically:
const EsES Lang = "es-ES"
var catalogs = map[Lang]catalog{
// …
EsES: {
subject: plural{
one: "SSLScout: %d certificado necesita atención",
other: "SSLScout: %d certificados necesitan atención",
},
days: plural{one: "%d día", other: "%d días"},
dateFormat: "02/01/2006",
// groupTitles, errorKinds and the line templates…
},
}TestCatalogsAreComplete in pkg/i18n/i18n_test.go fails if the new catalog
is missing any key the English one has, so a half-finished translation cannot
leak a raw token like hostname_mismatch into an alert.
Dashboard — public/index.html, the LANGS object at the top of the
script. Add an entry and its code to LANG_ORDER:
var LANG_ORDER = ['en', 'pt-BR', 'es-ES'];
var LANGS = {
// …
'es-ES': {
name: 'Español', // shown in the picker, in its own language
locale: 'es-ES', // handed to Intl for dates and numbers
s: { title: 'SSLScout — Monitor de Certificados', /* … */ }
}
};A string is plain text, a plural object {one, other}, or an array of segments
where {code: "…"} and {strong: "…"} become real elements. Placeholders look
like {name}. Any key you leave out falls back to English, so a partial
translation degrades one string at a time instead of breaking the page.
One entry per line. The rules are:
#starts a comment — it can take the whole line or come after the entry, at the end of the line.- Blank lines are ignored.
- Duplicates are dropped, preserving the original order of appearance.
- Four entry formats are accepted:
domain— port 443 is assumed;domain:port— for TLS services outside 443;https://domain/path— the URL is accepted and only host and port are extracted; the path is discarded;smtp://domain— a scheme that names a STARTTLS protocol (below).
# SSLScout domain list
# 1) plain domain — port 443 implied
example.com
www.example.com
# 2) domain with an explicit port
mail.example.com:993 # IMAPS
ldap.example.com:636 # LDAPS
api.example.com:8443 # trailing comment
# 3) URL — only host and port are used, /status is ignored
https://panel.example.com/status
https://internal.example.com:9443/health
Every entry above is normalized to the host:port form in the report — that is
what shows up in the domain field.
Not every certificate is behind a direct TLS handshake. On the submission port, on an LDAP directory or on a PostgreSQL instance the connection starts in the clear and the server only presents its certificate after the protocol agrees to upgrade. Dialling TLS straight at those ports gets you silence or a protocol error — from a service that is perfectly healthy.
Five negotiations are supported:
| Scheme | Default port | How the upgrade is requested |
|---|---|---|
smtp:// (or submission://) |
587 | EHLO, then STARTTLS |
imap:// |
143 | a001 STARTTLS |
pop3:// |
110 | STLS |
ldap:// |
389 | Extended Request with OID 1.3.6.1.4.1.1466.20037 |
postgres:// (or postgresql://) |
5432 | SSLRequest packet |
There are two ways to ask for one, and they exist for different situations:
# 1) The scheme says it outright. Use it on non-standard ports.
smtp://relay.example.com:2525
imap://mail.example.com
postgres://db.example.com:6432
# 2) On the classic ports nothing has to be said at all — the port implies it.
mail.example.com:587 # same as smtp://mail.example.com
ldap.example.com:389 # same as ldap://ldap.example.com
db.example.com:5432 # same as postgres://db.example.com
The inference covers 25, 587, 2525, 143, 110, 389 and 5432. Every one of them is a cleartext port whose TLS twin lives elsewhere — 587 upgrades while 465 is implicit, 143 upgrades while 993 is implicit — so it cannot shadow a service that would have answered a direct handshake. The ports that are implicit (443, 465, 636, 993, 995, 990) keep working exactly as before.
When the guess is wrong anyway — an implicit-TLS service parked on 587, say —
tls:// forces a direct handshake on any port:
tls://mail.example.com:587
A server that refuses the upgrade comes back as error_kind: protocol with the
reply it sent (the server answered "502 5.5.1 unknown command"), and is never
retried: the same conversation would only be replayed.
The negotiation that was used appears in the report as starttls, so nothing
about an inferred entry is invisible. MySQL is deliberately absent — its
handshake needs a capability exchange before the upgrade, and it is the one
protocol of the set where a certificate check is rarely what you want.
The report is rewritten in full on every run, at the -out path (by default
public/report.json). The current schema is version 2.
{
"schema_version": 2,
"generated_at": "2026-09-05T00:51:49.072652Z",
"duration_ms": 833,
"alert_threshold_days": 15,
"critical_threshold_days": 7,
"summary": {
"total": 5,
"ok": 2,
"warning": 0,
"critical": 0,
"expired": 1,
"invalid": 2,
"error": 0
},
"results": [
{
"domain": "github.com:443",
"host": "github.com",
"port": 443,
"status": "ok",
"valid": true,
"expires_at": "2026-11-29T23:59:59Z",
"issued_at": "2026-09-01T00:00:00Z",
"days_remaining": 85,
"subject": "github.com",
"issuer": "Sectigo Public Server Authentication CA DV E36",
"serial_number": "a59ebdb596751db7f5c095079613953c",
"dns_names": [
"github.com",
"www.github.com"
],
"tls_version": "TLS 1.3",
"cipher_suite": "TLS_AES_128_GCM_SHA256",
"checked_at": "2026-09-05T00:51:48.239286Z",
"duration_ms": 89,
"attempts": 1
},
{
"domain": "expired.badssl.com:443",
"host": "expired.badssl.com",
"port": 443,
"status": "expired",
"valid": false,
"expires_at": "2015-04-12T23:59:59Z",
"issued_at": "2015-04-09T00:00:00Z",
"days_remaining": -4164,
"subject": "*.badssl.com",
"issuer": "COMODO RSA Domain Validation Secure Server CA",
"serial_number": "4ae79549fa9abe3f100f17a478e16909",
"dns_names": [
"*.badssl.com",
"badssl.com"
],
"tls_version": "TLS 1.2",
"cipher_suite": "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256",
"metadata_insecure": true,
"checked_at": "2026-09-05T00:51:48.239289Z",
"duration_ms": 833,
"attempts": 1,
"error": "tls: failed to verify certificate: x509: certificate has expired or is not yet valid: \"*.badssl.com\" certificate is expired",
"error_kind": "expired"
}
]
}This is a real excerpt from a five-domain run, with results cut down to two
items so it fits here — which is why the summary counts do not match what is
listed. The first item passed verification, the second did not. Notice that
error and error_kind do not appear on the healthy item, and that the
certificate fields of the broken one come with metadata_insecure.
| Field | Type | Description |
|---|---|---|
schema_version |
int | Format version. Currently 2. |
generated_at |
string (RFC 3339) | When the report was generated. |
duration_ms |
int | Total run duration, in milliseconds. |
alert_threshold_days |
int | The warning threshold used in this run. |
critical_threshold_days |
int | The critical threshold used in this run. |
summary |
object | Result counts per state, plus total. |
results |
array | One object per checked domain. |
Always present:
| Field | Type | Description |
|---|---|---|
domain |
string | The normalized entry, in host:port form. |
host |
string | Host name. |
port |
int | Port used for the connection. |
starttls |
string | Absent for implicit TLS. Otherwise the negotiated protocol: smtp, imap, pop3, ldap or postgres. |
status |
string | One of ok, warning, critical, expired, invalid, error. |
valid |
bool | true only when status is ok, warning or critical. |
days_remaining |
int | Days until expiry. Can be negative when the certificate has already expired. Omitted or 0 when status is error. |
checked_at |
string (RFC 3339) | When this domain was checked. |
duration_ms |
int | Duration of this domain's check. |
attempts |
int | How many attempts were needed (see -retries). |
Optional (omitempty — they may not appear, typically when status is error
and there was no certificate to read):
| Field | Type | Description |
|---|---|---|
expires_at |
string (RFC 3339) | NotAfter of the leaf certificate. |
issued_at |
string (RFC 3339) | NotBefore of the leaf certificate. |
subject |
string | Certificate subject (usually the CN). |
issuer |
string | Issuer. |
serial_number |
string | Serial number. |
dns_names |
array of string | DNS SANs of the certificate. |
tls_version |
string | Negotiated version, e.g. TLS 1.3. |
cipher_suite |
string | Negotiated cipher. |
chain_expires_at |
string (RFC 3339) | NotAfter of the intermediate that expires first — present only when that date falls before the leaf's. |
chain_days_remaining |
int | Days until that date. It is this number, not days_remaining, that the thresholds compared. |
chain_subject |
string | Which certificate in the chain it is. |
metadata_insecure |
bool | true when the certificate fields above came from the diagnostic handshake, without verification (see below). Absent when the connection was verified successfully. |
error |
string | Readable error message. |
error_kind |
string | Error classification (table below). |
days_remaining always describes the leaf — the certificate issued for the
name you asked about. But the leaf is not the only thing that can run out: the
intermediate that signed it expires too, and when it does the site goes down
exactly as if the leaf had. That is what happened to half the web when AddTrust
expired in 2020 and DST Root X3 in 2021: every leaf involved had months left,
and every one of them stopped working.
So the three chain_* fields appear together, and only in the case worth
reporting — an intermediate the server sent that expires before the leaf:
{
"domain": "example.com:443",
"status": "critical",
"days_remaining": 200,
"chain_expires_at": "2026-09-10T12:00:00Z",
"chain_days_remaining": 4,
"chain_subject": "Example Intermediate CA"
}days_remaining still says 200, because that is the truth about the leaf. The
status is critical, because the thresholds run against the deadline that
actually matters. The alert names the intermediate, so nobody wastes an
afternoon renewing a certificate that was never the problem.
A self-signed root in the chain is ignored on purpose. Servers pad the chain with it out of habit, but trust comes from the local store, and the copy that counts is the one there — with its own dates.
The dashboard follows the same rule: the countdown on the card shows the deadline that decided the status, with a "chain, not the certificate" note under it, and the detail panel carries both dates. Sorting uses it too, so a host whose chain dies in two days sits above one whose own certificate dies in ten.
Evaluated in this order of precedence — the first one that applies wins:
status |
Meaning | valid |
|---|---|---|
error |
Network, DNS, timeout or refused-connection failure, or the server presented no certificate at all. There is no certificate metadata to report. | false |
expired |
The certificate was read successfully, but its NotAfter has passed. days_remaining is negative. |
false |
invalid |
The certificate was read, but verification failed for another reason: hostname mismatch, untrusted chain, or not yet in effect. | false |
critical |
Verification OK and days_remaining <= critical_threshold_days. |
true |
warning |
Verification OK and days_remaining <= alert_threshold_days. |
true |
ok |
Verification OK and the deadline is above the thresholds. | true |
A closed vocabulary — no other value appears in the report:
error_kind |
Meaning |
|---|---|
dns |
The name did not resolve. |
timeout |
The connection or the handshake exceeded -timeout. |
refused |
TCP connection refused on the port. |
expired |
Expired certificate. |
hostname_mismatch |
The certificate is valid, but not for the name that was queried. |
untrusted |
The chain does not chain up to a trusted system root. |
not_yet_valid |
NotBefore has not arrived yet. |
no_certificate |
The server completed the connection but presented no certificate. |
protocol |
TLS protocol error (incompatible version, malformed handshake, a service that does not speak TLS on that port). |
other |
Any other failure that does not fit the ones above. |
When TLS verification fails, the checker reconnects with verification disabled
only to extract the certificate metadata — validity, issuer, SANs — so the
dashboard can show what is wrong and since when. That reconnection is
strictly diagnostic: the status remains expired or invalid, and the
domain is never considered valid because of it.
Since that data comes from an unverified handshake, it only describes what
the server presented — it does not prove its identity. Whoever sits on the
connection path controls what shows up there. That is why the result carries
metadata_insecure: true, and why the dashboard labels the detail panel as
diagnostic data. Use it to investigate the problem, never as evidence that the
certificate belongs to who it claims.
Earlier versions wrote a bare JSON array, with no envelope. The dashboard
accepts both: when it finds an array it normalizes it to
{"schema_version": 1, "results": [...]}. v1 reports have no summary, no
thresholds and no granular states — just re-run sslscout and the file is
rewritten in the v2 schema.
./sslscout -serve :8080Checks the domains, writes the report and brings up a static HTTP server on the
-out directory (by default public/). Useful for development and for
internal-network use. Combined with -interval it becomes a long-running
process that refreshes itself:
./sslscout -serve :8080 -interval 6hWarning:
-servemode has no authentication, no TLS and no access control. Do not expose that port to the internet. See Security.
Leave sslscout running from cron/systemd and serve the public/ directory
with nginx. There is a ready-made vhost in
examples/nginx/sslscout.conf:
server {
listen 80;
server_name sslscout.internal.example.com;
root /opt/sslscout/public;
index index.html;
location = /report.json {
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
expires -1;
default_type application/json;
}
location / {
try_files $uri $uri/ =404;
}
}<VirtualHost *:80>
ServerName sslscout.internal.example.com
DocumentRoot /opt/sslscout/public
<Directory /opt/sslscout/public>
Require ip 10.0.0.0/8
Options -Indexes
AllowOverride None
</Directory>
# The report changes on every run; it must not be cached.
<Files "report.json">
Header set Cache-Control "no-store, no-cache, must-revalidate"
</Files>
</VirtualHost>The <Files> block requires the headers module
(a2enmod headers on Debian/Ubuntu).
The workflow .github/workflows/pages.yml
builds SSLScout on the runner, runs the check with -notify=false, and
publishes the public/ directory to GitHub Pages. It runs on demand
(workflow_dispatch) and once a week.
To enable it: Settings → Pages → Build and deployment → Source: GitHub
Actions. The Configure Pages step also passes enablement: true, so the
first run creates the Pages site by itself if that setting was never saved —
without it the run fails with Get Pages site failed … Not Found.
This is exactly how https://dbaio.github.io/sslscout/ is published.
Warning: in a public repository the published dashboard is public, and so is your domain list. Do not use Pages for an internal fleet.
make docker # or: docker build -t sslscout:latest .The final image is Alpine with ca-certificates (without the root
certificates every verification would fail as untrusted) and tzdata,
and it runs as a non-root user (uid 10001). The binary is static
(CGO_ENABLED=0).
This is the shape most people want: one container that checks and one that
serves the dashboard as plain static files. Both are defined in
docker-compose.yml at the repository root.
# 1. Configuration. Create config.json BEFORE the first "up" — if it is
# missing, Docker helpfully creates a DIRECTORY in its place.
cp config.example.json config.json
chmod 600 config.json
$EDITOR config.json
# 2. Optional: secrets and settings for compose.
cp examples/docker/env.example .env
$EDITOR .env
# 3. Up.
docker compose up -d --build # or: make up
open http://localhost:8080make up refuses to start when config.json is missing, precisely to avoid
that directory trap.
What the two services do:
| Service | Image | Role |
|---|---|---|
sslscout |
built from the Dockerfile |
Runs the check and rewrites report.json every SSLSCOUT_INTERVAL (12h by default). It never listens on a port. |
web |
nginx:1.27-alpine |
Serves public/ as static files on 127.0.0.1:8080. It knows nothing about SSLScout. |
They exchange data through the named volume public. A volume rather than a
bind mount avoids the usual permission fight: the image runs as uid 10001, and
a host directory owned by your user would not be writable by it. On the first
up Docker seeds the volume with the image content, so the dashboard is there
before the first check finishes. public/index.html is also bind-mounted
read-only into nginx, so editing the dashboard on the host and reloading the
page is enough — no volume rebuild.
The nginx site lives in
examples/docker/nginx.conf: no directory
listings, no server tokens, no-store on report.json, and a commented block
for restricting access by network or with HTTP basic auth.
Useful commands:
docker compose logs -f sslscout # or: make logs
docker compose exec sslscout sslscout -version
docker compose run --rm sslscout -notify=false # one extra check, right now
docker compose down # or: make down
docker compose down -v # also drops the report volumeSettings come from .env (see
examples/docker/env.example):
| Variable | Default | What it does |
|---|---|---|
SSLSCOUT_INTERVAL |
12h |
How often the check re-runs. |
SSLSCOUT_LANG |
en |
Alert language (en or pt-BR). |
SSLSCOUT_DASHBOARD_URL |
(empty) | Public URL of the dashboard, linked at the end of every alert. |
TZ |
UTC |
Time zone of the container log. |
SSLSCOUT_BIND |
127.0.0.1 |
Interface nginx is published on. |
SSLSCOUT_PORT |
8080 |
Published port. |
SSLSCOUT_SLACK_WEBHOOK_URL |
(empty) | Slack webhook. |
SSLSCOUT_TEAMS_WEBHOOK_URL |
(empty) | Teams webhook. |
SSLSCOUT_SMTP_USERNAME |
(empty) | SMTP user. |
SSLSCOUT_SMTP_PASSWORD |
(empty) | SMTP password. |
.env is git-ignored and is the right place for the credentials — they must
never end up inside docker-compose.yml.
The bind defaults to 127.0.0.1 on purpose: the dashboard has no
authentication of its own. Publish it more widely only behind a reverse proxy
with TLS and access control.
If the host already has a scheduler and you would rather not keep a process
sleeping between checks, use
examples/docker/docker-compose.oneshot.yml:
nginx stays up, and the checker runs once and exits.
docker compose -f examples/docker/docker-compose.oneshot.yml up -d web
docker compose -f examples/docker/docker-compose.oneshot.yml run --rm checkand in cron:
30 6 * * * cd /opt/sslscout && docker compose \
-f examples/docker/docker-compose.oneshot.yml run --rm checkThe exit code is the binary's own, so -fail-on works and cron reports a
failing run.
Single run, mounting the list and the configuration from outside and receiving the report in a host directory:
docker run --rm \
-v "$PWD/domains.txt:/app/domains.txt:ro" \
-v "$PWD/config.json:/app/config.json:ro" \
-v "$PWD/public:/app/public" \
sslscout:latestPassing the secrets through the environment, with no config.json in the
container:
docker run --rm \
-v "$PWD/domains.txt:/app/domains.txt:ro" \
-v "$PWD/public:/app/public" \
-e SSLSCOUT_SLACK_WEBHOOK_URL \
-e SSLSCOUT_SMTP_PASSWORD \
-e SSLSCOUT_LANG=pt-BR \
sslscout:latest -domains /app/domains.txt -out /app/public/report.jsonLong-running service, serving the dashboard and re-running every 6 hours:
docker run -d --name sslscout \
-p 127.0.0.1:8080:8080 \
-v "$PWD/domains.txt:/app/domains.txt:ro" \
-v "$PWD/config.json:/app/config.json:ro" \
-e TZ=America/Sao_Paulo \
sslscout:latest \
-domains /app/domains.txt \
-config /app/config.json \
-out /app/public/report.json \
-serve :8080 \
-interval 6hNote the -p 127.0.0.1:8080:8080: publishing it as -p 8080:8080 would leave
the unauthenticated dashboard reachable on every interface of the machine.
The directory mounted at /app/public has to be writable by uid 10001, since
that is where report.json is written. The Compose setup sidesteps this by
using a named volume.
A complete example lives in examples/crontab:
# /etc/cron.d/sslscout
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
MAILTO=sre@example.com
30 6 * * * sslscout cd /opt/sslscout && ./sslscout -quiet-quiet keeps cron silent while everything is fine: there is output (and
therefore mail to MAILTO) only when something goes wrong.
A ready unit and timer live in examples/systemd/:
sudo install -m 0644 examples/systemd/sslscout.service /etc/systemd/system/
sudo install -m 0644 examples/systemd/sslscout.timer /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now sslscout.timer
systemctl list-timers sslscout.timer
journalctl -u sslscout.service -n 50The unit is Type=oneshot, runs as the sslscout user, reads secrets from
/etc/sslscout/secrets.env (optional) and comes with systemd isolation turned
on (ProtectSystem=strict, NoNewPrivileges, syscall filter). The only
writable directory is /opt/sslscout/public, where the report is written.
If you prefer a long-running process over cron:
./sslscout -interval 6h -serve :8080Alerts are sent when there are results in an alert state. Each channel is
enabled individually and they can all coexist. To test without bothering
anyone, run with -notify=false until you are happy with the report.
The alert text is English by default and can be switched to Brazilian Portuguese — see Languages.
An alert tells you what broke; the dashboard tells you the rest. Set
dashboard_url and every alert closes with a line pointing at it, on all three
channels:
# in config.json
{ "dashboard_url": "https://sslscout.example.com" }
# or in the environment
export SSLSCOUT_DASHBOARD_URL=https://sslscout.example.com
# or per run
./sslscout -dashboard-url https://sslscout.example.comThe line follows the alert language:
en Learn more at https://sslscout.example.com
pt-BR Saiba mais em https://sslscout.example.com
Where it lands on each channel:
| Channel | Placement |
|---|---|
| Slack | Last line of the message. Slack auto-links the bare URL. |
| Microsoft Teams | A final section of the card, after the groups. |
| Last line of the plain-text body. |
Give it the address your users reach, not the port the process listens on:
behind the nginx vhost that is https://sslscout.example.com, not
http://localhost:8080. It has to be an absolute http:// or https:// URL —
a bare hostname or a relative path is rejected when the configuration is
loaded, since a link nobody can follow is worse than no link at all:
sslscout: invalid configuration:
dashboard_url "sslscout.example.com" must be an absolute http:// or https:// URL
Leave dashboard_url empty (the default) and the line is omitted entirely —
no dangling "Learn more at" with nothing after it.
- Go to https://api.slack.com/apps and click Create New App → From
scratch. Give it a name (e.g.
SSLScout) and pick the workspace. - In the sidebar, open Incoming Webhooks and turn on Activate Incoming Webhooks.
- Click Add New Webhook to Workspace, pick the destination channel and authorize it.
- Copy the generated URL — it looks like
https://hooks.slack.com/services/T00000000/B00000000/your-webhook-token.
That URL is the credential: whoever has it can post to your channel. Prefer supplying it through the environment:
export SSLSCOUT_SLACK_WEBHOOK_URL='https://hooks.slack.com/services/T00000000/B00000000/your-webhook-token'or, in config.json:
{ "slack_webhook_url": "https://hooks.slack.com/services/T00000000/B00000000/your-webhook-token" }- In Teams, open the destination channel and click the three dots → Connectors (on newer tenants: Workflows → the Post to a channel when a webhook request is received template).
- Choose Incoming Webhook, give it a name (e.g.
SSLScout) and confirm. - Copy the generated URL. It points at a
*.webhook.office.comdomain (or*.logic.azure.comin the Workflows case).
export SSLSCOUT_TEAMS_WEBHOOK_URL='https://example.webhook.office.com/webhookb2/...'As with Slack, the URL is the credential.
Gmail does not accept the account password over SMTP. You need an app password, which requires two-step verification to be active on the account:
- Turn on two-step verification at https://myaccount.google.com/security.
- Go to https://myaccount.google.com/apppasswords, create a password for "SSLScout" and copy the 16 characters it generates.
- Use that value as
smtp.password(preferably throughSSLSCOUT_SMTP_PASSWORD).
{
"smtp": {
"enabled": true,
"host": "smtp.gmail.com",
"port": 587,
"username": "alerts@yourdomain.com",
"password": "",
"from": "alerts@yourdomain.com",
"to": ["sre@yourdomain.com"],
"tls": "starttls"
}
}export SSLSCOUT_SMTP_PASSWORD='abcdefghijklmnop' # the app password, no spaces
./sslscoutTo use port 465 instead of 587, switch to "port": 465 and
"tls": "implicit".
A corporate relay that accepts the internal network without authentication:
{
"smtp": {
"enabled": true,
"host": "relay.internal.example.com",
"port": 25,
"username": "",
"password": "",
"from": "sslscout@example.com",
"to": ["sre@example.com", "noc@example.com"],
"tls": "none"
}
}If the relay supports STARTTLS on 25 or 587, prefer "tls": "starttls" —
none leaves the message (and the password, if any) in the clear on the wire.
A checker with no memory has one failure mode that outweighs any missing
feature. Put it on an hourly cron with alert_threshold_days at 15, and a
single certificate about to expire produces around 360 identical messages
before anybody renews it. Nobody responds to that by renewing faster. They mute
the channel — and then they miss the next outage, which is the one the tool
existed for.
So SSLScout keeps a small state file (state.json by default, -state to move
it, empty to switch the whole thing off) recording what it has already said.
The rules are few, and every one of them can only suppress a repeat of
something already sent:
| Situation | Alerts? |
|---|---|
| A domain nobody has heard about yet | Yes |
The status changed (warning to critical, invalid to error) |
Yes |
| The certificate was replaced — the expiry date moved | Yes |
| The countdown crossed a rung: 30, 14, 7, 3, 1 days | Yes |
Same problem, same rung, less than -repeat ago (24h) |
No |
| The certificate is fine again | No, and the entry is forgotten |
That last line matters as much as the rest: once a domain recovers it is dropped from the file, so the next problem on it alerts immediately instead of landing inside the quiet window of the previous one.
A second run says so instead of staying mysteriously silent:
Summary: total=42 ok=40 warning=1 critical=1 expired=0 invalid=0 error=0 (1.9s)
Report written to public/report.json
Nothing new to notify: 2 problem(s) already announced.
Note that this only filters notifications. report.json is always rewritten
in full, and the dashboard always shows every domain — the state file changes
who gets woken up, never what is true.
Every failure mode was chosen so that the worst case is a duplicate alert, not a missing one:
- The state file cannot be read, or is corrupt — the run reports it and starts from an empty history. Everything is announced again. Noisy, never silent.
- The delivery failed — nothing is recorded, so the next run tries again. If you have three channels and only one of them was down, that costs a duplicate on the other two. Worth it.
- The state file cannot be written — the run reports it and carries on. The alerts went out; the next run will repeat them.
- The check was interrupted (Ctrl-C or SIGTERM mid-run) — the state is left untouched, so a partial list can never make SSLScout forget a domain it simply did not reach.
# Announce a change, then stay quiet until something moves. No heartbeat.
./sslscout -repeat 0
# A reminder every 6 hours while a problem is open.
./sslscout -repeat 6h
# The old behaviour: every run alerts about everything.
./sslscout -state ""-repeat 0 suits a busy channel and an attentive team; -repeat 6h suits a
rota that changes shift. The rungs of the ladder still get through in both
cases, which is what keeps "7 days left" from being buried.
-
Never commit
config.json. It holds the SMTP password and the webhook URLs, which are full credentials. The project.gitignorealready blocksconfig.jsonandconfig.*.jsonwhile keepingconfig.example.jsonversioned. Before any commit, check withgit statusthat the file does not show up. The same goes for.env, used by Docker Compose. -
Prefer environment variables for secrets. The
SSLSCOUT_*variables override the corresponding fields, which lets you keep aconfig.jsonwith nothing sensitive in it and pass the credentials through systemd (EnvironmentFile=), Docker (-e, or.envwith Compose) or whichever secret manager you already use. -
Restrict the permissions of the configuration file:
sudo chown sslscout:sslscout /opt/sslscout/config.json sudo chmod 600 /opt/sslscout/config.json
The same applies to
/etc/sslscout/secrets.env, if you use the example systemd unit. -
-servemode has no authentication, no TLS and no access control. It serves the report directory to anyone who can reach the port. Do not expose it to the internet. If you need remote access, put it behind a reverse proxy with TLS and authentication (the nginx and Apache examples show how), or publish it with-p 127.0.0.1:8080:8080and reach it through an SSH tunnel. -
The domain list is sensitive information.
domains.txt, the generatedreport.jsonand thestate.jsonhistory describe your network surface — internal hosts included. Think twice before publishing the dashboard in a public repository or site. -
Keep
state.jsonout of the served directory. It is written with600permissions because it lists the domains that currently have a problem, which is a shortlist of where to attack. The default puts it in the working directory, not inpublic/; if you move it with-state, keep it that way. -
If a secret leaks, revoke it at the source (regenerate the webhook in Slack/Teams, revoke the app password with Google). Removing the file from disk does not invalidate the credential.
Start with the error_kind field of report.json — it is what tells you which
kind of problem you have.
error_kind |
What happened | What to do |
|---|---|---|
dns |
The name did not resolve. | Check the spelling in domains.txt. Test with dig +short your.domain.com. In a container, remember the resolver is the container's, not the host's — internal names may not exist there. |
timeout |
The connection or the handshake did not finish within -timeout. |
Test with openssl s_client -connect host:443. A firewall or security group blocking the port is the most common cause. If the server is merely slow, raise -timeout or timeout_seconds. Too high a -concurrency can also cause mass timeouts: lower it. |
refused |
TCP was refused on the port. | The service is down, or the port in the domains.txt entry is wrong. Confirm with nc -vz host port. |
expired |
The certificate expired. | Renew and redeploy the certificate. Check expires_at in the report to see how long ago. If you renewed and the error persists, the service probably did not reload the new certificate. |
hostname_mismatch |
The certificate is valid, but does not cover the name that was queried. | Compare the dns_names field of the report with the name you listed. Typical causes: a missing SAN, the host answering with the server's default certificate, or a misconfigured vhost/SNI. |
untrusted |
The chain does not reach a trusted root. | Usually the intermediate certificate is missing on the server: check with openssl s_client -showcerts -connect host:443. With an internal CA, install the root in the system store — in a container, mount it and run update-ca-certificates. If every domain came back untrusted, the problem is the environment: the ca-certificates package is missing. |
not_yet_valid |
NotBefore has not arrived yet. |
Almost always the clock of the machine running SSLScout is wrong. Check timedatectl / NTP. It can also be a certificate issued with a future date. |
no_certificate |
The connection completed but no certificate was presented. | The port probably does not speak TLS (e.g. 80 instead of 443), or the service requires SNI and has no default certificate. |
protocol |
TLS protocol error, or a refused STARTTLS upgrade. | The error field says which, and a refusal quotes the server's own reply. If the service speaks a plaintext protocol first, name it: smtp://host:2525 rather than host:2525 on a non-standard port. If it is the opposite — implicit TLS on a port SSLScout guessed was STARTTLS — force it with tls://host:port. Otherwise the service may not speak TLS there at all, or only accept versions Go refuses: check with openssl s_client -connect host:port (add -starttls smtp for the submission port). |
other |
Unclassified failure. | Read the error field, which carries the original message. |
Other common problems:
The dashboard shows "could not load" or stays empty.
report.json has not been generated yet — it does not ship in the repository.
Run ./sslscout once. Also check that the file is in the same directory as
index.html: the dashboard fetches report.json by relative path.
I double-clicked index.html and nothing loads.
Over file:// the browser blocks the report.json request under the
same-origin policy. Use ./sslscout -serve :8080 or any static HTTP server.
The dashboard shows stale data.
Force a cacheless reload (Ctrl+Shift+R). -serve mode already sends
Cache-Control: no-store for report.json; if you serve through nginx or
Apache, apply the headers from the examples above.
Every domain comes back untrusted inside the container.
ca-certificates is missing from the image. The Dockerfile in this
repository already installs it.
Notifications are not going out.
Confirm you are not running with -notify=false, that the channel has a value
configured (an empty webhook disables the channel, and smtp.enabled has to be
true) and that there really is a result in an alert state — when everything
is ok, nothing is sent.
E-mail fails with an authentication error on Gmail. The account password does not work; you need an app password (see Notifications).
-notify on its own does not turn notifications off.
It is a boolean flag defaulting to true; write -notify=false.
The alerts arrived in the wrong language.
Precedence is -lang > SSLSCOUT_LANG > language in config.json >
en. In Docker Compose, SSLSCOUT_LANG comes from .env, which beats
config.json.
Compose created a directory named config.json.
That is what Docker does when a bind-mount source is missing. Remove the
directory, create the file, and bring it up again:
docker compose down && rmdir config.json && cp config.example.json config.json.
.
├── cmd/sslscout/ # main: flags, orchestration, exit codes
├── pkg/checker/ # TLS connection, certificate reading, classification
├── pkg/config/ # loading config.json and the environment variables
├── pkg/report/ # building and writing report.json
├── pkg/notifier/ # Slack, Microsoft Teams, SMTP
├── pkg/state/ # alert history: what was announced, and when
├── pkg/i18n/ # message catalogs for the notifications
├── public/
│ ├── index.html # single-file static dashboard (with its own catalogs)
│ └── report.json # generated at run time (not versioned)
├── examples/
│ ├── crontab # cron scheduling
│ ├── systemd/ # oneshot unit + timer
│ ├── nginx/ # vhost for the static dashboard
│ └── docker/ # nginx.conf, .env template, one-shot compose
├── .github/workflows/ # CI and GitHub Pages publishing
├── config.example.json # template for config.json
├── domains.txt # domain list
├── docker-compose.yml # checker + nginx
├── Dockerfile
└── Makefile
The Makefile works with both GNU make and BSD make. make with no arguments
prints the help.
| Target | What it does |
|---|---|
make help |
Lists the targets (default). |
make build |
Builds sslscout, injecting the version into main.version. |
make run |
Builds and runs one check. |
make serve |
Builds, checks and serves the dashboard (SERVE_ADDR, default :8080). |
make test |
go test -race ./.... |
make fmt |
gofmt -w -s .. |
make vet |
go vet ./.... |
make lint |
Fails on any badly formatted file, then runs go vet. |
make clean |
Removes the binary, the coverage artifacts and public/report.json. |
make docker |
Builds the image (DOCKER_IMAGE:DOCKER_TAG). |
make up |
docker compose up -d --build (refuses to run without config.json). |
make down |
docker compose down. |
make logs |
Follows the checker logs. |
go test ./...
go test -race ./...
go test -race -coverprofile=coverage.out ./... && go tool cover -html=coverage.outTests must not depend on the external network — CI runs without guaranteed access to third-party hosts, and a test that talks to the internet turns flaky.
The workflow .github/workflows/ci.yml runs on
every push and pull request, on a matrix with Go 1.21 (the minimum in
go.mod) and stable Go, running: gofmt -l . (failing on any output),
go vet ./..., go build ./... and go test -race ./....
public/report.json is generated output, not source, and it is in
.gitignore. The reasons:
- it changes on every run, which would produce one noise commit per day;
- it describes the network surface of whoever ran the tool, internal hosts included — not something that should reach a repository by accident;
- a versioned report goes stale and misleads: the dashboard would show days remaining computed weeks ago.
The consequence is that in a fresh clone the dashboard shows the empty state
until the first run. That is the correct behaviour: an empty dashboard is
honest, a dashboard with somebody else's data is not. To fill it in, run
./sslscout (or make run).
.gitignore only applies to files that are not yet tracked. If
public/report.json is already in the Git index from an earlier commit, untrack
it once (the file stays on disk):
git rm --cached public/report.json
git commit -m "report.json is generated output, not source"- Keep the zero external dependencies rule: only the Go standard library on
the backend, and no CDN, remote font or external request in
public/index.html. - Code, comments and documentation in English. User-facing alert and dashboard text goes through the catalogs described in Languages — never hard-code a visible string.
- Run
make lintandmake testbefore opening a PR — that is what CI checks. - Breaking changes to the
report.jsonformat must bumpschema_versionand update this documentation.
BSD 2-Clause. See LICENSE.
