Compare commits

...

5 commits
master ... blog

11 changed files with 321 additions and 3 deletions

View file

@ -9,8 +9,7 @@ params:
siteLicenseName: "GNU GPLv3"
taxonomies:
category: categories
tag: tags
blog_tag: 'blog/tags'
privacy:
disqus:

15
content/blog/_index.md Normal file
View file

@ -0,0 +1,15 @@
---
title: "Blog"
date: 2024-04-01
draft: false
type: blog
menu:
main:
weight: 150
---
This blog is an eclectic collection of things I thought deserved to be written
up and recorded. It may (or may never) include the way I set up some services
for system administration, things I struggled to find, cool dev tricks, or
whatever crosses my mind (and, hopefully, was helpful).

View file

@ -0,0 +1,69 @@
---
title: "Infrastructure for ACME (LetsEncrypt) certificates in a private LAN"
date: 2024-04-10
draft: false
'blog/tags':
- sysadmin
- Échirolles
---
[LetsEncrypt](https://letsencrypt.org) --- the non-profit certification
authority that certifies a vast portion of the web --- doesn't really need
introduction anymore. They provide free SSL certificates, and even better, they
do so using their standardized automated protocol, ACME
([RFC 8555](https://datatracker.ietf.org/doc/html/rfc8555)).
When a client requests a certificate through ACME for a domain, it will be
handed a token --- a random string --- by the server. This token (the
challenge) must be temporarily served by the client to prove that it indeed
controls the domain. This automated validation uses either `http-01` or
`dns-01` challenges. The former requires the client to serve the token via http
at a certain URL. The latter requires the client to register a `TXT` DNS entry
under the domain bearing the token. The `http-01` process is clearly easier to
implement, and makes ACME straightforward in many cases.
Assume, however, that the machine trying to obtain a certificate is in a
private LAN --- it has no public IP address, or should remain completely
firewalled from the Internet, including the ACME server. In this case, the
`http-01` challenge cannot be used, and `dns-01` must be used instead. Many
articles online give instructions on how to make Certbot or other ACME clients
update DNS records. However, I am really not comfortable with giving all of my
applicative servers write-access to my DNS zones.
## ACME NS challenger
Our solution, instead, is to leave the ACME client running on the applicative
server (we use [Dehydrated](https://github.com/dehydrated-io/dehydrated)), and
to implement a simple API on the DNS server to allow applicative servers to
update only what they need to be able to update.
On the DNS server, a simple Flask-based webserver listens on the internal
network. It has two API endpoints: one to create an ACME challenge for a domain
(`PUT` request), one to mark a previously-created challenge as obsolete
(`DELETE` request).
Each request is validated in two ways:
* it must come from an IP address which resolves to the domain to be updated
--- with some configurable exceptions for eg. domains with addresses in
isolated networks;
* it must bear a challenge, which is the HMAC of the current time and a shared
secret between the DNS and applicative server (distributed out-of-band
earlier). The DNS server thus identifies which token is allowed to update
which domain.
An intermediate directory contains all the currently deployed challenges. The
`PUT` request for domain X generates a DNS zone file for X under the
intermediate directory; a `DELETE` request thus only needs to delete this file.
Finally, after a `PUT` request, all domain-specific zone files are gathered
under a single file, with the Python equivalent of
`cat intermediate_dir/*.zone > acme.zone`. A `DELETE` request, however, only
deletes the intermediate file: to avoid generating too many zone updates, the
global zone file will only be updated upon the next `PUT`, or upon scheduled
garbage collection.
Finally, the generated `acme.zone` file is included in the global DNS
configuration file.
## Deployment in practice

View file

@ -31,7 +31,7 @@
{{ partial "menu" . }}
</div>
<div id="content">
<div id="content" class="{{ block "contentclass" . }}{{ end }}">
{{ block "pagename" . }}
{{ end }}

15
layouts/blog/list.html Normal file
View file

@ -0,0 +1,15 @@
{{ define "profilepic" }}<div class="nopic"></div>{{ end }}
{{ define "pagename" }}
<h1>{{ .Title }}</h1>
{{ end }}
{{ define "main" }}
{{ .Content }}
<div id="entries">
{{ range .Pages }}
{{ partial "blog/entry_list.html" . }}
{{ end }}
</div>
{{ end }}

20
layouts/blog/single.html Normal file
View file

@ -0,0 +1,20 @@
{{ define "profilepic" }}<div class="nopic"></div>{{ end }}
{{ define "contentclass" }}blog{{ end }}
{{ define "pagename" }}
<div id="post_head">
<div id="post_date">{{ .Date | time.Format ":date_medium" }}</div>
{{ with .GetTerms "blog/tags" }}
<ul id="post_tags">
{{ range . -}}
<li class="tag"><a href="{{ .RelPermalink }}">{{ .LinkTitle }}</a></li>
{{- end }}
</ul>
{{ end }}
<h1>{{ .Title }}</h1>
</div>
{{ end }}
{{ define "main" }}
{{ .Content }}
{{ end }}

View file

@ -0,0 +1,13 @@
<div class="blog_entry">
<div class="blog_entry_meta">
<span class="blog_entry_date">{{ .Date | time.Format ":date_medium" }}</span>
{{ with .GetTerms "blog/tags" }}
<ul class="blog_entry_tags">
{{ range . -}}
<li class="tag"><a href="{{ .RelPermalink }}">{{ .LinkTitle }}</a></li>
{{- end }}
</ul>
{{ end }}
</div>
<a href="{{ .Permalink }}" class="blog_entry_title">{{ .Title }}</a>
</div>

108
scss/_blog.scss Normal file
View file

@ -0,0 +1,108 @@
// vim: tabstop=2 shiftwidth=2 expandtab
@import 'params';
#entries {
.blog_entry {
padding: 5px 0;
margin: 20px 0;
display: block;
.blog_entry_title {
display: block;
font-size: 1.1em;
text-align: left;
}
.blog_entry_meta {
display: flex;
justify-content: space-between;
align-items: center;
column-gap: 20px;
font-style: italic;
font-size: 0.9em;
color: $fg_color_light;
}
.blog_entry_date {
display: block;
align-self: flex-start;
}
.blog_entry_tags {
list-style: none;
padding-left: 0;
margin: 0;
li {
display: inline;
}
li ~ li::before {
content: ", ";
}
&::before {
content: "Tags:"
}
}
}
}
#post_head {
margin: 20px 0;
h1 {
margin: 5px 0;
}
#post_date {
text-align: right;
font-style: italic;
}
#post_tags {
display: flex;
justify-content: flex-end;
list-style: none;
padding-left: 0;
margin: 5px 0;
&::before {
content: "Tags: ";
font-style: italic;
padding-right: 1em;
}
li {
font-style: italic;
}
li ~ li::before {
content: ", ";
}
}
}
.blog {
.highlight>pre {
padding: 10px;
border-radius: 10px;
}
img {
display: block;
margin: 10px auto;
max-width: 80%;
max-height: 60vh;
border-radius: 10px;
}
blockquote {
font-style: italic;
border-left: 3px solid #8b8bbd;
padding-left: 15px;
margin-left: 25px;
}
:not(pre) > code {
background-color: $icode_bg_color;
padding: 2px 4px;
border-radius: 4px;
font-size: 85%;
color: $icode_fg_color;
}
}

View file

@ -5,6 +5,8 @@ $link_color: #07a;
$fg_color: #555;
$fg_color_light: #555555bb;
$head_bg_color: #060033;
$icode_fg_color: $fg_color;
$icode_bg_color: #e1e1e1;
$resp_small: 1350px;
$resp_vsmall: 1000px;

View file

@ -2,6 +2,7 @@
@import url('fonts.css');
@import 'params';
@import 'blog';
html {
background-color: $bg_color;

View file

@ -1,5 +1,81 @@
@charset "UTF-8";
@import url("fonts.css");
#entries .blog_entry {
padding: 5px 0;
margin: 20px 0;
display: block; }
#entries .blog_entry .blog_entry_title {
display: block;
font-size: 1.1em;
text-align: left; }
#entries .blog_entry .blog_entry_meta {
display: flex;
justify-content: space-between;
align-items: center;
column-gap: 20px;
font-style: italic;
font-size: 0.9em;
color: #555555bb; }
#entries .blog_entry .blog_entry_date {
display: block;
align-self: flex-start; }
#entries .blog_entry .blog_entry_tags {
list-style: none;
padding-left: 0;
margin: 0; }
#entries .blog_entry .blog_entry_tags li {
display: inline; }
#entries .blog_entry .blog_entry_tags li ~ li::before {
content: ", "; }
#entries .blog_entry .blog_entry_tags::before {
content: "Tags:"; }
#post_head {
margin: 20px 0; }
#post_head h1 {
margin: 5px 0; }
#post_head #post_date {
text-align: right;
font-style: italic; }
#post_head #post_tags {
display: flex;
justify-content: flex-end;
list-style: none;
padding-left: 0;
margin: 5px 0; }
#post_head #post_tags::before {
content: "Tags: ";
font-style: italic;
padding-right: 1em; }
#post_head #post_tags li {
font-style: italic; }
#post_head #post_tags li ~ li::before {
content: ", "; }
.blog .highlight > pre {
padding: 10px;
border-radius: 10px; }
.blog img {
display: block;
margin: 10px auto;
max-width: 80%;
max-height: 60vh;
border-radius: 10px; }
.blog blockquote {
font-style: italic;
border-left: 3px solid #8b8bbd;
padding-left: 15px;
margin-left: 25px; }
.blog :not(pre) > code {
background-color: #e1e1e1;
padding: 2px 4px;
border-radius: 4px;
font-size: 85%;
color: #555; }
html {
background-color: white; }