-
-
Notifications
You must be signed in to change notification settings - Fork 7
Expand file tree
/
Copy pathaction.yml
More file actions
240 lines (228 loc) · 11.5 KB
/
Copy pathaction.yml
File metadata and controls
240 lines (228 loc) · 11.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
name: 'ZeroSMTP Check'
description: 'Check outbound SMTP from a runner: ports, TLS, AUTH, and fail the job when the mail certificate is near expiry.'
author: 'msgwing'
# Marketplace categories, set 2026-08-23: Monitoring (primary), Utilities.
#
# Recorded here because they are not part of this file - GitHub keeps them on
# the release, so nothing in the repository would otherwise say what was
# chosen or why, and the next release would be a guess.
#
# Monitoring rather than Testing, deliberately. What makes this worth putting
# in a pipeline is not that it checks ports: it is that it fails the build when
# the mail server's certificate is inside the window you set. Nobody watches a
# mail certificate, it expires on a Sunday, and the first report is somebody
# saying scanning stopped working. Testing would have attracted people looking
# for a unit-test runner and disappointed them.
#
# To change them: repository -> Releases -> edit the release -> Release Action.
branding:
icon: 'mail'
color: 'purple'
inputs:
scan:
description: 'Path to scan for Microsoft 365 SMTP endpoints that stop accepting a password. Empty disables the scan.'
required: false
default: ''
fail-on-findings:
description: 'Fail the job when the scan finds something. Warns only by default.'
required: false
default: 'false'
host:
description: 'SMTP host to check.'
required: false
default: 'mx.msgwing.com'
ports:
description: 'Comma-separated ports. 25 is checked for reachability; whether it can be sent by depends on the server and the output says which.'
required: false
default: '25,587,465'
timeout:
description: 'Per-step timeout in milliseconds.'
required: false
default: '10000'
cert-expiry-days:
description: 'Fail if any certificate expires within this many days. 0 disables the check. A mail certificate nobody is watching expires on a Sunday.'
required: false
default: '0'
explain:
description: 'An SMTP error string to decode instead of connecting. Paste what a log, a mail library or a device panel printed - a Postfix SASL line, a Python traceback, a code off a printer panel. Set this and no connection is made at all.'
required: false
default: ''
fail-on-error:
description: 'Fail the job when a port is unreachable or a certificate does not verify. Set to false to report without failing, which is what you want on a scheduled canary that should not page anybody.'
required: false
default: 'true'
outputs:
ok:
description: 'true when every checked port was reachable with a valid certificate.'
value: ${{ steps.check.outputs.ok }}
result:
description: 'The full result as JSON.'
value: ${{ steps.check.outputs.result }}
soonest-expiry-days:
description: 'Days until the earliest certificate expiry across the checked ports, or empty if no certificate was read.'
value: ${{ steps.check.outputs.soonest }}
runs:
using: 'composite'
steps:
# No install step and no npm at all. The tool has zero dependencies, so the
# file that ships with this action is the whole thing - which also means
# this action cannot break because a registry was slow or a version moved.
# Decode-only mode. Every other SMTP action in the Marketplace sends mail
# or catches it; none of them says what a refusal means. That corpus is
# the one thing this project has that nothing else does, and until now it
# existed only in the CLI - a workflow that captured an error from a
# sending step had nowhere to take it.
# Scan the caller's own repository, which is the question 25,728 public
# files have and no action in the Marketplace answers.
#
# Measured 2026-08-29 against the competitor with 1,467 stars to our 4:
# they are referenced in 73 files across other people's repositories, we
# are in 0. Not because nobody uses this - a config with our hostname also
# holds a password, so nobody commits it. Our footprint is invisible by
# construction, and copying "be referenced in code" was never available.
#
# A workflow file is the exception: public, credential-free, and committed.
# So the artefact that can travel is an action with a reason to be in
# somebody's pipeline, and "tell me which of my files stop working in
# December" is that reason.
#
# Warns rather than fails by default. Failing a stranger's build on the
# first run is how an action gets removed the same afternoon.
- id: scan
if: inputs.scan != ''
shell: bash
run: |
set +e
ROOT="${{ inputs.scan }}"
# Endpoints that stop accepting a password. Kept in step with
# data/blast-radius.json, which counts the same strings across public
# GitHub - so the number a caller sees here and the number on the site
# mean the same thing.
PATTERNS='smtp\.office365\.com|smtp-mail\.outlook\.com|outlook\.office365\.com'
hits=$(grep -rInE "$PATTERNS" "$ROOT" \
--exclude-dir=.git --exclude-dir=node_modules --exclude-dir=vendor \
2>/dev/null | head -200)
n=$(printf '%s' "$hits" | grep -c . )
echo "affected=$n" >> "$GITHUB_OUTPUT"
{
echo "### Microsoft 365 SMTP endpoints in this repository"
echo ""
if [ "$n" = "0" ]; then
echo "None found. Nothing here points at an endpoint that stops"
echo "accepting a username and password."
else
echo "**$n line(s)** point at a Microsoft 365 SMTP endpoint."
echo ""
echo "At the end of December 2026 Exchange Online stops accepting a"
echo "username and password there by default. An administrator can"
echo "switch it back on, so this is a deadline rather than a wall -"
echo "and Microsoft announces the final removal date in the second"
echo "half of 2027."
echo ""
echo '```'
printf '%s\n' "$hits" | head -50
echo '```'
echo ""
echo "[What breaks and when](https://docs.msgwing.com/AFFECTED-SYSTEMS.html)"
echo " | [The dated timeline](https://docs.msgwing.com/EXCHANGE-ONLINE-SMTP-AUTH.html)"
fi
} >> "$GITHUB_STEP_SUMMARY"
if [ "$n" != "0" ]; then
echo "::warning::$n line(s) use a Microsoft 365 SMTP endpoint that stops accepting a password at the end of December 2026"
if [ "${{ inputs.fail-on-findings }}" = "true" ]; then
echo "::error::failing because fail-on-findings is set"
exit 1
fi
fi
- id: explain
if: inputs.explain != ''
shell: bash
run: |
set +e
out=$(node "$GITHUB_ACTION_PATH/packages/zerosmtp-check/index.js" --explain "${{ inputs.explain }}")
rc=$?
set -e
echo "$out"
{
echo "### What that SMTP error means"
echo ""
echo '```'
echo "$out"
echo '```'
echo ""
echo "[Every SMTP AUTH error and what it means](https://docs.msgwing.com/ERROR-MESSAGES.html)"
} >> "$GITHUB_STEP_SUMMARY"
if [ "$rc" != "0" ]; then
echo "::warning::no record of that error string - " "https://github.com/msgwing/ZeroSMTP/issues/new?template=error-string.yml"
fi
- id: check
if: inputs.explain == '' && inputs.scan == ''
shell: bash
run: |
set +e
IFS=',' read -ra PORTS <<< "${{ inputs.ports }}"
# index.js takes a single --port, so one pass per port and the results
# are merged here rather than pretending the CLI does something it does not.
merged='{"ports":[]}'
for p in "${PORTS[@]}"; do
p=$(echo "$p" | tr -d '[:space:]')
out=$(node "$GITHUB_ACTION_PATH/packages/zerosmtp-check/index.js" \
"${{ inputs.host }}" --port "$p" --timeout "${{ inputs.timeout }}" --json)
rc=$?
if [ "$rc" = "2" ]; then
echo "::error::${{ inputs.host }} does not resolve - check the hostname before anything else. https://docs.msgwing.com/TROUBLESHOOTING.html"
echo "ok=false" >> "$GITHUB_OUTPUT"
[ "${{ inputs.fail-on-error }}" = "true" ] && exit 1
exit 0
fi
merged=$(printf '%s\n%s' "$merged" "$out" | jq -s '{host: (.[1].host), addresses: (.[1].addresses), ports: (.[0].ports + .[1].ports)}')
done
set -e
echo "$merged" | jq -r '.ports[] | "port \(.port): tcp=\(.tcp) tls=\(.tls) auth=\(.auth|join(" ")|if .=="" then "none" else . end)\(if .error then " error=\(.error)" else "" end)"'
ok=$(echo "$merged" | jq -r '[.ports[] | (.tcp and .tls and (.cert == null or .cert.authorized) and (.error == null))] | all')
echo "ok=$ok" >> "$GITHUB_OUTPUT"
{
echo "result<<ZEROSMTP_EOF"
echo "$merged"
echo "ZEROSMTP_EOF"
} >> "$GITHUB_OUTPUT"
# Certificate expiry. The CLI reports validTo per port; the soonest one
# is what matters, because that is the day mail stops.
soonest=$(echo "$merged" | jq -r '[.ports[].cert.validTo // empty] | .[]' | while read -r d; do
date -u -d "$d" +%s 2>/dev/null || true
done | sort -n | head -1)
days=""
if [ -n "$soonest" ]; then
days=$(( (soonest - $(date -u +%s)) / 86400 ))
echo "soonest=$days" >> "$GITHUB_OUTPUT"
echo "earliest certificate expiry: $days days"
else
echo "soonest=" >> "$GITHUB_OUTPUT"
fi
{
echo "### ZeroSMTP check - ${{ inputs.host }}"
echo ""
echo "| Port | TCP | TLS | AUTH |"
echo "|---|---|---|---|"
echo "$merged" | jq -r '.ports[] | "| \(.port) | \(if .tcp then "ok" else "FAIL" end) | \(if .tls then "ok" else "FAIL" end) | \(.auth|join(" ")|if .=="" then "none offered" else . end) |"'
echo ""
[ -n "$days" ] && echo "Earliest certificate expiry: **$days days**."
} >> "$GITHUB_STEP_SUMMARY"
limit="${{ inputs.cert-expiry-days }}"
if [ "$limit" != "0" ] && [ -n "$days" ] && [ "$days" -lt "$limit" ]; then
echo "::error::certificate on ${{ inputs.host }} expires in $days days, under the $limit day limit. Nobody is warned when a mail certificate lapses: https://docs.msgwing.com/MONITORING.html"
exit 1
fi
if [ "$ok" != "true" ] && [ "${{ inputs.fail-on-error }}" = "true" ]; then
# A failing port is one of three different problems and they have
# different pages. Saying which is the difference between an error a
# person can act on and one they have to go and research.
if echo "$merged" | jq -e '[.ports[]|select(.tcp==false)]|length>0' >/dev/null; then
echo "::error::a port could not be reached from this runner. Cloud " "providers block outbound SMTP by default: " "https://docs.msgwing.com/TROUBLESHOOTING.html"
elif echo "$merged" | jq -e '[.ports[]|select(.cert!=null and .cert.authorized==false)]|length>0' >/dev/null; then
echo "::error::the certificate did not verify. On a device with old " "firmware this is usually a frozen root store rather than a real " "problem: https://docs.msgwing.com/PRINTER-CERTIFICATE-ERROR.html"
else
echo "::error::at least one port failed - see the rows above. " "https://docs.msgwing.com/TROUBLESHOOTING.html"
fi
exit 1
fi