Compare commits
5 commits
Author | SHA1 | Date | |
---|---|---|---|
Théophile Bastian | c035f62b41 | ||
Théophile Bastian | f0dc1483a4 | ||
Théophile Bastian | acc6b594f8 | ||
Théophile Bastian | e59b755090 | ||
Théophile Bastian | de415ad907 |
|
@ -9,8 +9,7 @@ params:
|
||||||
siteLicenseName: "GNU GPLv3"
|
siteLicenseName: "GNU GPLv3"
|
||||||
|
|
||||||
taxonomies:
|
taxonomies:
|
||||||
category: categories
|
blog_tag: 'blog/tags'
|
||||||
tag: tags
|
|
||||||
|
|
||||||
privacy:
|
privacy:
|
||||||
disqus:
|
disqus:
|
||||||
|
|
15
content/blog/_index.md
Normal file
15
content/blog/_index.md
Normal 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).
|
69
content/blog/acme_infrastructure_in_private_lan.md
Normal file
69
content/blog/acme_infrastructure_in_private_lan.md
Normal 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
|
||||||
|
|
||||||
|
|
|
@ -31,7 +31,7 @@
|
||||||
{{ partial "menu" . }}
|
{{ partial "menu" . }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="content">
|
<div id="content" class="{{ block "contentclass" . }}{{ end }}">
|
||||||
{{ block "pagename" . }}
|
{{ block "pagename" . }}
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
||||||
|
|
15
layouts/blog/list.html
Normal file
15
layouts/blog/list.html
Normal 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
20
layouts/blog/single.html
Normal 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 }}
|
13
layouts/partials/blog/entry_list.html
Normal file
13
layouts/partials/blog/entry_list.html
Normal 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
108
scss/_blog.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -5,6 +5,8 @@ $link_color: #07a;
|
||||||
$fg_color: #555;
|
$fg_color: #555;
|
||||||
$fg_color_light: #555555bb;
|
$fg_color_light: #555555bb;
|
||||||
$head_bg_color: #060033;
|
$head_bg_color: #060033;
|
||||||
|
$icode_fg_color: $fg_color;
|
||||||
|
$icode_bg_color: #e1e1e1;
|
||||||
|
|
||||||
$resp_small: 1350px;
|
$resp_small: 1350px;
|
||||||
$resp_vsmall: 1000px;
|
$resp_vsmall: 1000px;
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
@import url('fonts.css');
|
@import url('fonts.css');
|
||||||
@import 'params';
|
@import 'params';
|
||||||
|
@import 'blog';
|
||||||
|
|
||||||
html {
|
html {
|
||||||
background-color: $bg_color;
|
background-color: $bg_color;
|
||||||
|
|
|
@ -1,5 +1,81 @@
|
||||||
@charset "UTF-8";
|
@charset "UTF-8";
|
||||||
@import url("fonts.css");
|
@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 {
|
html {
|
||||||
background-color: white; }
|
background-color: white; }
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue