├── domains.txt ├── .github └── workflows │ └── run.yml ├── README.md ├── check.py └── thttp.py /domains.txt: -------------------------------------------------------------------------------- 1 | brntn.me 2 | www.brntn.me 3 | 4 | utils.brntn.me 5 | bookmarks.brntn.me 6 | hn500.brntn.me 7 | 8 | runrandomly.com 9 | www.runrandomly.com 10 | 11 | loginwith.space 12 | www.loginwith.space 13 | 14 | basehtml.xyz 15 | www.basehtml.xyz 16 | 17 | django-up.com 18 | www.django-up.com 19 | 20 | dockerwatch.net 21 | www.dockerwatch.net 22 | 23 | delta-v.club 24 | 25 | abc.net.au 26 | fastmail.com 27 | example.com 28 | news.ycombinator.com 29 | 30 | requestheaders.dev 31 | www.requestheaders.dev 32 | 33 | webdevctf.com 34 | www.webdevctf.com 35 | clues.webdevctf.com 36 | -------------------------------------------------------------------------------- /.github/workflows/run.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: CI 4 | 5 | # Controls when the action will run. 6 | on: 7 | # Triggers the workflow on push or pull request events but only for the main branch 8 | push: 9 | branches: [ main ] 10 | pull_request: 11 | branches: [ main ] 12 | schedule: 13 | - cron: 4 22 * * * 14 | 15 | # Allows you to run this workflow manually from the Actions tab 16 | workflow_dispatch: 17 | 18 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 19 | jobs: 20 | # This workflow contains a single job called "build" 21 | build: 22 | # The type of runner that the job will run on 23 | runs-on: ubuntu-latest 24 | 25 | # Steps represent a sequence of tasks that will be executed as part of the job 26 | steps: 27 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 28 | - uses: actions/checkout@v3 29 | 30 | # Runs a single command using the runners shell 31 | - name: Execute the checks 32 | run: python check.py 33 | env: 34 | GH_TOKEN: ${{ github.token }} 35 | GH_REPO: ${{ github.repository }} 36 | 37 | - name: Commit and push files 38 | run: | 39 | git pull 40 | git config --local user.email "empty@example.org" 41 | git config --local user.name "Cert Checker Bot" 42 | git add README.md 43 | git diff --exit-code --cached || exit_code=$? 44 | echo $exit_code 45 | if (( exit_code > 0 )); then 46 | git commit -m "Update README with expiry dates" && \ 47 | git push https://${GITHUB_ACTOR}:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git HEAD:main 48 | fi 49 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cert Checker 2 | 3 | This tiny project takes a list of domains (`domains.txt`) and checks the certificate expiry. Once that's done, it does two things: 4 | 5 | - Adds the list of domains with their SSL certificate expiry to the end of this README 6 | - Creates a new Github Issue if the SSL certificate expires in less than 30 days (if `GITHUB_TOKEN` is present) and again with 15 days remaining. 7 | 8 | 9 | ## Usage 10 | 11 | Use this template to create a version of the project in your Github account, then edit `domains.txt` to set up the domains you want to track. 12 | 13 | 14 | ### Optional Steps 15 | 16 | - Modify `.github/workflows/run.yml` to configure the frequency you run the bot 17 | - Modify `.github/workflows/run.yml` to update the email address for the bot 18 | 19 | ## Results 20 | 21 | | Expiry | Domain | 22 | |-----------|----------| 23 | | 2026-02-07 | news.ycombinator.com | 24 | | 2026-02-10 | basehtml.xyz | 25 | | 2026-02-10 | www.basehtml.xyz | 26 | | 2026-02-23 | runrandomly.com | 27 | | 2026-02-23 | www.runrandomly.com | 28 | | 2026-02-23 | requestheaders.dev | 29 | | 2026-02-23 | www.requestheaders.dev | 30 | | 2026-03-02 | fastmail.com | 31 | | 2026-03-07 | brntn.me | 32 | | 2026-03-07 | www.brntn.me | 33 | | 2026-03-11 | utils.brntn.me | 34 | | 2026-03-11 | hn500.brntn.me | 35 | | 2026-03-16 | example.com | 36 | | 2026-09-24 | abc.net.au | 37 | | LookupFailed | loginwith.space | 38 | | LookupFailed | www.loginwith.space | 39 | | LookupFailed | dockerwatch.net | 40 | | LookupFailed | www.dockerwatch.net | 41 | | LookupFailed | delta-v.club | 42 | | LookupFailed | webdevctf.com | 43 | | LookupFailed | www.webdevctf.com | 44 | | LookupFailed | clues.webdevctf.com | 45 | | UnknownSSLFailure | bookmarks.brntn.me | 46 | | UnknownSSLFailure | django-up.com | 47 | | UnknownSSLFailure | www.django-up.com | 48 | -------------------------------------------------------------------------------- /check.py: -------------------------------------------------------------------------------- 1 | import os 2 | import ssl 3 | 4 | import errno 5 | import socket 6 | import ssl 7 | 8 | from datetime import datetime, date, timedelta, timezone 9 | from thttp import request 10 | 11 | 12 | CONNECTION_TIMEOUT = 5.0 13 | 14 | 15 | class SSLConnectionFailed(Exception): 16 | pass 17 | 18 | 19 | class UnknownSSLFailure(Exception): 20 | pass 21 | 22 | 23 | class LookupFailed(Exception): 24 | pass 25 | 26 | 27 | def get_ssl_expiry(domain): 28 | try: 29 | sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 30 | sock.settimeout(CONNECTION_TIMEOUT) 31 | 32 | context = ssl.create_default_context() 33 | ssl_sock = context.wrap_socket(sock, server_hostname=domain) 34 | ssl_sock.settimeout(CONNECTION_TIMEOUT) 35 | ssl_sock.connect((domain, 443)) 36 | 37 | cert = ssl_sock.getpeercert() 38 | end = datetime.fromtimestamp( 39 | ssl.cert_time_to_seconds(cert["notAfter"]), tz=timezone.utc 40 | ) 41 | ssl_sock.close() 42 | return end.date() 43 | except socket.gaierror: 44 | raise LookupFailed 45 | except socket.error as e: 46 | if e.errno == errno.ECONNREFUSED: 47 | # connection to port 443 was confused 48 | raise SSLConnectionFailed 49 | raise UnknownSSLFailure 50 | 51 | 52 | if __name__ == "__main__": 53 | domains = [] 54 | 55 | with open("domains.txt") as f: 56 | for l in f.readlines(): 57 | if l.strip() and not l.strip().startswith("#"): 58 | domain = l.strip() 59 | 60 | try: 61 | domains.append((domain, str(get_ssl_expiry(domain)))) 62 | except Exception as e: 63 | domains.append((domain, type(e).__name__)) 64 | 65 | # Update README 66 | with open("README.md", "r") as f: 67 | readme = f.read().split("## Results")[0].strip() 68 | readme += "\n\n## Results\n\n" 69 | readme += "| Expiry | Domain |\n" 70 | readme += "|-----------|----------|\n" 71 | 72 | for domain, expiry in sorted(domains, key=lambda x: x[1]): 73 | readme += f"| {expiry} | {domain} |\n" 74 | 75 | with open("README.md", "w") as f: 76 | f.write(readme) 77 | 78 | # Open Github issues 79 | for domain, expiry in sorted(domains, key=lambda x: x[1]): 80 | if "-" not in expiry: 81 | continue 82 | 83 | d = date(*[int(x) for x in expiry.split("-")]) 84 | if d < date.today() + timedelta(days=30): 85 | token = os.environ.get("GH_TOKEN") 86 | repo = os.environ.get("GH_REPO") 87 | 88 | if token and repo: 89 | if d < date.today() + timedelta(days=14): 90 | title = f"{domain} expires in less than 14 days ({d})" 91 | elif d < date.today() + timedelta(days=30): 92 | title = f"{domain} expires in less than 30 days ({d})" 93 | 94 | existing_issues = request( 95 | f"https://api.github.com/repos/{repo}/issues", 96 | params={"state": "open"}, 97 | headers={"Authorization": f"token {token}"}, 98 | ) 99 | 100 | needs_issue = True 101 | for issue in existing_issues.json: 102 | if issue["title"] == title: 103 | needs_issue = False 104 | break 105 | 106 | if needs_issue: 107 | request( 108 | f"https://api.github.com/repos/{repo}/issues", 109 | headers={"Authorization": f"token {token}"}, 110 | json={"title": title}, 111 | method="post", 112 | ) 113 | -------------------------------------------------------------------------------- /thttp.py: -------------------------------------------------------------------------------- 1 | """ 2 | UNLICENSED 3 | This is free and unencumbered software released into the public domain. 4 | 5 | https://github.com/sesh/thttp 6 | """ 7 | 8 | import gzip 9 | import ssl 10 | import json as json_lib 11 | 12 | from base64 import b64encode 13 | from collections import namedtuple 14 | 15 | from http.cookiejar import CookieJar 16 | from urllib.error import HTTPError, URLError 17 | from urllib.parse import urlencode 18 | from urllib.request import ( 19 | Request, 20 | build_opener, 21 | HTTPRedirectHandler, 22 | HTTPSHandler, 23 | HTTPCookieProcessor, 24 | ) 25 | 26 | 27 | Response = namedtuple("Response", "request content json status url headers cookiejar") 28 | 29 | 30 | class NoRedirect(HTTPRedirectHandler): 31 | def redirect_request(self, req, fp, code, msg, headers, newurl): 32 | return None 33 | 34 | 35 | def request( 36 | url, 37 | params={}, 38 | json=None, 39 | data=None, 40 | headers={}, 41 | method="GET", 42 | verify=True, 43 | redirect=True, 44 | cookiejar=None, 45 | basic_auth=None, 46 | timeout=None, 47 | ): 48 | """ 49 | Returns a (named)tuple with the following properties: 50 | - request 51 | - content 52 | - json (dict; or None) 53 | - headers (dict; all lowercase keys) 54 | - https://stackoverflow.com/questions/5258977/are-http-headers-case-sensitive 55 | - status 56 | - url (final url, after any redirects) 57 | - cookiejar 58 | """ 59 | method = method.upper() 60 | headers = {k.lower(): v for k, v in headers.items()} # lowecase headers 61 | 62 | if params: 63 | url += "?" + urlencode(params) # build URL from params 64 | if json and data: 65 | raise Exception("Cannot provide both json and data parameters") 66 | if method not in ["POST", "PATCH", "PUT"] and (json or data): 67 | raise Exception( 68 | "Request method must POST, PATCH or PUT if json or data is provided" 69 | ) 70 | if not timeout: 71 | timeout = 60 72 | 73 | if json: # if we have json, stringify and put it in our data variable 74 | headers["content-type"] = "application/json" 75 | data = json_lib.dumps(json).encode("utf-8") 76 | elif data: 77 | data = urlencode(data).encode() 78 | 79 | if basic_auth and len(basic_auth) == 2 and "authorization" not in headers: 80 | username, password = basic_auth 81 | headers[ 82 | "authorization" 83 | ] = f'Basic {b64encode(f"{username}:{password}".encode()).decode("ascii")}' 84 | 85 | if not cookiejar: 86 | cookiejar = CookieJar() 87 | 88 | ctx = ssl.create_default_context() 89 | if not verify: # ignore ssl errors 90 | ctx.check_hostname = False 91 | ctx.verify_mode = ssl.CERT_NONE 92 | 93 | handlers = [] 94 | handlers.append(HTTPSHandler(context=ctx)) 95 | handlers.append(HTTPCookieProcessor(cookiejar=cookiejar)) 96 | 97 | if not redirect: 98 | no_redirect = NoRedirect() 99 | handlers.append(no_redirect) 100 | 101 | opener = build_opener(*handlers) 102 | req = Request(url, data=data, headers=headers, method=method) 103 | 104 | try: 105 | with opener.open(req, timeout=timeout) as resp: 106 | status, content, resp_url = (resp.getcode(), resp.read(), resp.geturl()) 107 | headers = {k.lower(): v for k, v in list(resp.info().items())} 108 | 109 | if "gzip" in headers.get("content-encoding", ""): 110 | content = gzip.decompress(content) 111 | 112 | json = ( 113 | json_lib.loads(content) 114 | if "application/json" in headers.get("content-type", "").lower() 115 | and content 116 | else None 117 | ) 118 | except HTTPError as e: 119 | status, content, resp_url = (e.code, e.read(), e.geturl()) 120 | headers = {k.lower(): v for k, v in list(e.headers.items())} 121 | 122 | if "gzip" in headers.get("content-encoding", ""): 123 | content = gzip.decompress(content) 124 | 125 | json = ( 126 | json_lib.loads(content) 127 | if "application/json" in headers.get("content-type", "").lower() and content 128 | else None 129 | ) 130 | 131 | return Response(req, content, json, status, resp_url, headers, cookiejar) 132 | 133 | 134 | import unittest 135 | 136 | 137 | class RequestTestCase(unittest.TestCase): 138 | def test_cannot_provide_json_and_data(self): 139 | with self.assertRaises(Exception): 140 | request( 141 | "https://httpbingo.org/post", 142 | json={"name": "Brenton"}, 143 | data="This is some form data", 144 | ) 145 | 146 | def test_should_fail_if_json_or_data_and_not_p_method(self): 147 | with self.assertRaises(Exception): 148 | request("https://httpbingo.org/post", json={"name": "Brenton"}) 149 | 150 | with self.assertRaises(Exception): 151 | request( 152 | "https://httpbingo.org/post", json={"name": "Brenton"}, method="HEAD" 153 | ) 154 | 155 | def test_should_set_content_type_for_json_request(self): 156 | response = request( 157 | "https://httpbingo.org/post", json={"name": "Brenton"}, method="POST" 158 | ) 159 | self.assertEqual(response.request.headers["Content-type"], "application/json") 160 | 161 | def test_should_work(self): 162 | response = request("https://httpbingo.org/get") 163 | self.assertEqual(response.status, 200) 164 | 165 | def test_should_create_url_from_params(self): 166 | response = request( 167 | "https://httpbingo.org/get", 168 | params={"name": "brenton", "library": "tiny-request"}, 169 | ) 170 | self.assertEqual( 171 | response.url, "https://httpbingo.org/get?name=brenton&library=tiny-request" 172 | ) 173 | 174 | def test_should_return_headers(self): 175 | response = request( 176 | "https://httpbingo.org/response-headers", params={"Test-Header": "value"} 177 | ) 178 | self.assertEqual(response.headers["test-header"], "value") 179 | 180 | def test_should_populate_json(self): 181 | response = request("https://httpbingo.org/json") 182 | self.assertTrue("slideshow" in response.json) 183 | 184 | def test_should_return_response_for_404(self): 185 | response = request("https://httpbingo.org/404") 186 | self.assertEqual(response.status, 404) 187 | self.assertTrue("text/plain" in response.headers["content-type"]) 188 | 189 | def test_should_fail_with_bad_ssl(self): 190 | with self.assertRaises(URLError): 191 | response = request("https://expired.badssl.com/") 192 | 193 | def test_should_load_bad_ssl_with_verify_false(self): 194 | response = request("https://expired.badssl.com/", verify=False) 195 | self.assertEqual(response.status, 200) 196 | 197 | def test_should_form_encode_non_json_post_requests(self): 198 | response = request( 199 | "https://httpbingo.org/post", data={"name": "test-user"}, method="POST" 200 | ) 201 | self.assertEqual(response.json["form"]["name"], ["test-user"]) 202 | 203 | def test_should_follow_redirect(self): 204 | response = request( 205 | "https://httpbingo.org/redirect-to", 206 | params={"url": "https://duckduckgo.com/"}, 207 | ) 208 | self.assertEqual(response.url, "https://duckduckgo.com/") 209 | self.assertEqual(response.status, 200) 210 | 211 | def test_should_not_follow_redirect_if_redirect_false(self): 212 | response = request( 213 | "https://httpbingo.org/redirect-to", 214 | params={"url": "https://duckduckgo.com/"}, 215 | redirect=False, 216 | ) 217 | self.assertEqual(response.status, 302) 218 | 219 | def test_cookies(self): 220 | response = request( 221 | "https://httpbingo.org/cookies/set", 222 | params={"cookie": "test"}, 223 | redirect=False, 224 | ) 225 | response = request( 226 | "https://httpbingo.org/cookies", cookiejar=response.cookiejar 227 | ) 228 | self.assertEqual(response.json["cookie"], "test") 229 | 230 | def test_basic_auth(self): 231 | response = request( 232 | "http://httpbingo.org/basic-auth/user/passwd", basic_auth=("user", "passwd") 233 | ) 234 | self.assertEqual(response.json["authorized"], True) 235 | 236 | def test_should_handle_gzip(self): 237 | response = request( 238 | "http://httpbingo.org/gzip", headers={"Accept-Encoding": "gzip"} 239 | ) 240 | self.assertEqual(response.json["gzipped"], True) 241 | 242 | def test_should_handle_gzip_error(self): 243 | response = request( 244 | "http://httpbingo.org/status/418", headers={"Accept-Encoding": "gzip"} 245 | ) 246 | self.assertEqual(response.content, b"I'm a teapot!") 247 | 248 | def test_should_timeout(self): 249 | import socket 250 | 251 | with self.assertRaises((TimeoutError, socket.timeout)): 252 | response = request("http://httpbingo.org/delay/3", timeout=1) 253 | 254 | def test_should_handle_head_requests(self): 255 | response = request("http://httpbingo.org/head", method="HEAD") 256 | self.assertTrue(response.content == b"") 257 | --------------------------------------------------------------------------------