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.
- {isitup_cli-0.1.4 → isitup_cli-0.2.0}/PKG-INFO +133 -10
- {isitup_cli-0.1.4 → isitup_cli-0.2.0}/README.md +132 -9
- {isitup_cli-0.1.4 → isitup_cli-0.2.0}/pyproject.toml +1 -1
- {isitup_cli-0.1.4 → isitup_cli-0.2.0}/pyproject.toml.orig +1 -1
- {isitup_cli-0.1.4 → isitup_cli-0.2.0}/src/isitup/checks.py +98 -15
- {isitup_cli-0.1.4 → isitup_cli-0.2.0}/src/isitup/cli.py +119 -9
- isitup_cli-0.2.0/src/isitup/config.py +364 -0
- isitup_cli-0.2.0/src/isitup/history.py +112 -0
- {isitup_cli-0.1.4 → isitup_cli-0.2.0}/src/isitup/output.py +50 -8
- {isitup_cli-0.1.4 → isitup_cli-0.2.0}/src/isitup/render.py +116 -44
- isitup_cli-0.1.4/src/isitup/config.py +0 -160
- isitup_cli-0.1.4/src/isitup/history.py +0 -62
- {isitup_cli-0.1.4 → isitup_cli-0.2.0}/LICENSE +0 -0
- {isitup_cli-0.1.4 → isitup_cli-0.2.0}/src/isitup/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: isitup-cli
|
|
3
|
-
Version: 0.
|
|
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
|
|
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
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
68
|
-
shown as Min/Max latency columns and a tiny
|
|
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
|
|
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
|
|
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
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
47
|
-
shown as Min/Max latency columns and a tiny
|
|
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
|
|
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
|
|
|
@@ -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,
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
)
|