isitup-cli 0.1.4__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: isitup-cli
3
- Version: 0.1.4
3
+ Version: 0.2.0
4
4
  Summary: A small CLI that periodically checks whether servers respond to ping, HTTP, and TCP ports
5
5
  Author: stefan.insam
6
6
  Author-email: stefan.insam <stefan.insam@netgo.de>
@@ -37,7 +37,7 @@ Description-Content-Type: text/markdown
37
37
  ---
38
38
 
39
39
  **isitup** is a small CLI that periodically checks whether servers respond to ping and,
40
- depending on how the target is written, HTTP or a TCP port:
40
+ depending on how the target is written, HTTP, a TCP port, or SSH:
41
41
 
42
42
  | Target | Checks |
43
43
  | --- | --- |
@@ -45,14 +45,15 @@ depending on how the target is written, HTTP or a TCP port:
45
45
  | `example.com:1337` | ping + TCP connect to port 1337 |
46
46
  | `http://example.com` / `https://example.com` | ping + HTTP (catching a 500, a DNS failure, an expired TLS certificate, a redirect loop, etc.) |
47
47
  | `https://example.com:1337` | ping + HTTP, against that port |
48
+ | `ssh://example.com` / `ssh://example.com:1337` | ping + SSH (connects and verifies the server's SSH identification banner; defaults to port 22) |
48
49
 
49
- Ping is checked independently from the HTTP/TCP probe: many hosts (behind
50
+ Ping is checked independently from the HTTP/TCP/SSH probe: many hosts (behind
50
51
  load balancers, CDNs, or firewalls) drop ICMP but serve their actual service
51
52
  just fine, so a blocked ping alone doesn't mark a target down: the "Status"
52
- column is based on the probe (HTTP or TCP), with ping shown alongside as
53
- extra diagnostic info. A target only shows as `OFFLINE` when both ping and
54
- the probe fail; a ping-only target's status is based on ping alone, since
55
- there's nothing else to check.
53
+ column is based on the probe, with ping shown alongside as extra diagnostic
54
+ info. A target only shows as `OFFLINE` when both ping and the probe fail; a
55
+ ping-only target's status is based on ping alone, since there's nothing else
56
+ to check.
56
57
 
57
58
  If the same host appears more than once (e.g. `example.com` and
58
59
  `https://example.com/health`), it's only pinged once per round. The ping
@@ -64,8 +65,9 @@ updates in place (via [`rich`](https://github.com/Textualize/rich)) rather
64
65
  than reprinting on every round.
65
66
 
66
67
  Each target also keeps a rolling window of its last 20 response times for
67
- the session (HTTP response time or TCP connect time, whichever applies),
68
- shown as Min/Max latency columns and a tiny sparkline ("Trend"), along with
68
+ the session (HTTP response time, TCP connect time, or SSH connect+banner
69
+ time, whichever applies), shown as Min/Max latency columns and a tiny
70
+ sparkline ("Trend"), along with
69
71
  the last time it was seen online and the last time it was seen offline.
70
72
  This history is in-memory only and resets each time you start the tool.
71
73
 
@@ -128,6 +130,11 @@ what's in the file).
128
130
  | `--ping-timeout SECONDS` | ping reply timeout (default: 2) |
129
131
  | `--http-timeout SECONDS` | timeout for the HTTP request or TCP connect (default: 5) |
130
132
  | `--no-ping` | skip ICMP entirely, HTTP/TCP-only checks |
133
+ | `--columns LIST` | comma-separated list of table columns to show, in order (interactive UI only; `--plain`/`--json` always include every field) — see below |
134
+ | `--basic-auth USER:PASS` | HTTP Basic Auth for CLI-supplied HTTP targets that don't set their own `basic_auth:` in the config file — see below |
135
+ | `--basic-auth-env USER_VAR:PASS_VAR` | same as `--basic-auth`, but reads the username/password from the named environment variables |
136
+ | `--tag TAG` | tag applied to CLI-supplied targets (repeatable) — see below |
137
+ | `--filter-tag TAG` | only monitor targets carrying at least one of the given tags (repeatable) — see below |
131
138
  | `--once` | run a single round and exit; exit code is `1` if any target is down, `0` otherwise. Useful as a cron/CI health gate |
132
139
  | `--plain` | print one plain-text line per target per round instead of the live UI (for logging/piping) |
133
140
  | `--json` | print one JSON object per target per round instead of the live UI (for machine consumption) |
@@ -147,9 +154,124 @@ for it in the config file, or pass `--no-ping` to disable ICMP checks
147
154
  globally. Note that a ping-only target (bare hostname, no port) can't have
148
155
  `ping: false`, there would be nothing left to check.
149
156
 
157
+ ### SSH availability check
158
+
159
+ `ssh://example.com` (or `ssh://example.com:1234` for a non-standard port,
160
+ default 22) connects over TCP and reads the server's SSH identification
161
+ banner (the string like `SSH-2.0-OpenSSH_9.6` that a compliant server sends
162
+ before any client input, per RFC 4253) rather than just checking whether
163
+ the port is open. If the port is open but the banner doesn't start with
164
+ `SSH-`, the target is reported down with `error_kind: protocol` — this
165
+ catches something else listening on the port. No SSH handshake, key
166
+ exchange, or authentication is attempted, so no credentials are needed or
167
+ used.
168
+
169
+ ### Customizing table columns
170
+
171
+ The interactive table shows all of `target`, `status`, `ping`, `service`,
172
+ `latency`, `min`, `max`, `trend`, `last_online`, `last_offline`, `detail`,
173
+ `checked` by default, in that order. A few more are available but not
174
+ shown by default — `tags` (see above) and the HTTP header columns
175
+ `content_length`, `last_modified`, `etag`, `content_type`, `server`,
176
+ `header_change` (see below). To show a different subset (or reorder
177
+ them), pass `--columns` with a comma-separated list:
178
+
179
+ ```bash
180
+ isitup --columns target,status,detail --url https://example.com
181
+ ```
182
+
183
+ Or set it once in the config file:
184
+
185
+ ```yaml
186
+ columns: [target, status, service, latency, detail]
187
+ ```
188
+
189
+ `--columns` takes precedence over a config file's `columns:` list, which
190
+ takes precedence over the default. This only affects the interactive UI —
191
+ `--plain` and `--json` always include every field, since they're meant for
192
+ machine consumption.
193
+
194
+ ### HTTP Basic Auth
195
+
196
+ Set `basic_auth` on a config file target to send an `Authorization` header
197
+ with its HTTP requests. Each of `username`/`password` can be given either
198
+ literally or via a `*_env` reference to an environment variable (but not
199
+ both):
200
+
201
+ ```yaml
202
+ targets:
203
+ - name: Protected API
204
+ url: https://api.internal.example.com
205
+ basic_auth:
206
+ username: admin
207
+ password_env: API_PASSWORD # read from $API_PASSWORD at startup
208
+ ```
209
+
210
+ `basic_auth` only applies to `http://`/`https://` targets — it's rejected
211
+ on a bare hostname or `hostname:port` target.
212
+
213
+ For targets passed directly on the command line (positional or `--url`),
214
+ use `--basic-auth USER:PASS` or `--basic-auth-env USER_VAR:PASS_VAR`
215
+ instead. Either flag applies to every CLI-supplied HTTP target in that
216
+ invocation, but never to config-file targets — those always use their own
217
+ `basic_auth:` (or none), so the two never mix unexpectedly:
218
+
219
+ ```bash
220
+ isitup --basic-auth-env API_USER:API_PASSWORD --url https://api.internal.example.com
221
+ ```
222
+
223
+ ### HTTP header/metadata tracking
224
+
225
+ For HTTP(S) targets, a handful of response headers are captured every
226
+ round — `Content-Length` (response size), `Last-Modified`, `ETag`,
227
+ `Content-Type`, and `Server` — without ever touching the response body
228
+ itself. `--plain`/`--json` always include them (nested under `http` in
229
+ JSON, as `content_length=`/`last_modified=`/etc. fields in `--plain`),
230
+ alongside a `last_header_change`/`header_change` timestamp: the last time
231
+ any of those five values actually differed from the previous round (not
232
+ set on the very first check, since there's nothing yet to compare against).
233
+ This is handy for noticing "something on this page changed" without
234
+ diffing the body yourself.
235
+
236
+ In the interactive table these show up as the `content_length`,
237
+ `last_modified`, `etag`, `content_type`, `server`, and `header_change`
238
+ columns — available via `--columns`/config `columns:`, but not part of the
239
+ default set (see above), so the table's default look is unaffected.
240
+
241
+ ### Tagging and grouping
242
+
243
+ Any target (ping-only, TCP, or HTTP) can carry `tags` in the config file:
244
+
245
+ ```yaml
246
+ targets:
247
+ - name: Prod Website
248
+ url: https://example.com
249
+ tags: [prod, web]
250
+ - name: Staging Website
251
+ url: https://staging.example.com
252
+ tags: [staging, web]
253
+ ```
254
+
255
+ Tags show up as their own `Tags` table column — add it with `--columns
256
+ target,tags,status,...` (or a config `columns:` list), since it isn't in
257
+ the default set — and always as a `tags` field in `--plain`/`--json`
258
+ output.
259
+
260
+ To only monitor a subset, pass `--filter-tag` (repeatable — a target
261
+ matches if it has *any* of the given tags):
262
+
263
+ ```bash
264
+ isitup --config config.yaml --filter-tag prod
265
+ ```
266
+
267
+ With no `--filter-tag` given, every target is monitored, same as today.
268
+ For targets passed directly on the command line, `--tag TAG` (repeatable)
269
+ tags every CLI-supplied target in that invocation — like `--basic-auth`,
270
+ it never touches config-file targets, which set their own `tags:` instead.
271
+
150
272
  ### Failure classification
151
273
 
152
- When the HTTP or TCP probe fails, the specific reason is captured as
274
+ When the HTTP, TCP, or SSH probe fails, the specific reason is captured as
153
275
  `error_kind` (visible in `--json` output, and shown as a short tag like
154
276
  `DOWN (DNS)` in the Service column otherwise):
155
277
 
@@ -160,6 +282,7 @@ When the HTTP or TCP probe fails, the specific reason is captured as
160
282
  | `redirect` | too many redirects (HTTP only, possible redirect loop) |
161
283
  | `timeout` | didn't complete within `--http-timeout` |
162
284
  | `connection` | connection refused or otherwise unreachable |
285
+ | `protocol` | connected, but the server's response wasn't a valid SSH banner (SSH only) |
163
286
  | `client_error` / `server_error` | got a 4xx / 5xx HTTP response |
164
287
  | `request_error` | anything else `requests` raised |
165
288
 
@@ -16,7 +16,7 @@
16
16
  ---
17
17
 
18
18
  **isitup** is a small CLI that periodically checks whether servers respond to ping and,
19
- depending on how the target is written, HTTP or a TCP port:
19
+ depending on how the target is written, HTTP, a TCP port, or SSH:
20
20
 
21
21
  | Target | Checks |
22
22
  | --- | --- |
@@ -24,14 +24,15 @@ depending on how the target is written, HTTP or a TCP port:
24
24
  | `example.com:1337` | ping + TCP connect to port 1337 |
25
25
  | `http://example.com` / `https://example.com` | ping + HTTP (catching a 500, a DNS failure, an expired TLS certificate, a redirect loop, etc.) |
26
26
  | `https://example.com:1337` | ping + HTTP, against that port |
27
+ | `ssh://example.com` / `ssh://example.com:1337` | ping + SSH (connects and verifies the server's SSH identification banner; defaults to port 22) |
27
28
 
28
- Ping is checked independently from the HTTP/TCP probe: many hosts (behind
29
+ Ping is checked independently from the HTTP/TCP/SSH probe: many hosts (behind
29
30
  load balancers, CDNs, or firewalls) drop ICMP but serve their actual service
30
31
  just fine, so a blocked ping alone doesn't mark a target down: the "Status"
31
- column is based on the probe (HTTP or TCP), with ping shown alongside as
32
- extra diagnostic info. A target only shows as `OFFLINE` when both ping and
33
- the probe fail; a ping-only target's status is based on ping alone, since
34
- there's nothing else to check.
32
+ column is based on the probe, with ping shown alongside as extra diagnostic
33
+ info. A target only shows as `OFFLINE` when both ping and the probe fail; a
34
+ ping-only target's status is based on ping alone, since there's nothing else
35
+ to check.
35
36
 
36
37
  If the same host appears more than once (e.g. `example.com` and
37
38
  `https://example.com/health`), it's only pinged once per round. The ping
@@ -43,8 +44,9 @@ updates in place (via [`rich`](https://github.com/Textualize/rich)) rather
43
44
  than reprinting on every round.
44
45
 
45
46
  Each target also keeps a rolling window of its last 20 response times for
46
- the session (HTTP response time or TCP connect time, whichever applies),
47
- shown as Min/Max latency columns and a tiny sparkline ("Trend"), along with
47
+ the session (HTTP response time, TCP connect time, or SSH connect+banner
48
+ time, whichever applies), shown as Min/Max latency columns and a tiny
49
+ sparkline ("Trend"), along with
48
50
  the last time it was seen online and the last time it was seen offline.
49
51
  This history is in-memory only and resets each time you start the tool.
50
52
 
@@ -107,6 +109,11 @@ what's in the file).
107
109
  | `--ping-timeout SECONDS` | ping reply timeout (default: 2) |
108
110
  | `--http-timeout SECONDS` | timeout for the HTTP request or TCP connect (default: 5) |
109
111
  | `--no-ping` | skip ICMP entirely, HTTP/TCP-only checks |
112
+ | `--columns LIST` | comma-separated list of table columns to show, in order (interactive UI only; `--plain`/`--json` always include every field) — see below |
113
+ | `--basic-auth USER:PASS` | HTTP Basic Auth for CLI-supplied HTTP targets that don't set their own `basic_auth:` in the config file — see below |
114
+ | `--basic-auth-env USER_VAR:PASS_VAR` | same as `--basic-auth`, but reads the username/password from the named environment variables |
115
+ | `--tag TAG` | tag applied to CLI-supplied targets (repeatable) — see below |
116
+ | `--filter-tag TAG` | only monitor targets carrying at least one of the given tags (repeatable) — see below |
110
117
  | `--once` | run a single round and exit; exit code is `1` if any target is down, `0` otherwise. Useful as a cron/CI health gate |
111
118
  | `--plain` | print one plain-text line per target per round instead of the live UI (for logging/piping) |
112
119
  | `--json` | print one JSON object per target per round instead of the live UI (for machine consumption) |
@@ -126,9 +133,124 @@ for it in the config file, or pass `--no-ping` to disable ICMP checks
126
133
  globally. Note that a ping-only target (bare hostname, no port) can't have
127
134
  `ping: false`, there would be nothing left to check.
128
135
 
136
+ ### SSH availability check
137
+
138
+ `ssh://example.com` (or `ssh://example.com:1234` for a non-standard port,
139
+ default 22) connects over TCP and reads the server's SSH identification
140
+ banner (the string like `SSH-2.0-OpenSSH_9.6` that a compliant server sends
141
+ before any client input, per RFC 4253) rather than just checking whether
142
+ the port is open. If the port is open but the banner doesn't start with
143
+ `SSH-`, the target is reported down with `error_kind: protocol` — this
144
+ catches something else listening on the port. No SSH handshake, key
145
+ exchange, or authentication is attempted, so no credentials are needed or
146
+ used.
147
+
148
+ ### Customizing table columns
149
+
150
+ The interactive table shows all of `target`, `status`, `ping`, `service`,
151
+ `latency`, `min`, `max`, `trend`, `last_online`, `last_offline`, `detail`,
152
+ `checked` by default, in that order. A few more are available but not
153
+ shown by default — `tags` (see above) and the HTTP header columns
154
+ `content_length`, `last_modified`, `etag`, `content_type`, `server`,
155
+ `header_change` (see below). To show a different subset (or reorder
156
+ them), pass `--columns` with a comma-separated list:
157
+
158
+ ```bash
159
+ isitup --columns target,status,detail --url https://example.com
160
+ ```
161
+
162
+ Or set it once in the config file:
163
+
164
+ ```yaml
165
+ columns: [target, status, service, latency, detail]
166
+ ```
167
+
168
+ `--columns` takes precedence over a config file's `columns:` list, which
169
+ takes precedence over the default. This only affects the interactive UI —
170
+ `--plain` and `--json` always include every field, since they're meant for
171
+ machine consumption.
172
+
173
+ ### HTTP Basic Auth
174
+
175
+ Set `basic_auth` on a config file target to send an `Authorization` header
176
+ with its HTTP requests. Each of `username`/`password` can be given either
177
+ literally or via a `*_env` reference to an environment variable (but not
178
+ both):
179
+
180
+ ```yaml
181
+ targets:
182
+ - name: Protected API
183
+ url: https://api.internal.example.com
184
+ basic_auth:
185
+ username: admin
186
+ password_env: API_PASSWORD # read from $API_PASSWORD at startup
187
+ ```
188
+
189
+ `basic_auth` only applies to `http://`/`https://` targets — it's rejected
190
+ on a bare hostname or `hostname:port` target.
191
+
192
+ For targets passed directly on the command line (positional or `--url`),
193
+ use `--basic-auth USER:PASS` or `--basic-auth-env USER_VAR:PASS_VAR`
194
+ instead. Either flag applies to every CLI-supplied HTTP target in that
195
+ invocation, but never to config-file targets — those always use their own
196
+ `basic_auth:` (or none), so the two never mix unexpectedly:
197
+
198
+ ```bash
199
+ isitup --basic-auth-env API_USER:API_PASSWORD --url https://api.internal.example.com
200
+ ```
201
+
202
+ ### HTTP header/metadata tracking
203
+
204
+ For HTTP(S) targets, a handful of response headers are captured every
205
+ round — `Content-Length` (response size), `Last-Modified`, `ETag`,
206
+ `Content-Type`, and `Server` — without ever touching the response body
207
+ itself. `--plain`/`--json` always include them (nested under `http` in
208
+ JSON, as `content_length=`/`last_modified=`/etc. fields in `--plain`),
209
+ alongside a `last_header_change`/`header_change` timestamp: the last time
210
+ any of those five values actually differed from the previous round (not
211
+ set on the very first check, since there's nothing yet to compare against).
212
+ This is handy for noticing "something on this page changed" without
213
+ diffing the body yourself.
214
+
215
+ In the interactive table these show up as the `content_length`,
216
+ `last_modified`, `etag`, `content_type`, `server`, and `header_change`
217
+ columns — available via `--columns`/config `columns:`, but not part of the
218
+ default set (see above), so the table's default look is unaffected.
219
+
220
+ ### Tagging and grouping
221
+
222
+ Any target (ping-only, TCP, or HTTP) can carry `tags` in the config file:
223
+
224
+ ```yaml
225
+ targets:
226
+ - name: Prod Website
227
+ url: https://example.com
228
+ tags: [prod, web]
229
+ - name: Staging Website
230
+ url: https://staging.example.com
231
+ tags: [staging, web]
232
+ ```
233
+
234
+ Tags show up as their own `Tags` table column — add it with `--columns
235
+ target,tags,status,...` (or a config `columns:` list), since it isn't in
236
+ the default set — and always as a `tags` field in `--plain`/`--json`
237
+ output.
238
+
239
+ To only monitor a subset, pass `--filter-tag` (repeatable — a target
240
+ matches if it has *any* of the given tags):
241
+
242
+ ```bash
243
+ isitup --config config.yaml --filter-tag prod
244
+ ```
245
+
246
+ With no `--filter-tag` given, every target is monitored, same as today.
247
+ For targets passed directly on the command line, `--tag TAG` (repeatable)
248
+ tags every CLI-supplied target in that invocation — like `--basic-auth`,
249
+ it never touches config-file targets, which set their own `tags:` instead.
250
+
129
251
  ### Failure classification
130
252
 
131
- When the HTTP or TCP probe fails, the specific reason is captured as
253
+ When the HTTP, TCP, or SSH probe fails, the specific reason is captured as
132
254
  `error_kind` (visible in `--json` output, and shown as a short tag like
133
255
  `DOWN (DNS)` in the Service column otherwise):
134
256
 
@@ -139,6 +261,7 @@ When the HTTP or TCP probe fails, the specific reason is captured as
139
261
  | `redirect` | too many redirects (HTTP only, possible redirect loop) |
140
262
  | `timeout` | didn't complete within `--http-timeout` |
141
263
  | `connection` | connection refused or otherwise unreachable |
264
+ | `protocol` | connected, but the server's response wasn't a valid SSH banner (SSH only) |
142
265
  | `client_error` / `server_error` | got a 4xx / 5xx HTTP response |
143
266
  | `request_error` | anything else `requests` raised |
144
267
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "isitup-cli"
3
- version = "0.1.4"
3
+ version = "0.2.0"
4
4
  description = "A small CLI that periodically checks whether servers respond to ping, HTTP, and TCP ports"
5
5
  readme = "README.md"
6
6
  license = "GPL-3.0-only"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "isitup-cli"
3
- version = "0.1.4"
3
+ version = "0.2.0"
4
4
  description = "A small CLI that periodically checks whether servers respond to ping, HTTP, and TCP ports"
5
5
  readme = "README.md"
6
6
  license = "GPL-3.0-only"
@@ -26,6 +26,19 @@ class Status(StrEnum):
26
26
  UNKNOWN = "unknown"
27
27
 
28
28
 
29
+ # Response headers tracked for HTTP targets (metadata only — the body itself
30
+ # is never hashed/diffed). Maps our field name to the actual header name.
31
+ HTTP_HEADER_NAMES = {
32
+ "content_length": "Content-Length",
33
+ "last_modified": "Last-Modified",
34
+ "etag": "ETag",
35
+ "content_type": "Content-Type",
36
+ "server": "Server",
37
+ }
38
+ HTTP_HEADER_FIELDS = tuple(HTTP_HEADER_NAMES)
39
+ HttpHeaders = dict[str, str | None]
40
+
41
+
29
42
  @dataclass
30
43
  class CheckResult:
31
44
  target: Target
@@ -39,17 +52,32 @@ class CheckResult:
39
52
  error_kind: ErrorKind
40
53
  detail: str
41
54
  checked_at: float
55
+ ssh_status: Status | None = None # set only when target.kind is SSH
56
+ ssh_latency_ms: float | None = None
57
+ ssh_banner: str | None = None
58
+ http_headers: HttpHeaders | None = None # set only on a successful HTTP response
42
59
 
43
60
  @property
44
61
  def latency_ms(self) -> float | None:
45
62
  """Whichever probe latency applies to this target's kind (HTTP
46
- response time, TCP connect time, or None for a ping-only target)."""
63
+ response time, TCP connect time, SSH connect+banner time, or None
64
+ for a ping-only target)."""
47
65
  if self.target.kind == TargetKind.HTTP:
48
66
  return self.http_latency_ms
49
67
  if self.target.kind == TargetKind.TCP_PORT:
50
68
  return self.tcp_latency_ms
69
+ if self.target.kind == TargetKind.SSH:
70
+ return self.ssh_latency_ms
51
71
  return None
52
72
 
73
+ @property
74
+ def effective_latency_ms(self) -> float | None:
75
+ """`latency_ms`, falling back to ping latency when the probe didn't
76
+ produce one — e.g. it timed out/failed, or there's no probe at all
77
+ (ping-only target) — so a struggling or unreachable target still
78
+ shows a latency signal instead of a blank dash."""
79
+ return self.latency_ms if self.latency_ms is not None else self.ping_latency_ms
80
+
53
81
 
54
82
  def ping_once(host: str, timeout_s: float = 2.0) -> tuple[bool, float | None]:
55
83
  """Send a single ICMP echo request using the OS ping binary. Returns (reachable, latency_ms)."""
@@ -112,29 +140,36 @@ def _caused_by(exc: BaseException, cls: type[BaseException]) -> bool:
112
140
  return False
113
141
 
114
142
 
115
- def http_check(url: str, timeout_s: float = 5.0) -> tuple[Status, int | None, float | None, ErrorKind, str]:
143
+ def _extract_headers(response: requests.Response) -> HttpHeaders:
144
+ return {field: response.headers.get(name) for field, name in HTTP_HEADER_NAMES.items()}
145
+
146
+
147
+ def http_check(
148
+ url: str, timeout_s: float = 5.0, auth: tuple[str, str] | None = None
149
+ ) -> tuple[Status, int | None, float | None, ErrorKind, str, HttpHeaders | None]:
116
150
  try:
117
- response = requests.get(url, timeout=timeout_s, allow_redirects=True)
151
+ response = requests.get(url, timeout=timeout_s, allow_redirects=True, auth=auth)
118
152
  except requests.exceptions.SSLError as exc:
119
- return Status.DOWN, None, None, "tls", f"TLS/certificate error: {exc}"
153
+ return Status.DOWN, None, None, "tls", f"TLS/certificate error: {exc}", None
120
154
  except requests.exceptions.TooManyRedirects:
121
- return Status.DOWN, None, None, "redirect", "too many redirects (possible redirect loop)"
155
+ return Status.DOWN, None, None, "redirect", "too many redirects (possible redirect loop)", None
122
156
  except requests.exceptions.Timeout:
123
- return Status.DOWN, None, None, "timeout", "request timed out"
157
+ return Status.DOWN, None, None, "timeout", "request timed out", None
124
158
  except requests.exceptions.ConnectionError as exc:
125
159
  if _caused_by(exc, socket.gaierror):
126
- return Status.DOWN, None, None, "dns", "DNS resolution failed"
127
- return Status.DOWN, None, None, "connection", "connection failed"
160
+ return Status.DOWN, None, None, "dns", "DNS resolution failed", None
161
+ return Status.DOWN, None, None, "connection", "connection failed", None
128
162
  except requests.exceptions.RequestException as exc:
129
- return Status.DOWN, None, None, "request_error", str(exc)
163
+ return Status.DOWN, None, None, "request_error", str(exc), None
130
164
 
131
165
  latency_ms = response.elapsed.total_seconds() * 1000
132
166
  code = response.status_code
167
+ headers = _extract_headers(response)
133
168
  if code >= 500:
134
- return Status.DOWN, code, latency_ms, "server_error", f"HTTP {code} server error"
169
+ return Status.DOWN, code, latency_ms, "server_error", f"HTTP {code} server error", headers
135
170
  if code >= 400:
136
- return Status.WARN, code, latency_ms, "client_error", f"HTTP {code} client error"
137
- return Status.OK, code, latency_ms, None, f"HTTP {code}"
171
+ return Status.WARN, code, latency_ms, "client_error", f"HTTP {code} client error", headers
172
+ return Status.OK, code, latency_ms, None, f"HTTP {code}", headers
138
173
 
139
174
 
140
175
  def tcp_check(
@@ -155,6 +190,39 @@ def tcp_check(
155
190
  return Status.OK, latency_ms, None, f"port {port} open"
156
191
 
157
192
 
193
+ def ssh_check(
194
+ hostname: str, port: int, timeout_s: float = 5.0
195
+ ) -> tuple[Status, float | None, ErrorKind, str, str | None]:
196
+ """Connect and read the SSH identification string — a compliant server
197
+ sends this itself before any client input (RFC 4253 §4.2), so checking
198
+ it confirms an actual SSH service without needing a full handshake."""
199
+ start = time.monotonic()
200
+ try:
201
+ with socket.create_connection((hostname, port), timeout=timeout_s) as sock:
202
+ sock.settimeout(timeout_s)
203
+ raw_banner = sock.recv(256)
204
+ except TimeoutError:
205
+ return Status.DOWN, None, "timeout", f"connection to port {port} timed out", None
206
+ except socket.gaierror:
207
+ return Status.DOWN, None, "dns", "DNS resolution failed", None
208
+ except OSError as exc:
209
+ return (
210
+ Status.DOWN,
211
+ None,
212
+ "connection",
213
+ f"connection to port {port} failed: {exc.strerror or exc}",
214
+ None,
215
+ )
216
+
217
+ latency_ms = (time.monotonic() - start) * 1000
218
+ banner = raw_banner.decode("utf-8", errors="replace").strip("\r\n")
219
+
220
+ if not banner.startswith("SSH-"):
221
+ return Status.DOWN, latency_ms, "protocol", f"port {port} open, but no SSH banner", banner or None
222
+
223
+ return Status.OK, latency_ms, None, f"SSH banner: {banner}", banner
224
+
225
+
158
226
  def probe_target(
159
227
  target: Target,
160
228
  ping_status: Status,
@@ -167,24 +235,35 @@ def probe_target(
167
235
  already been done — see `run_pings` for why it's handled separately."""
168
236
  checked_at = time.time()
169
237
 
170
- http_status = http_code = http_latency_ms = None
238
+ http_status = http_code = http_latency_ms = http_headers = None
171
239
  tcp_status = tcp_latency_ms = None
240
+ ssh_status = ssh_latency_ms = ssh_banner = None
172
241
  error_kind: ErrorKind = None
173
242
 
174
243
  if target.kind == TargetKind.HTTP:
175
244
  assert target.url is not None
176
245
  if on_phase:
177
246
  on_phase("http")
178
- http_status, http_code, http_latency_ms, error_kind, detail = http_check(target.url, timeout_s)
247
+ http_status, http_code, http_latency_ms, error_kind, detail, http_headers = http_check(
248
+ target.url, timeout_s, auth=target.basic_auth
249
+ )
179
250
  elif target.kind == TargetKind.TCP_PORT:
180
251
  assert target.port is not None
181
252
  if on_phase:
182
253
  on_phase("tcp")
183
254
  tcp_status, tcp_latency_ms, error_kind, detail = tcp_check(target.hostname, target.port, timeout_s)
255
+ elif target.kind == TargetKind.SSH:
256
+ assert target.port is not None
257
+ if on_phase:
258
+ on_phase("ssh")
259
+ ssh_status, ssh_latency_ms, error_kind, detail, ssh_banner = ssh_check(
260
+ target.hostname, target.port, timeout_s
261
+ )
184
262
  else:
185
263
  detail = "ping-only target (no scheme or port given)"
186
264
 
187
- if ping_status == Status.DOWN and (http_status == Status.DOWN or tcp_status == Status.DOWN):
265
+ probe_down = Status.DOWN in (http_status, tcp_status, ssh_status)
266
+ if ping_status == Status.DOWN and probe_down:
188
267
  detail = "host did not respond to ping, and " + detail
189
268
 
190
269
  return CheckResult(
@@ -199,4 +278,8 @@ def probe_target(
199
278
  error_kind=error_kind,
200
279
  detail=detail,
201
280
  checked_at=checked_at,
281
+ ssh_status=ssh_status,
282
+ ssh_latency_ms=ssh_latency_ms,
283
+ ssh_banner=ssh_banner,
284
+ http_headers=http_headers,
202
285
  )