Compare commits
3 commits
main
...
article/fi
Author | SHA1 | Date | |
---|---|---|---|
d69ddb02a5 | |||
368ee09953 | |||
f3daa66e7e |
45 changed files with 633 additions and 1742 deletions
72
.drone.yml
Normal file
72
.drone.yml
Normal file
|
@ -0,0 +1,72 @@
|
|||
---
|
||||
# Test building the code and docker image
|
||||
kind: pipeline
|
||||
type: docker
|
||||
name: test-build
|
||||
|
||||
steps:
|
||||
- name: docker-build-only
|
||||
image: thegeeklab/drone-docker-buildx
|
||||
privileged: true
|
||||
settings:
|
||||
repo: git.faercol.me/faercol/hugo-relie
|
||||
tags: latest
|
||||
dry_run: true
|
||||
platforms:
|
||||
- linux/amd64
|
||||
depends_on:
|
||||
when:
|
||||
branch:
|
||||
exclude:
|
||||
- main
|
||||
|
||||
- name: docker-build-push
|
||||
image: thegeeklab/drone-docker-buildx
|
||||
privileged: true
|
||||
settings:
|
||||
repo: git.faercol.me/faercol/hugo-relie
|
||||
registry: git.faercol.me
|
||||
tags: latest
|
||||
username:
|
||||
from_secret: GIT_USERNAME
|
||||
password:
|
||||
from_secret: GIT_PASSWORD
|
||||
platforms:
|
||||
- linux/amd64
|
||||
when:
|
||||
branch:
|
||||
- main
|
||||
|
||||
trigger:
|
||||
event:
|
||||
- push
|
||||
- tag
|
||||
|
||||
---
|
||||
# On a tag, only build the related docker image
|
||||
kind: pipeline
|
||||
type: docker
|
||||
name: tag-release
|
||||
depends_on:
|
||||
- test-build
|
||||
|
||||
steps:
|
||||
- name: docker-push-tag
|
||||
image: thegeeklab/drone-docker-buildx
|
||||
privileged: true
|
||||
settings:
|
||||
registry: git.faercol.me
|
||||
repo: git.faercol.me/faercol/hugo-relie
|
||||
auto_tag: true
|
||||
platforms:
|
||||
- linux/amd64
|
||||
username:
|
||||
from_secret: GIT_USERNAME
|
||||
password:
|
||||
from_secret: GIT_PASSWORD
|
||||
|
||||
trigger:
|
||||
event:
|
||||
- tag
|
||||
|
||||
...
|
|
@ -1,21 +0,0 @@
|
|||
on:
|
||||
push:
|
||||
branches:
|
||||
- "main"
|
||||
jobs:
|
||||
docker-build-push:
|
||||
runs-on: cth-ubuntu-latest
|
||||
steps:
|
||||
- name: set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: login to repository
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.faercol.me
|
||||
username: ${{ secrets.DOCKER_LOGIN }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: build and push image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
push: true
|
||||
tags: git.faercol.me/faercol/hugo-relie:latest
|
|
@ -1,21 +0,0 @@
|
|||
on:
|
||||
push:
|
||||
tags:
|
||||
- "**"
|
||||
jobs:
|
||||
docker-build-push:
|
||||
runs-on: cth-ubuntu-latest
|
||||
steps:
|
||||
- name: set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: login to repository
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.faercol.me
|
||||
username: ${{ secrets.DOCKER_LOGIN }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: build and push image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
push: true
|
||||
tags: git.faercol.me/faercol/hugo-relie:${{ gitea.ref_name }}
|
|
@ -1,16 +0,0 @@
|
|||
on:
|
||||
push:
|
||||
branches:
|
||||
- "**"
|
||||
- "!main"
|
||||
jobs:
|
||||
docker-build-only:
|
||||
runs-on: cth-ubuntu-latest
|
||||
steps:
|
||||
- name: set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: build image (build only)
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
push: false
|
||||
tags: faercol/hugo-relie:latest
|
|
@ -1,6 +1,6 @@
|
|||
# hugo-relie
|
||||
|
||||
[![status-badge](https://ci-server.internal.faercol.me/api/badges/1/status.svg)](https://ci-server.internal.faercol.me/repos/1)
|
||||
[![Build Status](https://drone.faercol.me/api/badges/faercol/hugo-relie/status.svg)](https://drone.faercol.me/faercol/hugo-relie)
|
||||
|
||||
Personal blog using Hugo
|
||||
|
||||
|
|
16
config.toml
16
config.toml
|
@ -15,6 +15,9 @@ dataDir = "data"
|
|||
layoutDir = "layouts"
|
||||
publishDir = "public"
|
||||
|
||||
[author]
|
||||
name = "Melora Hugues"
|
||||
|
||||
[taxonomies]
|
||||
category = "blog"
|
||||
tags = "tags"
|
||||
|
@ -29,7 +32,6 @@ dateformNumTime = "02-01-2006 15:04"
|
|||
disableReadOtherPosts = false
|
||||
enableSharingButtons = true
|
||||
enableGlobalLanguageMenu = true
|
||||
enableThemeToggle = true
|
||||
|
||||
description = "Personal website and tech blog for Melora Hugues"
|
||||
keywords = "blog, tech, dev"
|
||||
|
@ -40,22 +42,22 @@ images = [""]
|
|||
# url = ""
|
||||
|
||||
# Integrate Plausible.io
|
||||
# plausibleDataDomain = 'faercol.me'
|
||||
# plausibleScriptSource = 'https://plausible.faercol.me/js/script.js'
|
||||
plausibleDataDomain = 'faercol.me'
|
||||
plausibleScriptSource = 'https://plausible.faercol.me/js/script.js'
|
||||
|
||||
[params.footer]
|
||||
trademark = true
|
||||
trademark = 2023
|
||||
rss = true
|
||||
copyright = false
|
||||
author = true
|
||||
|
||||
[params.author]
|
||||
name = "Melora Hugues"
|
||||
|
||||
[params.logo]
|
||||
logoText = "Hello there!"
|
||||
logoHomeLink = "/"
|
||||
|
||||
[params.author]
|
||||
name = "Melora Hugues"
|
||||
|
||||
[[params.social]]
|
||||
name = "git"
|
||||
url = "https://git.faercol.me"
|
||||
|
|
|
@ -1,689 +0,0 @@
|
|||
---
|
||||
title: "How to do HTTPS at home (when your infrastructure is private)"
|
||||
date: 2024-09-08T15:18:00+02:00
|
||||
lastmod: 2024-09-08T17:20:00+02:00
|
||||
draft: false
|
||||
toc: true
|
||||
images:
|
||||
tags:
|
||||
- self-hosting
|
||||
- sysadmin
|
||||
---
|
||||
|
||||
## The problem of having a self-hosted infrastructure
|
||||
|
||||
I've been maintaining a personal homelab and self-hosted infrastructure for a few years
|
||||
now, but one of the most infuriating pages when starting such project is this dreaded
|
||||
**Warning: Potential Security Risk Ahead** page that appears when you're using a
|
||||
self-signed certificate, or when trying to use a password on a website or app that is
|
||||
served through plain HTTP.
|
||||
|
||||
![A screenshot of a warning from Firefox indicating that the website that is being accessed is not secure.](/images/dns_article_firefox_warning.png)
|
||||
|
||||
While acceptable if you're alone on your own infrastructure or dev environment, this
|
||||
poses several issues in many other contexts:
|
||||
|
||||
- It is not acceptable to publicly expose a website presenting this issue
|
||||
- It's not advisable to say "hey look, I know that your browser gives you a big red
|
||||
warning, but it's okay, you can just accept" to friends/family/etc. It's just a very
|
||||
bad habit to have
|
||||
- After a while, it really starts to get on your nerve
|
||||
|
||||
Thankfully a free solution for that, which is well known by now, has existed
|
||||
for almost ten (10) years now: [Let's Encrypt and the ACME protocol](https://letsencrypt.org/).
|
||||
|
||||
{{< callout type="note" >}}
|
||||
I promise this is not yet another Let's Encrypt tutorial... Well it is, but for a more
|
||||
specific use-case
|
||||
{{< /callout >}}
|
||||
|
||||
## The Let's Encrypt solution
|
||||
|
||||
### What is Let's Encrypt
|
||||
|
||||
[Let's Encrypt](https://letsencrypt.org/) is a nonprofit certificate authority founded
|
||||
in November 2014. Its main goal was to provide an easy and free way to obtain a TLS
|
||||
certificate in order to make it easy to use HTTPS everywhere.
|
||||
|
||||
The [ACME protocol](https://letsencrypt.org/docs/client-options/) developed by Let's
|
||||
Encrypt is an automated verification system aiming at doing the following:
|
||||
|
||||
- verifying that you own the domain for which you want a certificate
|
||||
- creating and registering that certificate
|
||||
- delivering the certificate to you
|
||||
|
||||
Most client implementation also have an automated renewal system, further reducing the
|
||||
workload for sysadmins.
|
||||
|
||||
The current specification for the ACME protocol proposes two (2) types of challenges
|
||||
to prove ownership and control over a domain: [HTTP-01](https://letsencrypt.org/docs/challenge-types/#http-01-challenge) and [DNS-01](https://letsencrypt.org/docs/challenge-types/#dns-01-challenge) challenge.
|
||||
|
||||
{{< callout type="note" >}}
|
||||
Actually there are two (2) others: [TLS-SNI-01](https://letsencrypt.org/docs/challenge-types/#tls-sni-01) which is now disabled, and [TLS-ALPN-01](https://letsencrypt.org/docs/challenge-types/#tls-alpn-01) which is only aimed at a very
|
||||
specific category of users, which we will ignore here.
|
||||
{{< /callout >}}
|
||||
|
||||
### The common solution: HTTP challenge
|
||||
|
||||
The [HTTP-01](https://letsencrypt.org/docs/challenge-types/#http-01-challenge) challenge
|
||||
is the most common type of ACME challenge, and will satisfy most use-cases.
|
||||
|
||||
![A schema describing the HTTP challenge workflow for the ACME protocol and the interactions between the application server, Let's Encrypt, and the DNS server, all of them public.](/images/dns_article_http_challenge.svg)
|
||||
|
||||
For this challenge, we need the following elements :
|
||||
|
||||
- A domain name and a record for that domain in a public DNS server (it can be a self-hosted DNS server, our providers', etc)
|
||||
- Access to a server with a public IP that can be publicly reached
|
||||
|
||||
When performing this type of challenge, the following happens (in a very simplified way):
|
||||
|
||||
1. The ACME client will ask to start a challenge to the Let's Encrypt API
|
||||
2. In return, it will get a token
|
||||
3. It will then either start a standalone server, or edit the configuration for our
|
||||
current web server (nginx, apache, etc) to serve a file containing the token and a fingerprint of our account key.
|
||||
4. Let's Encrypt will try to resolve our domain `test.example.com`.
|
||||
5. If resolution works, then it will check the url `http://test.example.com/.well-known/acme-challenge/<TOKEN>`, and verify that the file from step 3 is served with the correct
|
||||
content.
|
||||
|
||||
If everything works as expected, then the ACME client can download the certificate and key, and we can configure our reverse proxy or server to use this valid certificate,
|
||||
all is well.
|
||||
|
||||
{{< callout type="help" >}}
|
||||
Okay, but my app contains my accounts, or my proxmox management interface, and I
|
||||
don't really want to make it public, so how does it work here?
|
||||
{{< /callout >}}
|
||||
|
||||
Well it doesn't. For this type of challenge to work, the application server **must** be
|
||||
public. For this challenge we need to prove that we have control over the application
|
||||
that uses the target domain (even if we don't control the domain itself). But the
|
||||
DNS-01 challenge bypasses this limitation.
|
||||
|
||||
### When it's not enough: the DNS challenge
|
||||
|
||||
As we saw in the previous section, sometimes, for various reasons, the application
|
||||
server is in a private zone. It must be only reachable from inside a private network,
|
||||
but we might still want to be able to use a free Let's Encrypt certificate.
|
||||
|
||||
For this purpose, the [DNS-01](https://letsencrypt.org/docs/challenge-types/#dns-01-challenge) challenge is based on proving that one has control over the **DNS
|
||||
server** itself, instead of the application server.
|
||||
|
||||
![A schema describing the DNS challenge workflow for the ACME protocol and the interaction between Let's Encrypt, the public DNS server and the private application server](/images/dns_article_dns_challenge_1.svg)
|
||||
|
||||
For this type of challenge, the following elements are needed :
|
||||
|
||||
- A public DNS server we have control over (can be a self-hosted server, or your DNS provider)
|
||||
- A ACME client (usually it would be on the application server), it doesn't need to be public
|
||||
|
||||
Then, the challenge is done the following way :
|
||||
|
||||
1. The ACME client will ask to start a challenge to the Let's Encrypt API.
|
||||
2. In return, it will get a token.
|
||||
3. The client then creates a `TXT` record at `_acme-challenge.test.example.com` derived from the token
|
||||
and the account key.
|
||||
4. Let's Encrypt will try to resolve the expected `TXT` record, and verify that the content is correct.
|
||||
|
||||
If the verification succeeds, we can download your certificate and key, just like the other
|
||||
type of challenge.
|
||||
|
||||
It's important to note that **at no point in time did Let's Encrypt have access to the
|
||||
application server itself**, because this challenges involves proving that we control
|
||||
the domain, not that we control the destination of that domain.
|
||||
|
||||
If I'm trying to obtain a valid certificate for my Proxmox interface, this is the way I
|
||||
would want to go, because it would allow me to have a valid certificate, despite my server
|
||||
not being public at all. So let's see how it works in practice.
|
||||
|
||||
## DNS challenge in practice
|
||||
|
||||
For this example, I will try to obtain a certificate for my own domain
|
||||
`test.internal.example.com`. As this name suggests, it is an internal domain and should not
|
||||
be publicly reachable, so this means I'm going to use a DNS challenge. I don't really want
|
||||
to use my DNS provider API for this, so I'm going to use a self-hosted [bind](https://www.isc.org/bind/)
|
||||
server for that.
|
||||
|
||||
{{< callout type="note" >}}
|
||||
The rest of this "guide" will be based on a deployment for a `bind9` server. It can be
|
||||
adapted to any other type of deployment, but all the configuration snippets are based
|
||||
on `bind9`. Let's Encrypt has [relevant documentations](https://community.letsencrypt.org/t/dns-providers-who-easily-integrate-with-lets-encrypt-dns-validation/86438) for
|
||||
other hosting providers.
|
||||
{{< /callout >}}
|
||||
|
||||
### Configuring the DNS server
|
||||
|
||||
The first step is configuring the DNS server. For this, I'll just use a [bind](https://bind9.readthedocs.io/en/v9.18.27/)
|
||||
server installed from my usual package manager.
|
||||
|
||||
```bash
|
||||
# example on Debian 12
|
||||
sudo apt install bind9
|
||||
```
|
||||
|
||||
Most of the configuration happens in the `/etc/bind` directory, mostly in `/etc/bind/named.conf.local`
|
||||
|
||||
```text
|
||||
root@dns-server: ls /etc/bind/
|
||||
bind.keys db.127 db.empty named.conf named.conf.local rndc.key
|
||||
db.0 db.255 db.local named.conf.default-zones named.conf.options zones.rfc1918
|
||||
```
|
||||
|
||||
Let's declare a first zone, for `internal.example.com`. Add the following config to
|
||||
`/etc/bind/named.conf.local`
|
||||
|
||||
```text
|
||||
zone "internal.example.com." IN {
|
||||
type master;
|
||||
file "/var/lib/bind/internal.example.com.zone";
|
||||
```
|
||||
|
||||
This simply declares a new zone which is described in the file `/var/lib/bind/internal.example.com.zone`
|
||||
|
||||
Let's now create the zone itself. A DNS zone has a base structure that we must follow
|
||||
|
||||
```dns
|
||||
$ORIGIN .
|
||||
$TTL 7200 ; 2 hours
|
||||
internal.example.com IN SOA ns.internal.example.com. admin.example.com. (
|
||||
2024070301 ; serial
|
||||
3600 ; refresh (1 hour)
|
||||
600 ; retry (10 minutes)
|
||||
86400 ; expire (1 day)
|
||||
600 ; minimum (10 minutes)
|
||||
)
|
||||
NS ns.internal.example.com.
|
||||
|
||||
$ORIGIN internal.example.com.
|
||||
ns A 1.2.3.4
|
||||
test A 192.168.1.2
|
||||
```
|
||||
|
||||
This file declares a zone `internal.example.com` which master is `ns.internal.example.com`.
|
||||
It also sets the parameters (time to live for the records, and the current serial for the
|
||||
zone config).
|
||||
|
||||
Finally, two (2) A records are created, associating the name `ns.internal.example.com` to
|
||||
the IP address `1.2.3.4`, and `test.internal.example.com` (the domain for which we want
|
||||
a certificate) to a local IP address `192.168.1.2`.
|
||||
|
||||
A simple `systemctl restart bind9` would be enough to apply the modification, but we still
|
||||
have one thing to do, which is allowing remote modifications to the zone.
|
||||
|
||||
### Enabling remote DNS zone modification
|
||||
|
||||
To allow remote modification of our DNS zone, we are going to use [TSIG](https://www.ibm.com/docs/en/aix/7.3?topic=ssw_aix_73/network/bind9_tsig.htm)
|
||||
which stands for **Transaction signature**. It's a way to secure server to server operations
|
||||
to edit a DNS zone, and is preferred to access control based on IP addresses.
|
||||
|
||||
Let's start with creating a key using the command `tsig-keygen <keyname>`
|
||||
|
||||
```shell
|
||||
➜ tsig-keygen letsencrypt
|
||||
key "letsencrypt" {
|
||||
algorithm hmac-sha256;
|
||||
secret "oK6SqKRvGNXHyNyIEy3hijQ1pclreZw4Vn5v+Q4rTLs=";
|
||||
};
|
||||
```
|
||||
|
||||
This creates a key with the given name using the default algorithm (which is `hmac-sha256`).
|
||||
The entire output of this command is actually a code block that you can add to your bind9
|
||||
configuration.
|
||||
|
||||
Finally, using `update-policy`, allow this key to be used to update the zone.
|
||||
|
||||
```text
|
||||
update-policy {
|
||||
grant letsencrypt. zonesub txt;
|
||||
};
|
||||
```
|
||||
|
||||
{{< callout type="note" >}}
|
||||
Doing so allows users to update everything in our zone using this key. In fact
|
||||
we would only need to update `_acme-challenge.test.internal.example.com` as seen
|
||||
in the DNS challenge description.
|
||||
|
||||
If we want a better restriction, then we can use the following configuration instead
|
||||
|
||||
```text
|
||||
update-policy {
|
||||
grant letsencrypt. name _acme-challenge.test.internal.example.com. txt;
|
||||
};
|
||||
```
|
||||
|
||||
{{< /callout >}}
|
||||
|
||||
This means our entire `named.conf.local` would become something like this
|
||||
|
||||
```text
|
||||
key "letsencrypt" {
|
||||
algorithm hmac-sha256;
|
||||
secret "oK6SqKRvGNXHyNyIEy3hijQ1pclreZw4Vn5v+Q4rTLs=";
|
||||
};
|
||||
|
||||
zone "internal.example.com." IN {
|
||||
type master;
|
||||
file "/var/lib/bind/internal.example.com.zone";
|
||||
update-policy {
|
||||
grant letsencrypt. zonesub txt;
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
{{< callout type="warning" >}}
|
||||
Be **very cautious** about the `.` at the end of the zone name and the key name, they are
|
||||
easy to miss, and forgetting them will cause issues that would be hard to detect.
|
||||
{{< /callout >}}
|
||||
|
||||
With that being done, you can restart the DNS server and everything is ready server side,
|
||||
the only remaining thing to do would be the DNS challenge itself.
|
||||
|
||||
### Performing the challenge
|
||||
|
||||
We start by installing the certbot with the RFC2136 plugin (to perform the DNS challenge).
|
||||
|
||||
```shell
|
||||
apt install python3-certbot-dns-rfc2136
|
||||
```
|
||||
|
||||
It's handled using a `.ini` configuration file, let's put it in `/etc/certbot/credentials.ini`
|
||||
|
||||
```ini
|
||||
dns_rfc2136_server = <you_dns_ip>
|
||||
dns_rfc2136_port = 53
|
||||
dns_rfc2136_name = letsencrypt.
|
||||
dns_rfc2136_secret = oK6SqKRvGNXHyNyIEy3hijQ1pclreZw4Vn5v+Q4rTLs=
|
||||
dns_rfc2136_algorithm = HMAC-SHA512
|
||||
```
|
||||
|
||||
Finally, run the challenge using certbot (if it's the first time you're using certbot on
|
||||
that machine, it might ask for an email to handle admin stuff).
|
||||
|
||||
```shell
|
||||
root@toolbox:~: certbot certonly --dns-rfc2136 --dns-rfc2136-credentials /etc/certbot/credentials.ini -d 'test.internal.example.com'
|
||||
|
||||
Saving debug log to /var/log/letsencrypt/letsencrypt.log
|
||||
Requesting a certificate for test.internal.example.com
|
||||
Waiting 60 seconds for DNS changes to propagate
|
||||
|
||||
Successfully received certificate.
|
||||
Certificate is saved at: /etc/letsencrypt/live/test.internal.example.com/fullchain.pem
|
||||
Key is saved at: /etc/letsencrypt/live/test.internal.example.com/privkey.pem
|
||||
This certificate expires on 2024-09-30.
|
||||
These files will be updated when the certificate renews.
|
||||
Certbot has set up a scheduled task to automatically renew this certificate in the background.
|
||||
|
||||
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
||||
If you like Certbot, please consider supporting our work by:
|
||||
* Donating to ISRG / Let's Encrypt: https://letsencrypt.org/donate
|
||||
* Donating to EFF: https://eff.org/donate-le
|
||||
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
||||
```
|
||||
|
||||
And that's done, we have a certificate, and a no point in time did we need to
|
||||
actually expose our application to the outside world.
|
||||
|
||||
{{< callout type="warning" >}}
|
||||
We used `standalone` mode for the certbot here, which means that when it renews
|
||||
the certificates, certbot will only download the new certificates, and nothing more.
|
||||
If we use a reverse proxy like `nginx`, we would also need to restart the service
|
||||
in order to load the new certificates when they are renewed, as certbot would not do
|
||||
it itself in `standalone` mode.
|
||||
{{< /callout >}}
|
||||
|
||||
Now because I like to go way too far, I can propose two (2) improvements to this
|
||||
setup:
|
||||
|
||||
- Using ACL in addition to the TSIG key to secure operations on the DNS server
|
||||
- Using a second DNS server only locally accessible for your private records, and
|
||||
using the public server to only perform challenges
|
||||
|
||||
## Bonus 1: adding a second layer of authentication to connect to the DNS
|
||||
|
||||
In our setup, we used **TSIG** to secure our access to the DNS server, meaning that
|
||||
having the key is necessary to perform the operations. If we are paranoid, or if we
|
||||
want to do a little bit more, then we could add a second layer of authentication based
|
||||
on [Access Control List (ACL)](https://bind9.readthedocs.io/en/v9.18.1/security.html).
|
||||
|
||||
**ACL** allow to filter allowed operations based on several characteristics, such as
|
||||
IP address, TSIG key, subnet. In our case, we will use an IPV4 subnet from inside a
|
||||
Wireguard tunnel between the application servers (DNS clients) and the DNS server. It
|
||||
could be any form of tunnel, but Wireguard is easy to configure and perfect for
|
||||
point-to-point tunnels such as what we are doing here.
|
||||
|
||||
### Wireguard configuration
|
||||
|
||||
First, let's create the [Wireguard](https://www.wireguard.com/quickstart/) tunnel.
|
||||
|
||||
We start by creating two wireguard key pairs, which can be done this way
|
||||
|
||||
```shell
|
||||
# Install wireguard tools
|
||||
apt install wireguard-tools
|
||||
|
||||
# Create the keypair
|
||||
wg genkey | tee privatekey | wg pubkey > publickey
|
||||
```
|
||||
|
||||
Private key is in the `privatekey` file, and public key in the `publickey` file.
|
||||
|
||||
Then we can create the server configuration, create a file `/etc/wg/wg0.conf` on
|
||||
the DNS server.
|
||||
|
||||
```ini
|
||||
[Interface]
|
||||
PrivateKey = <server_private_key>
|
||||
Address = 192.168.42.1/24
|
||||
ListenPort = 51820
|
||||
|
||||
[Peer]
|
||||
PublicKey = <client_public_key>
|
||||
AllowedIPs = 192.168.42.0/24
|
||||
```
|
||||
|
||||
Then on the client side you can do the same
|
||||
|
||||
```ini
|
||||
[Interface]
|
||||
PrivateKey = <client_private_key>
|
||||
Address = 192.168.42.2/24
|
||||
|
||||
[Peer]
|
||||
PublicKey = <server_public_key>
|
||||
Endpoint = <dns_public_ip>:51820
|
||||
AllowedIPs = 192.168.42.1/32
|
||||
```
|
||||
|
||||
Then you can start the tunnel on both sides using `wg-quick up wg0`, check that ip works
|
||||
by pinging the server from the client
|
||||
|
||||
```shell
|
||||
root@toolbox:~ ping 192.168.42.1
|
||||
PING 192.168.42.1 (192.168.42.1) 56(84) bytes of data.
|
||||
64 bytes from 192.168.42.1: icmp_seq=1 ttl=64 time=19.2 ms
|
||||
64 bytes from 192.168.42.1: icmp_seq=2 ttl=64 time=8.25 ms
|
||||
```
|
||||
|
||||
Basically, we created a new network `192.168.42.0/24` which links the DNS server and our client,
|
||||
and we can restrict modification to the DNS zone to force them to be from inside the
|
||||
virtual network, instead of allowing them from anywhere.
|
||||
|
||||
{{< callout type="note" >}}
|
||||
The ACL that we are going to use here can have many other purposes, such as hiding
|
||||
some domains, or serving different versions of a zone depending on the origin of
|
||||
the client. This is not our topic of concern here though.
|
||||
{{< /callout >}}
|
||||
|
||||
### DNS configuration
|
||||
|
||||
Using ACLs, we are going to split the DNS zone into several [views](https://kb.isc.org/docs/aa-00851)
|
||||
based on the source IP. Basically our goal is to say that
|
||||
|
||||
- Users coming from inside our wireguard network `192.168.42.0/24` can modify DNS
|
||||
records in our zone using the TSIG key defined earlier.
|
||||
- Users coming from any other IP can read the DNS zone, but nothing else, so they can't
|
||||
update it, even using the correct key.
|
||||
|
||||
ACL can be defined inside `named.conf.local` using the following syntax.
|
||||
|
||||
```text
|
||||
acl local {
|
||||
127.0.0.0/8;
|
||||
192.168.42.0/24;
|
||||
};
|
||||
```
|
||||
|
||||
This means that local addresses, and addresses coming from our wireguard network
|
||||
will be considered as `local` and can be referenced as such in the rest of the
|
||||
configuration.
|
||||
|
||||
Then, a view can be created like this:
|
||||
|
||||
```text
|
||||
view "internal" {
|
||||
match-clients { local; };
|
||||
zone "internal.example.com." IN {
|
||||
type master;
|
||||
file "/var/lib/bind/internal.example.com.zone";
|
||||
update-policy {
|
||||
grant letsencrypt. zonesub txt;
|
||||
};
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
Basically this means that the view `internal` is only used for clients that match
|
||||
the `local` ACL defined above. In this view we define the zone `internal.example.com`,
|
||||
which is the zone we defined earlier.
|
||||
|
||||
We also need to declare the zone for non-local users who wouldn't match the `local` ACL.
|
||||
It's important to note that **you cannot use the same zone file twice in different zones**,
|
||||
so we cannot define the public view exactly the same way. Our public view will be
|
||||
defined the following way:
|
||||
|
||||
```text
|
||||
view "public" {
|
||||
zone "internal.example.com." IN {
|
||||
in-view internal;
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
This way, in the `public` view, we define the `internal.example.com` zone, and we
|
||||
define this zone as being inside the `internal` view. This way, we will serve the
|
||||
exact same DNS zone whatever the origin, but the *update policy* only applies to user
|
||||
from local addresses, and they will be the only ones able to edit the zone.
|
||||
|
||||
In summary, our `named.conf.local` file should now look like this.
|
||||
|
||||
```text
|
||||
acl local {
|
||||
127.0.0.0/8;
|
||||
192.168.42.0/24;
|
||||
};
|
||||
|
||||
key "letsencrypt." {
|
||||
algorithm hmac-sha512;
|
||||
secret "oK6SqKRvGNXHyNyIEy3hijQ1pclreZw4Vn5v+Q4rTLs=";
|
||||
};
|
||||
|
||||
view "internal" {
|
||||
match-clients { local; };
|
||||
zone "internal.example.com." IN {
|
||||
type master;
|
||||
file "/var/lib/bind/internal.example.com.zone";
|
||||
update-policy {
|
||||
grant letsencrypt. zonesub txt;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
view "public" {
|
||||
zone "internal.example.com." IN {
|
||||
in-view internal;
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
And now, without any additional change needed, we have a second layer of authentication
|
||||
for the DNS zone updates. We can go a little further and make sure that the private IPs
|
||||
themselves are hidden from the outside.
|
||||
|
||||
## Bonus 2: completely hiding our private domains from outside
|
||||
|
||||
In this post, we implemented our own DNS server (or we used the one from our provider) in
|
||||
order to resolve internal private hosts, and perform DNS challenges for those hosts in order
|
||||
to obtain SSL certificates. But this is not entirely satisfying.
|
||||
|
||||
For example, we have the following record in our DNS zone:
|
||||
|
||||
```text
|
||||
test A 192.168.1.2
|
||||
```
|
||||
|
||||
This means that running `host test.internal.example.com` (or dig, or any other DNS query tool)
|
||||
will return `192.168.1.2`, whether you're using your internal DNS, or Google's, or any
|
||||
other server. This is not great: this IP is private, and should not have any meaning
|
||||
outside of your network, and, while there wouldn't probably be any impact, publicly
|
||||
giving the information that you have a private host named `test` on an internal domain,
|
||||
its IP address (and thus par of your internal infrastructure) isn't great, especially
|
||||
if you have 10 hosts instead of only one.
|
||||
|
||||
For this reason we could use two (2) DNS servers with a different purpose:
|
||||
|
||||
- A server inside the private network which would resolve the private hosts
|
||||
- A server outside the private network, which is only used for the challenges
|
||||
|
||||
Indeed, inside our network, we don't really need to be publicly reachable, but we need
|
||||
name resolution on our local hosts. In the same way, Let's Encrypt doesn't need any
|
||||
`A` record to perform DNS challenges, it only needs a `TXT` record, so each server
|
||||
can have its own specific role.
|
||||
|
||||
![A schema describing the DNS challenge workflow for the ACME protocol with a separation between a public and a private DNS servers and the interaction between Let's Encrypt and the public DNS server on one side, and the private application server, the user, and the private DNS server on the other side](/images/dns_article_dns_challenge_2.svg)
|
||||
|
||||
Basically, what we need is the following:
|
||||
|
||||
- a publicly reachable DNS server (the one from the previous parts of this post), that will
|
||||
have:
|
||||
- only its own `NS` records
|
||||
- the TSIG key and rules to update the zone
|
||||
- optionally, the VPN tunnel
|
||||
- the `TXT` record to perform the DNS challenges
|
||||
- a private DNS on your local infrastructure, that will have
|
||||
- all the `A` (and other types of) DNS records for your internal infrastructure
|
||||
|
||||
Let's split the previous configuration (I'll use the one from the [Bonus 1](#bonus-1-adding-a-second-layer-of-authentication-to-connect-to-the-dns) section as an example
|
||||
|
||||
### Private DNS server
|
||||
|
||||
On the private DNS server, the only thing we need is our local `internal.example.com` zone
|
||||
definition, so our `named.conf.local` should look like this
|
||||
|
||||
```text
|
||||
zone "internal.example.com" IN {
|
||||
type master;
|
||||
file "/var/lib/bind/internal.example.com.zone";
|
||||
allow-update { none; };
|
||||
};
|
||||
```
|
||||
|
||||
And our zone definition would look like this
|
||||
|
||||
```text
|
||||
$ORIGIN .
|
||||
$TTL 7200 ; 2 hours
|
||||
internal.example.com IN SOA ns.internal.example.com. admin.example.com. (
|
||||
2024070301 ; serial
|
||||
3600 ; refresh (1 hour)
|
||||
600 ; retry (10 minutes)
|
||||
86400 ; expire (1 day)
|
||||
600 ; minimum (10 minutes)
|
||||
)
|
||||
NS ns.internal.example.com.
|
||||
|
||||
$ORIGIN internal.example.com.
|
||||
ns A 192.168.1.1
|
||||
test A 192.168.1.2
|
||||
```
|
||||
|
||||
This server should be set as DNS in our DHCP configuration (or in the client
|
||||
configuration if we don't use DHCP).
|
||||
|
||||
### Public DNS server
|
||||
|
||||
For the public DNS server, we don't need private `A` records, we just need the
|
||||
configuration necessary to update the public zone, so our `named.conf.local`
|
||||
file should look like this (it's the exact same configuration as before)
|
||||
|
||||
```text
|
||||
acl local {
|
||||
127.0.0.0/8;
|
||||
192.168.42.0/24;
|
||||
};
|
||||
|
||||
key "letsencrypt." {
|
||||
algorithm hmac-sha512;
|
||||
secret "oK6SqKRvGNXHyNyIEy3hijQ1pclreZw4Vn5v+Q4rTLs=";
|
||||
};
|
||||
|
||||
view "internal" {
|
||||
match-clients { local; };
|
||||
zone "internal.example.com." IN {
|
||||
type master;
|
||||
file "/var/lib/bind/internal.example.com.zone";
|
||||
update-policy {
|
||||
grant letsencrypt. zonesub txt;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
view "public" {
|
||||
zone "internal.example.com." IN {
|
||||
in-view internal;
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
The zone file should be the following (we only removed the private `A` record,
|
||||
the rest is the same as before).
|
||||
|
||||
```text
|
||||
$ORIGIN .
|
||||
$TTL 7200 ; 2 hours
|
||||
internal.example.com IN SOA ns.internal.example.com. admin.example.com. (
|
||||
2024070301 ; serial
|
||||
3600 ; refresh (1 hour)
|
||||
600 ; retry (10 minutes)
|
||||
86400 ; expire (1 day)
|
||||
600 ; minimum (10 minutes)
|
||||
)
|
||||
NS ns.internal.example.com.
|
||||
|
||||
$ORIGIN internal.example.com.
|
||||
ns A 1.2.3.4
|
||||
```
|
||||
|
||||
### Testing the configuration
|
||||
|
||||
Once the two servers are up and running, and everything is configured as expected,
|
||||
we can test that everything works as expected by trying to perform a DNS query
|
||||
using `hosts`, `dig`, etc on our private records and our `NS` record.
|
||||
|
||||
```shell
|
||||
# Trying to resolve our domain from inside our private infra returns the expected IP
|
||||
~ …
|
||||
➜ host test.internal.example.com
|
||||
Using domain server:
|
||||
Name: 192.168.1.1
|
||||
Address: 192.168.1.11#53
|
||||
Aliases:
|
||||
|
||||
test.internal.example.com has address 192.168.1.2
|
||||
|
||||
# Trying to resolve our domain using a public DNS server (here Google)
|
||||
# fails since it doesn't exist outside our network
|
||||
~ …
|
||||
➜ host test.internal.example.com 8.8.8.8
|
||||
Using domain server:
|
||||
Name: 8.8.8.8
|
||||
Address: 8.8.8.8#53
|
||||
Aliases:
|
||||
|
||||
Host test.internal.example.com not found: 3(NXDOMAIN)
|
||||
```
|
||||
|
||||
## Final words
|
||||
|
||||
While this method, including the small adjustments and improvements, is a bit
|
||||
more involved than ignoring the issue and using only HTTP challenges, when the
|
||||
infrastructure is in place it becomes very easy to use and to set-up, and makes
|
||||
for a very clean infrastructure.
|
||||
|
||||
It is also the only way to obtain a wildcard certificate `*.internal.example.com`
|
||||
for example that would allow using a single certificate for all the services inside
|
||||
an infrastructure.
|
||||
|
||||
I would argue that a setup of this type is very adapted to homelabs or small businesses
|
||||
that have a private infrastructure, but don't want to go through the trouble of setuping
|
||||
an entire PKI (Private Key Infrastructure).
|
||||
|
||||
## Edits
|
||||
|
||||
- 2024-09-08: Fixed [public DNS configuration](#public-dns-server)
|
|
@ -1,717 +0,0 @@
|
|||
---
|
||||
title: "Comment faire du HTTPS chez soi (quand son infrastructure est privée)"
|
||||
date: 2024-09-08T15:18:00+02:00
|
||||
lastmod: 2024-09-08T17:20:00+02:00
|
||||
draft: false
|
||||
toc: true
|
||||
images:
|
||||
tags:
|
||||
- self-hosting
|
||||
- sysadmin
|
||||
---
|
||||
|
||||
## Le problème quand on a une infrastructure chez soi
|
||||
|
||||
Cela fait plusieurs années que je maintiens ma propre infrastructure chez
|
||||
moi, mais l'un des problèmes les plus pénibles lorsque l'on commence ce
|
||||
type de projet est la fameuse page **Attention: risque de sécurité** qui
|
||||
apparaît lorsque l'on utilise un certificat autosigné, ou lorsque l'on essaye
|
||||
d'utiliser un mot de passe sur un site web ou une application servie uniquement
|
||||
via HTTP.
|
||||
|
||||
![Une capture d'écran de la page Firefox indiquant que le site web auquel on essaye d'accéder n'est pas sécurisé.](/images/dns_article_firefox_warning.png)
|
||||
|
||||
Si on peut accepter cela si on est seul·e sur son infrastructure, ou son
|
||||
environnement de dev, cela commence à poser des problèmes dans d'autres contextes :
|
||||
|
||||
- Ce n'est pas acceptable d'exposer publiquement un site web présentant ce problème
|
||||
- Cela paraît douteux de conseiller à ses ami·es ou fanille "t'inquiète, je sais que
|
||||
ton navigateur te montre un gros avertissement en rouge là, mais tu peux accepter".
|
||||
C'est juste une très mauvaise habitude à avoir
|
||||
- Au bout d'un moment c'est vraiment pénible de voir cette page à chaque fois
|
||||
|
||||
Heureusement, il y a une solution pour cela. Vous la connaissez sûrement déjà, et cela
|
||||
fait maintenant presque dix (10) ans que cela existe, c'est
|
||||
[Let's Encrypt, et le protocole ACME](https://letsencrypt.org/).
|
||||
|
||||
{{< callout type="note" >}}
|
||||
Je vous jure que ce n'est pas encore un nouveau tuto Let's Encrypt. Enfin, en un sens
|
||||
c'est le cas, mais c'est un peu plus spécifique ici.
|
||||
{{< /callout >}}
|
||||
|
||||
## La solution Let's Encrypt
|
||||
|
||||
### Let's Encrypt, c'est quoi?
|
||||
|
||||
[Let's Encrypt](https://letsencrypt.org/) est une autorité de certification non-lucrative
|
||||
fondée en novembre 2014. Son objectif principal était de proposer une façon facile, et
|
||||
gratuite d'obtenir un certificat TLS afin de rendre facile le HTTPS partout sur le Web.
|
||||
|
||||
Le [protocole ACME](https://letsencrypt.org/docs/client-options/), développé par Let's
|
||||
Encrypt, est un système de vérification automatique visant à faire la chose suivante :
|
||||
|
||||
- vérifier que l'on possède le domaine pour lequel on veut obtenir un certificat
|
||||
- créer et enregistrer ce certificat
|
||||
- délivrer le certificat à l'utilisateur
|
||||
|
||||
La majorité des implémentations client de ce protocole propose également un système
|
||||
automatisé de renouvellement, réduisant davantage la charge de travail pour les
|
||||
administrateur·ices système.
|
||||
|
||||
Les spécifications actuelles du protocol ACME offrent le choix entre deux (2) types de
|
||||
challenge afin de prouver le contrôle sur le domaine : [HTTP-01](https://letsencrypt.org/docs/challenge-types/#http-01-challenge) et [DNS-01](https://letsencrypt.org/docs/challenge-types/#dns-01-challenge).
|
||||
|
||||
{{< callout type=note >}}
|
||||
En vérité, il existe deux (2) autres types de challenges : [TLS-SNI-01](https://letsencrypt.org/docs/challenge-types/#tls-sni-01) aujourd'hui déprécié et désactivé, ainsi que [TLS-ALPN-01](https://letsencrypt.org/docs/challenge-types/#tls-alpn-01).
|
||||
Ce dernier s'adresse à un public très spécifique, et nous allons donc complètement l'ignorer
|
||||
pour le moment.
|
||||
{{< /callout >}}
|
||||
|
||||
### La solution habituelle : le challenge HTTP
|
||||
|
||||
Le challenge [HTTP-01](https://letsencrypt.org/docs/challenge-types/#http-01-challenge)
|
||||
est le type de challenge ACME le plus courant. Il est largement suffisant pour la majorité
|
||||
des cas d'usage.
|
||||
|
||||
![Un schema décrivant le fonctionnement du challenge HTTP challenge pour le protocole ACME et les interactions entre le serveur applicatif, Let's Encrypt, et le serveur DNS, tous publics.](/images/dns_article_http_challenge.svg)
|
||||
|
||||
Pour ce challenge, on a besoin des éléments suivants :
|
||||
|
||||
- Un nom de domaine et un enregistrement pour ce domaine dans un serveur DNS public
|
||||
(cela peut être un serveur DNS self-hosted, celui du provider DNS, etc)
|
||||
- Un accès à un serveur avec une adresse IP publiquement accessible
|
||||
|
||||
Ce type de challenge se déroule de la façon suivante (de façon schématisée et simplifiée)
|
||||
|
||||
1. Le client ACME contacte l'API Let's Encrypt afin de commencer le challenge
|
||||
2. Il obtient un token
|
||||
3. Ensuite, il va démarrer un serveur dédié, ou éditer la configuration du serveur Web actuel
|
||||
(nginx, apache, etc) afin de serveur un fichier contenant le token et l'empreinte de la clé
|
||||
de notre compte Let's Encrypt
|
||||
4. Let's Encrypt va ensuite essayer de résoudre notre domaine `test.example.com`
|
||||
5. Si la résolution fonctionne, Let's Encrypt va accéder à l'URL `http://test.example.com/.well-known/acme-challange/<TOKEN>` et vérifier que le fichier généré à l'étape 3 est bien servi avec le
|
||||
contenu attendu
|
||||
|
||||
Si tout se déroule comme prévu, alors le client ACME peut télécharger le certificat et sa clé,
|
||||
et on peut configurer notre reverse-proxy ou serveur pour utiliser ce certificat, et c'est bon.
|
||||
|
||||
{{< callout type=help >}}
|
||||
Ok, super, mais mon app c'est l'interface de management de mon serveur Proxmox, et franchement
|
||||
je ne veux vraiment pas la rendre accessible publiquement, du coup ça marchera comment ?
|
||||
{{< /callout >}}
|
||||
|
||||
Dans ce cas, ce type de challenge ne fonctionnera tout simplement pas. En effet, ici, le serveur applicatif **doit** être public. Ce type de challenge cherche à montrer
|
||||
que l'on a le contrôle sur la destination ciblée par le domaine (même si on n'a pas le contrôle
|
||||
sur le domaine lui-même). En revanche, le challenge DNS-01 permet de répondre à ce besoin.
|
||||
|
||||
### Si cela ne suffit pas : le challenge DNS
|
||||
|
||||
Comme on l'a dit précédemment, parfois, pour diverses raisons, le serveur applicatif se trouve
|
||||
dans une zone privée. Il ne doit être accessible que depuis un réseau privée, mais on aimerait
|
||||
quand même pouvoir avoir recours à un certificat gratuit Let's Encrypt
|
||||
|
||||
Dans cet objectif, il existe le challenge [DNS-01](https://letsencrypt.org/docs/challenge-types/#dns-01-challenge), dont l'objectif est de prouver le contrôle du **serveur DNS** (ou
|
||||
plutôt de la zone DNS), et non du serveur applicatif.
|
||||
|
||||
![Un schema décrivant le fonctionnement du challenge DNS pour le protocole ACME ainsi que les interactions entre Let's Encrypt, le serveur DNS public et le serveur applicatif privé](/images/dns_article_dns_challenge_1.svg)
|
||||
|
||||
Pour ce type de challenge, les éléments suivants sont nécessaires :
|
||||
|
||||
- Un serveur DNS public dont on a le contrôle (ici encore, il peut s'agit d'un serveur self-hosted,
|
||||
de l'interface de notre provider, etc)
|
||||
- Un client ACME qui n'a pas besoin d'être sur une machine publiquement accessible
|
||||
|
||||
Ensuite, le challenge se déroule de la façon suivante :
|
||||
|
||||
1. Le client ACME demande à l'API Let's Encrypt de démarrer le challenge
|
||||
2. Le client obtient un token en retour
|
||||
3. Le client crée ensuite un enregistrement `TXT` sur `_acme-challenge.test.example.com`, dont
|
||||
la valeur est dérivée du token et de la clé utilisateur Let's Encrypt
|
||||
4. Let's Encrypt essaye de résoudre l'enregistrement `TXT` en question, et vérifie que le contenu
|
||||
est correct.
|
||||
|
||||
Si la vérification se déroule correctement, il est ensuite possible de télécharger le certificat
|
||||
et la clé associée, comme pour le type de challenge précédent.
|
||||
|
||||
Il faut noter que **à aucun moment Let's Encrypt n'a eu besoin d'avoir accès au serveur de l'application**
|
||||
car ce challenge a pour objectif de montrer que l'on contrôle le domaine, pas que l'on contrôle
|
||||
la destination de ce domaine.
|
||||
|
||||
Si je veux obtenir un certificat valide et vérifiable pour mon interface Proxmox, c'est ce type
|
||||
de challenge que je vais vouloir utiliser. En effet, cela me permet d'obtenir mon certificat
|
||||
valide sans pour autant rendre public mon serveur Proxmox. Voyons-voir comment cela fonctionne
|
||||
en pratique.
|
||||
|
||||
## Comment faire un challenge DNS en pratique
|
||||
|
||||
Dans le cadre de cet example, je vais essayer d'obtenir un certificat pour mon propre domaine
|
||||
`test.internal.example.com`. Comme ce nom le suggère, il s'agit d'un domaine interne qui ne
|
||||
devrait pas être publiquement accessible, ce qui signifie que je vais utiliser un challenge
|
||||
DNS. Je ne veux pas vraiment utiliser l'API de mon provider DNS pour cela, et je vais donc
|
||||
me reposer sur un serveur [bind](https://www.isc.org/bind/) self-hosted.
|
||||
|
||||
{{< callout type="note" >}}
|
||||
Pour le reste de ce "guide", je vais me baser sur un déploiement pour un serveur DNS
|
||||
`bind9`. Tout est adaptable pour n'importe quel autre type de déploiement, mais tous
|
||||
les exemples de configurations sont spécifiques à `bind9`. Let's Encrypt propose
|
||||
de la [documentation adaptée](https://community.letsencrypt.org/t/dns-providers-who-easily-integrate-with-lets-encrypt-dns-validation/86438) à d'autres
|
||||
types de déploiements.
|
||||
{{< /callout >}}
|
||||
|
||||
### Configuration du serveur DNS
|
||||
|
||||
La première étape est de configurer un serveur DNS. Pour cela, je vais simplement utiliser
|
||||
un server [bind](https://bind9.readthedocs.io/en/v9.18.27/) installé depuis mon gestionnaire
|
||||
de paquets habituel.
|
||||
|
||||
```bash
|
||||
# exemple pour Debian 12
|
||||
sudo apt install bind9
|
||||
```
|
||||
|
||||
La majorité de la configuration se produit dans le dossier `/etc/bind/`, et principalement
|
||||
dans le fichier `/etc/bind/named.conf.local`
|
||||
|
||||
```shell
|
||||
root@dns-server: ls /etc/bind/
|
||||
bind.keys db.127 db.empty named.conf named.conf.local rndc.key
|
||||
db.0 db.255 db.local named.conf.default-zones named.conf.options zones.rfc1918
|
||||
```
|
||||
|
||||
Commençons par déclarer une première zone pour le domaine `internal.example.com`. On ajoute
|
||||
la configuration suivante au fichier `/etc/bind/named.conf.local`
|
||||
|
||||
```text
|
||||
zone "internal.example.com." IN {
|
||||
type master;
|
||||
file "/var/lib/bind/internal.example.com.zone";
|
||||
```
|
||||
|
||||
Cela permet de déclarer une nouvelle zone dont le contenu est décrit dans le fichier
|
||||
`/var/lib/bind/internal.example.com.zone`.
|
||||
|
||||
À présent, on va créer la zone elle-même. Une zone DNS a une structure de base qu'il faut suivre.
|
||||
|
||||
```dns
|
||||
$ORIGIN .
|
||||
$TTL 7200 ; 2 hours
|
||||
internal.example.com IN SOA ns.internal.example.com. admin.example.com. (
|
||||
2024070301 ; serial
|
||||
3600 ; refresh (1 hour)
|
||||
600 ; retry (10 minutes)
|
||||
86400 ; expire (1 day)
|
||||
600 ; minimum (10 minutes)
|
||||
)
|
||||
NS ns.internal.example.com.
|
||||
|
||||
$ORIGIN internal.example.com.
|
||||
ns A 1.2.3.4
|
||||
test A 192.168.1.2
|
||||
```
|
||||
|
||||
Ce fichier déclare une zone `internal.example.com`, dont le serveur maître a pour nom
|
||||
`ns.internal.example.com`. On définit également une série de paramètres (durée de vie
|
||||
des enregistrements, numéro de série de la zone, etc).
|
||||
|
||||
Enfin, on crée deux (2) enregistrements `A` afin d'associer le nom `ns.internal.example.com`
|
||||
à l'adresse IP `1.2.3.4`, et `test.internal.example.com` (qui est le domaine pour lequel on
|
||||
voudra un certificat) à une adresse IP locale `192.168.1.2`.
|
||||
|
||||
Enfin, un simple `systemctl restart bind9` (sur Debian) permettrait d'appliquer directement
|
||||
ces modifications. Mais il nous reste encore une chose à configurer avant : permettre la
|
||||
modification de cette zone à distance.
|
||||
|
||||
### Activation des modifications de la zone à distance
|
||||
|
||||
Afin de permettre de modifier notre zone DNS à distance, nous allons utiliser
|
||||
[TSIG](https://www.ibm.com/docs/en/aix/7.3?topic=ssw_aix_73/network/bind9_tsig.htm), ce
|
||||
qui signifie **Transaction signature**. C'est une façon de sécuriser des opérations
|
||||
entre serveurs afin d'éditer une zone DNS, et elle est préférée par rapport à une simple
|
||||
sécurisation basée sur l'adresse IP des clients par exemple.
|
||||
|
||||
Commençons par créer une clé en utilisant la commande `tsig-keygen <keyname>`.
|
||||
|
||||
```shell
|
||||
➜ tsig-keygen letsencrypt
|
||||
key "letsencrypt" {
|
||||
algorithm hmac-sha256;
|
||||
secret "oK6SqKRvGNXHyNyIEy3hijQ1pclreZw4Vn5v+Q4rTLs=";
|
||||
};
|
||||
```
|
||||
|
||||
Cela permet de créer une clé qui a le nom donné en paramètre en utilisant l'algorithme
|
||||
par défaut (ici il s'agit de `hmac-sha256`). L'intégralité de la sortie de cette commande
|
||||
est en fait un bloc de configuration que l'on peut ajouter au reste de la configuration
|
||||
de `bind`.
|
||||
|
||||
Enfin, en utilisant la directive `update-policy`, on peut permettre à cette clé d'autoriser
|
||||
des modifications à distance de notre zone.
|
||||
|
||||
```text
|
||||
update-policy {
|
||||
grant letsencrypt. zonesub txt;
|
||||
};
|
||||
```
|
||||
|
||||
{{< callout type=note >}}
|
||||
En faisant cela, nous autorisons les utilisateur·ices à modifier l'intégralité de cette
|
||||
zone en utilisant cette clé. En fait, ici nous n'aurions besoin de modifier que l'enregistrement
|
||||
`TXT` `_acme-challenge.test.internal.example.com`, comme ce qui est spécifié pour le challenge
|
||||
DNS.
|
||||
|
||||
Si on veut une meilleure restriction, on peut utiliser cette configuration à la place,
|
||||
et dans ce cas n'autoriser que la modification d'un enregistrement spécifique.
|
||||
|
||||
```text
|
||||
update-policy {
|
||||
grant letsencrypt. name _acme-challenge.test.internal.example.com. txt;
|
||||
};
|
||||
```
|
||||
|
||||
{{< /callout >}}
|
||||
|
||||
Cela veut dire que le contenu de notre fichier `named.conf.local` devient
|
||||
|
||||
```text
|
||||
key "letsencrypt" {
|
||||
algorithm hmac-sha256;
|
||||
secret "oK6SqKRvGNXHyNyIEy3hijQ1pclreZw4Vn5v+Q4rTLs=";
|
||||
};
|
||||
|
||||
zone "internal.example.com." IN {
|
||||
type master;
|
||||
file "/var/lib/bind/internal.example.com.zone";
|
||||
update-policy {
|
||||
grant letsencrypt. zonesub txt;
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
{{< callout type="warning" >}}
|
||||
Il faut faire **très attention** au `.` à la fin du nom de la zone ainsi que du nom de la clé,
|
||||
c'est très facile de les oublier, ce qui causerait des problèmes difficiles à détecter.
|
||||
{{< /callout >}}
|
||||
|
||||
### Réalisation du challenge
|
||||
|
||||
On commence par installer le certbot avec le plugin **RFC 2136** (qui nous permet de réaliser
|
||||
les challenges DNS).
|
||||
|
||||
```shell
|
||||
apt install python3-certbot-dns-rfc2136
|
||||
```
|
||||
|
||||
Le certbot se configure via un fichier de configuration au format `.ini`, on va le placer
|
||||
dans le fichier `/etc/certbot/credentials.ini`.
|
||||
|
||||
```ini
|
||||
dns_rfc2136_server = <you_dns_ip>
|
||||
dns_rfc2136_port = 53
|
||||
dns_rfc2136_name = letsencrypt.
|
||||
dns_rfc2136_secret = oK6SqKRvGNXHyNyIEy3hijQ1pclreZw4Vn5v+Q4rTLs=
|
||||
dns_rfc2136_algorithm = HMAC-SHA512
|
||||
```
|
||||
|
||||
Enfin, on peut lancer le challenge en utilisant le certbot (si c'est la première fois que
|
||||
le bot est utilisé sur notre machine, on nous demandera d'accepter les conditions d'utilisation
|
||||
et de donner une adresse email pour la gestion administrative des certificats et les notifications
|
||||
de renouvellement, c'est normal.)
|
||||
|
||||
```shell
|
||||
root@toolbox:~: certbot certonly --dns-rfc2136 --dns-rfc2136-credentials /etc/certbot/credentials.ini -d 'test.internal.example.com'
|
||||
|
||||
Saving debug log to /var/log/letsencrypt/letsencrypt.log
|
||||
Requesting a certificate for test.internal.example.com
|
||||
Waiting 60 seconds for DNS changes to propagate
|
||||
|
||||
Successfully received certificate.
|
||||
Certificate is saved at: /etc/letsencrypt/live/test.internal.example.com/fullchain.pem
|
||||
Key is saved at: /etc/letsencrypt/live/test.internal.example.com/privkey.pem
|
||||
This certificate expires on 2024-09-30.
|
||||
These files will be updated when the certificate renews.
|
||||
Certbot has set up a scheduled task to automatically renew this certificate in the background.
|
||||
|
||||
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
||||
If you like Certbot, please consider supporting our work by:
|
||||
* Donating to ISRG / Let's Encrypt: https://letsencrypt.org/donate
|
||||
* Donating to EFF: https://eff.org/donate-le
|
||||
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
||||
```
|
||||
|
||||
Et c'est bon, on a un certificat, et à aucun moment nous n'avons eu besoin d'exposer une
|
||||
application au monde extérieur.
|
||||
|
||||
{{< callout type="warning" >}}
|
||||
On a utilisé ici le mode `standalone` pour le certbot, ce qui signifie que lorsque les
|
||||
certificats sont renouvelés, aucune action supplémentaire n'est exécutée. Si on utilise
|
||||
un reverse proxy comme `nginx`, il faut également redémarrer le serveur (ou le recharger)
|
||||
afin de charger les nouveaux certificats après renouvellement car le `certbot` ne le fait
|
||||
pas de lui-même en mode `standalone`.
|
||||
{{< /callout >}}
|
||||
|
||||
Maintenant, comme j'aime aller beaucoup trop loin dans tout ce que je fais. on peut ajouter
|
||||
deux (2) améliorations à notre setup :
|
||||
|
||||
- Utiliser des ACL (Contrôle d'Accès) en plus des clés TSIG pour sécuriser les opérations sur
|
||||
notre serveur DNS
|
||||
- Utiliser un second serveur DNS accessible uniquement localement contenant nos enregistrements
|
||||
privés, et utiliser le serveur public uniquement pour réaliser les challenges
|
||||
|
||||
## Bonus 1 : ajouter une couche d'authentification pour se connecter au DNS
|
||||
|
||||
Dans notre setup, on a utilisé **TSIG** afin de sécuriser l'accès au serveur DNS, ce qui
|
||||
signifie que la possession de la clé est nécessaire pour effectuer les opérations. Si vous
|
||||
êtes paranoïaque, ou que vous voulez juste en faire un peu plus, il est possible d'ajouter
|
||||
une seconde couche d'authentification en utilisant des [Listes de Contrôle d'Accès (ACL)](https://bind9.readthedocs.io/en/v9.18.1/security.html).
|
||||
|
||||
Les **ACL** nous permettent de filtrer les opérations autorisées en se basant sur plusieurs
|
||||
caractéristiques, par exemple l'adresse IP, une clé TSIG, etc. Dans notre cas, nous allons
|
||||
utiliser un sous-réseau IPV4 qui se situe à l'intérieur d'un tunnel Wireguard entre notre
|
||||
serveur applicatif (client DNS qui veut réaliser le challenge) et le serveur DNS. Cela peut
|
||||
être n'importe quel type de tunnel, mais Wireguard est facile à configurer et est parfait pour
|
||||
notre cas d'usage.
|
||||
|
||||
### Configuration de Wireguard
|
||||
|
||||
Commençons par créer le tunnel [Wireguard](https://www.wireguard.com/quickstart/)
|
||||
|
||||
On commence par créer deux paires de clé Wireguard, cela peut se faire de la façon suivante
|
||||
|
||||
```shell
|
||||
# Installation des outils Wireguard
|
||||
apt install wireguard-tools
|
||||
|
||||
# Création de la paire de clés
|
||||
wg genkey | tee privatekey | wg pubkey > publickey
|
||||
```
|
||||
|
||||
La clé privée se trouve dans le fichier `privatekey`, et la clé publique dans le fichier
|
||||
`publickey`.
|
||||
|
||||
Ensuite, on peut créer la configuration du serveur. Commençons par placer un fichier
|
||||
`/etc/wg/wg0.conf` dans le serveur DNS avec le contenu suivant
|
||||
|
||||
```ini
|
||||
[Interface]
|
||||
PrivateKey = <server_private_key>
|
||||
Address = 192.168.42.1/24
|
||||
ListenPort = 51820
|
||||
|
||||
[Peer]
|
||||
PublicKey = <client_public_key>
|
||||
AllowedIPs = 192.168.42.0/24
|
||||
```
|
||||
|
||||
Ensuite, du côté de notre client, on peut faire la même chose
|
||||
|
||||
```ini
|
||||
[Interface]
|
||||
PrivateKey = <client_private_key>
|
||||
Address = 192.168.42.2/24
|
||||
|
||||
[Peer]
|
||||
PublicKey = <server_public_key>
|
||||
Endpoint = <dns_public_ip>:51820
|
||||
AllowedIPs = 192.168.42.1/32
|
||||
```
|
||||
|
||||
Enfin, on démarre le tunnel des deux cotés en utilisant la commande `wg-quick up wg0`. Un
|
||||
ping nous permet de vérifier que le tunnel est opérationnel et que le client peut bien
|
||||
atteindre le serveur.
|
||||
|
||||
```shell
|
||||
root@toolbox:~ ping 192.168.42.1
|
||||
PING 192.168.42.1 (192.168.42.1) 56(84) bytes of data.
|
||||
64 bytes from 192.168.42.1: icmp_seq=1 ttl=64 time=19.2 ms
|
||||
64 bytes from 192.168.42.1: icmp_seq=2 ttl=64 time=8.25 ms
|
||||
```
|
||||
|
||||
Pour résumer, on vient de créer un nouveau réseau privé `192.168.42.0/24` qui lie le serveur
|
||||
DNS et notre client, et nous pouvons restreindre les modifications à la zone DNS afin de ne
|
||||
les autoriser que depuis ce réseau privé virtuel, et non de partout comme c'était le cas
|
||||
auparavant.
|
||||
|
||||
{{< callout type="note" >}}
|
||||
Les Contrôles d'Accès que nous allons utiliser ici ont plusieurs autres usages possibles, tels
|
||||
que cacher certains domaines, ou servir différentes versions d'une zone en fonction de
|
||||
l'origine du client. Ça n'est pas vraiment l'objet de ce post cependant.
|
||||
{{< /callout >}}
|
||||
|
||||
### Configuration du serveur DNS
|
||||
|
||||
En utilisant des ACLs, nous allons séparer la zone DNS en différentes [vues](https://kb.isc.org/docs/aa-00851) basées sur l'adresse IP source. Notre objectif est donc que :
|
||||
|
||||
- les utilisateur·ices venant de notre réseau Wiregaurd `192.168.42.0/24` puissent modifier
|
||||
les enregistrements DNS en utilisant la clé TSIG définie précédemment
|
||||
- les utilisateur·ices venant de toute autre adresse IP puissent lire notre zone DNS, mais
|
||||
sans pouvoir la modifier, même avec la bonne clé TSIG
|
||||
|
||||
Les ACL se définissent dans `named.conf.local` en utilisant la syntaxe suivante
|
||||
|
||||
```text
|
||||
acl local {
|
||||
127.0.0.0/8;
|
||||
192.168.42.0/24;
|
||||
};
|
||||
```
|
||||
|
||||
Cela permet de considérer que les adresses locales, ainsi que les adresses de notre réseau
|
||||
Wireguard sont dans un groupe nommé `local`, et on peut utiliser ce groupe pour les
|
||||
référencer dans le reste de notre configuration.
|
||||
|
||||
Ensuite, une vue se crée de la façon suivante :
|
||||
|
||||
```text
|
||||
view "internal" {
|
||||
match-clients { local; };
|
||||
zone "internal.example.com." IN {
|
||||
type master;
|
||||
file "/var/lib/bind/internal.example.com.zone";
|
||||
update-policy {
|
||||
grant letsencrypt. zonesub txt;
|
||||
};
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
Globalement, cela signifie que notre vue `internal` n'est utilisable que pour les clients
|
||||
qui correspondent bien à notre liste d'accès `local` que nous venons de définir. Dans cette
|
||||
vue, nous définissons la zone `internal.example.com` comme dans les sections précédentes.
|
||||
|
||||
Nous devons également déclarer la zone pour les utilisateurs non-locaux qui ne vont dont
|
||||
pas correspondre à l'ACL `local`. Il est important de souligner que **l'on ne peut pas
|
||||
utiliser le même fichier de zone pour deux zones différentes**, donc nous ne pouvons
|
||||
pas définir la vue publique de la même façon que notre vue privée. Nous allons avoir
|
||||
recours à la syntaxe suivante :
|
||||
|
||||
```text
|
||||
view "public" {
|
||||
zone "internal.example.com." IN {
|
||||
in-view internal;
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
Avec cette syntaxe, dans la vue `public`, nous définissons la zone `internal.example.com`
|
||||
en indiquant que cette zone se trouve dans la vue `internal`. De cette façon nous servons
|
||||
exactement la même zone DNS quelle que soit l'origine du client, mais la **politique de mise
|
||||
à jour** ne s'applique pour les utilisateurs avec une adresse locale, qui seront donc les
|
||||
seuls à avoir le droit de mettre à jour la zone.
|
||||
|
||||
En résumé, notre fichier `named.conf.local` devrait ressembler à cela
|
||||
|
||||
```text
|
||||
acl local {
|
||||
127.0.0.0/8;
|
||||
192.168.42.0/24;
|
||||
};
|
||||
|
||||
key "letsencrypt." {
|
||||
algorithm hmac-sha512;
|
||||
secret "oK6SqKRvGNXHyNyIEy3hijQ1pclreZw4Vn5v+Q4rTLs=";
|
||||
};
|
||||
|
||||
view "internal" {
|
||||
match-clients { local; };
|
||||
zone "internal.example.com." IN {
|
||||
type master;
|
||||
file "/var/lib/bind/internal.example.com.zone";
|
||||
update-policy {
|
||||
grant letsencrypt. zonesub txt;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
view "public" {
|
||||
zone "internal.example.com." IN {
|
||||
in-view internal;
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
## Bonus 2 : complètement cacher les adresses privées
|
||||
|
||||
Dans cet article, nous avons implémenté notre propre serveur DNS (ou utilisé celui de notre
|
||||
provider) afin de résoudre des domaines privés internes et effectuer des challenges DNS pour
|
||||
ces hôtes dans le but d'obtenir des certificats SSL. Cependant il reste un élément peu satisfaisant.
|
||||
|
||||
Par exemple, prenons cet enregistrement dans notre zone DNS
|
||||
|
||||
```text
|
||||
test A 192.168.1.2
|
||||
```
|
||||
|
||||
Cela signifie qu'en utilisant la commande `host test.internal.example.com` (ou dig, ou
|
||||
n'importe quel autre outil client DNS) va renvoyer l'adresse `192.168.1.2`, que l'on
|
||||
utilise notre serveur DNS, celui de Google, etc. C'est un peu dommage : cette adresse
|
||||
est privée, elle n'a aucun sens en dehors de sa propre infrastructure, et, même si l'impact
|
||||
est limité, donner des informations sur la nature de notre infrastructure publique est
|
||||
généralement non-désirable.
|
||||
|
||||
Pour corriger ce problème, nous pourrions utiliser deux (2) serveurs DNS ayant chacun leur
|
||||
propre rôle :
|
||||
|
||||
- Un serveur à l'intérieur du réseau privé qui servira à résoudre ces hôtes privés
|
||||
- Un serveur à l'extérieur du réseau public qui servira uniquement pour les challenges DNS
|
||||
|
||||
En effet, à l'intérieur de notre réseau, nous n'avons pas besoin d'être publiquement accessible,
|
||||
mais nous avons besoin d'une résolution de nos noms locaux. De même, Let's Encrypt n'a besoin
|
||||
d'aucun enregistrement `A` pour effectuer le challenge DNS, nous n'avons besoin que d'un
|
||||
unique enregistrement `TXT`, donc chaque serveur a son propre rôle.
|
||||
|
||||
![Un schéma décrivant le fonctionnement du challenge DNS dans le protocole ACME avec une séparation entre un serveur DNS privé et public et les interactions entre Let's Encrypt le serveur DNS public d'un côté, et le serveur DNS privé, l'utilisateur, de l'autre coté](/images/dns_article_dns_challenge_2.svg)
|
||||
|
||||
Globalement, nous avons besoin des éléments suivants :
|
||||
|
||||
- un serveur DNS publiquement accessible (concrètement celui des précédentes parties de cet
|
||||
article) qui aura :
|
||||
- uniquement son propre enregistrement `NS`
|
||||
- la clé TSIG et les règles permettant la mise à jour de sa zone
|
||||
- le tunnel VPN optionnel
|
||||
- l'enregistrement `TXT` permettant la réalisation des challenges DNS
|
||||
- un serveur DNS privé dans l'infrastructure locale qui aura :
|
||||
- tous les enregistrements `A` (et autres) de l'infrastructure privée
|
||||
|
||||
Nous allons donc séparer la configuration précédente (je vais utiliser la configuration
|
||||
finale obtenue dans la section [Bonus 1](#bonus-1--ajouter-une-couche-dauthentification-pour-se-connecter-au-dns)
|
||||
en guise d'exemple)
|
||||
|
||||
### Serveur DNS privé
|
||||
|
||||
Du côté du serveur privé, nous n'avons besoin que de définir notre zone locale
|
||||
`internal.example.com`. Donc notre fichier `named.conf.local` n'a besoin que
|
||||
de la configuration suivante
|
||||
|
||||
```text
|
||||
zone "internal.example.com" IN {
|
||||
type master;
|
||||
file "/var/lib/bind/internal.example.com.zone";
|
||||
allow-update { none; };
|
||||
};
|
||||
```
|
||||
|
||||
Et la définition de notre zone ressemblerait à ceci
|
||||
|
||||
```text
|
||||
$ORIGIN .
|
||||
$TTL 7200 ; 2 hours
|
||||
internal.example.com IN SOA ns.internal.example.com. admin.example.com. (
|
||||
2024070301 ; serial
|
||||
3600 ; refresh (1 hour)
|
||||
600 ; retry (10 minutes)
|
||||
86400 ; expire (1 day)
|
||||
600 ; minimum (10 minutes)
|
||||
)
|
||||
NS ns.internal.example.com.
|
||||
|
||||
$ORIGIN internal.example.com.
|
||||
ns A 192.168.1.1
|
||||
test A 192.168.1.2
|
||||
```
|
||||
|
||||
Ce serveur devrait être configuré en tant que serveur DNS principal dans notre configuration
|
||||
DHCP (ou en configuration statique le cas échéant).
|
||||
|
||||
### Serveur DNS public
|
||||
|
||||
Pour le serveur DNS public, nous n'avons plus besoin des enregistrements `A` précédents,
|
||||
mais uniquement de la configuration nécessaire afin de pouvoir mettre à jour la zone
|
||||
à distance. Notre fichier `named.conf.local` devrait donc ressembler à ceci (il s'agit
|
||||
de la même configuration qu'avant en vérité)
|
||||
|
||||
```text
|
||||
acl local {
|
||||
127.0.0.0/8;
|
||||
192.168.42.0/24;
|
||||
};
|
||||
|
||||
key "letsencrypt." {
|
||||
algorithm hmac-sha512;
|
||||
secret "oK6SqKRvGNXHyNyIEy3hijQ1pclreZw4Vn5v+Q4rTLs=";
|
||||
};
|
||||
|
||||
view "internal" {
|
||||
match-clients { local; };
|
||||
zone "internal.example.com." IN {
|
||||
type master;
|
||||
file "/var/lib/bind/internal.example.com.zone";
|
||||
update-policy {
|
||||
grant letsencrypt. zonesub txt;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
view "public" {
|
||||
zone "internal.example.com." IN {
|
||||
in-view internal;
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
Le fichier de zone devrait ressembler à ceci (nous avons enlevé l'enregistrement `A` qui n'a
|
||||
plus sa place dans la zone publique)
|
||||
|
||||
```text
|
||||
$ORIGIN .
|
||||
$TTL 7200 ; 2 hours
|
||||
internal.example.com IN SOA ns.internal.example.com. admin.example.com. (
|
||||
2024070301 ; serial
|
||||
3600 ; refresh (1 hour)
|
||||
600 ; retry (10 minutes)
|
||||
86400 ; expire (1 day)
|
||||
600 ; minimum (10 minutes)
|
||||
)
|
||||
NS ns.internal.example.com.
|
||||
|
||||
$ORIGIN internal.example.com.
|
||||
ns A 1.2.3.4
|
||||
```
|
||||
|
||||
### Tester la configuration
|
||||
|
||||
Une fois que les deux serveurs sont opérationnels, et que tout est correctement configuré,
|
||||
nous pouvons tester que tout fonctionne comme attendu. Il suffit d'effectuer une requête DNS
|
||||
en utilisant `host`, `dig`, ... sur notre enregistrement privé afin de vérifier que
|
||||
le comportement attendu a bien lieu.
|
||||
|
||||
```shell
|
||||
# Nous pouvons bien atteindre notre domaine privé depuis notre infrastructure
|
||||
~ …
|
||||
➜ host test.internal.example.com
|
||||
Using domain server:
|
||||
Name: 192.168.1.1
|
||||
Address: 192.168.1.11#53
|
||||
Aliases:
|
||||
|
||||
test.internal.example.com has address 192.168.1.2
|
||||
|
||||
# Utiliser un serveur DNS public (ici 8.8.8.8) ne fonctionne pas
|
||||
# car cet enregistrement n'existe pas en dehors de notre réseau
|
||||
~ …
|
||||
➜ host test.internal.example.com 8.8.8.8
|
||||
Using domain server:
|
||||
Name: 8.8.8.8
|
||||
Address: 8.8.8.8#53
|
||||
Aliases:
|
||||
|
||||
Host test.internal.example.com not found: 3(NXDOMAIN)
|
||||
```
|
||||
|
||||
## Mot de la fin
|
||||
|
||||
Cette méthode (en considérant également les améliorations proposées) est plus exigeante
|
||||
que simplement ignorer le problème et n'utiliser que des challenges HTTP. Mais une fois que
|
||||
l'infrastructure est en place, cela devient très simple à mettre en place, et permet
|
||||
d'obtenir une infrastructure très propre et facile à maintenir.
|
||||
|
||||
Il s'agit également de la seule façon possible d'obtenir un certificat wildcard (par exemple
|
||||
`*.internal.example.com`) qui permettrait d'utiliser un unique certificat pour la totalité de
|
||||
notre infrastructure.
|
||||
|
||||
Je considère que ce type de setup est particulièrement adapté à un homelab, ou a un business
|
||||
de petite taille possédant une infrastructure privée, mais ne souhaitant pas s'infliger
|
||||
la création d'une infrastructure PKI (Infrastructure de Clé Privée) complète.
|
||||
|
||||
## Corrections
|
||||
|
||||
- 2024-09-08: Correction de la [configuration DNS publique](#serveur-dns-public)
|
BIN
content/fr/posts/firefly-action-management/euronext.png
Normal file
BIN
content/fr/posts/firefly-action-management/euronext.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 24 KiB |
152
content/fr/posts/firefly-action-management/index.md
Normal file
152
content/fr/posts/firefly-action-management/index.md
Normal file
|
@ -0,0 +1,152 @@
|
|||
---
|
||||
title: "Gérer son épargne en action sous Firefly"
|
||||
date: 2023-12-19T20:10:42+02:00
|
||||
draft: true
|
||||
---
|
||||
|
||||
## Problématique
|
||||
|
||||
## Gestion du portefeuille
|
||||
|
||||
### Valeur d'une action
|
||||
|
||||
Comme énoncé précédemment, la valeur d'un portefeuille d'actions dépend de la composition de ce portefeuille
|
||||
(donc de quelles actions et en quelle quantité), et de la valeur unitaire de chacune de ces actions.
|
||||
|
||||
Cette valeur unitaire est définie par les marchés financiers et varie continuellement. On considère
|
||||
que la valeur d'une action est le prix du dernier ordre d'achat passé pour cette action sur les marchés
|
||||
financiers.
|
||||
|
||||
{{< callout type="example" >}}
|
||||
Considérons la valeur "MSCI World" (un ETF trackant les valeurs des plus grosses entreprises dans
|
||||
le monde entier). Au 10 décembre, les marchés financiers s'ouvrent avec la valeur de 27.13€ par part
|
||||
pour cette valeur. à 9h05, une personne passe un ordre d'achat de 15 actions au prix de 27.17€. Dès que
|
||||
cet ordre est exécuté, on considère que l'action "MSCI World" a la valeur de 27.17€ par action.
|
||||
{{< /callout >}}
|
||||
|
||||
Dans mon cas, je n'ai absolument pas besoin d'une mise à jour rapide des valeurs, une mise à jour
|
||||
quotidienne est largement suffisante pour mes besoins, et une mise à jour hebdomadaire pourrait même
|
||||
convenir à vrai dire.
|
||||
|
||||
J'ai choisi donc de considérer pour chaque action, sa valeur au moment de la cloture des marchés (17h).
|
||||
Mais comment calculer cette valeur ?
|
||||
|
||||
### Obtenir la valeur d'une action
|
||||
|
||||
#### À la recherche d'une API
|
||||
|
||||
Pour le reste de cet article, je considèrerai l'action "MSCI Monde" en titre d'exemple. Avec un navigateur,
|
||||
on arrive facilement à obtenir les informations que l'on veut :
|
||||
- La bourse européenne (Euronext) nous donne accès à son [cours](https://live.euronext.com/fr/product/etfs/fr0011869353-xpar/lyxor-msci-wor-pea/ewld)
|
||||
- On obtient facilement les mêmes informations sur [Google Finance](https://www.google.com/finance/quote/EWLD:EPA?hl=fr) ou [Yahoo Finance](https://fr.finance.yahoo.com/quote/EWLD.PA/profile/?guccounter=1)
|
||||
|
||||
Cependant, il s'agit d'information destinées à un visionnage humain, aucune API n'est publiquement
|
||||
disponible pour obtenir ces informations de façon automatisée.
|
||||
|
||||
En faisant des recherches sur le sujet, je me heurte rapidement à deux problèmes :
|
||||
- soit je trouve des services gratuits, mais limités, soit en quantité d'appels (5 appels par jour par exemple)
|
||||
soit en fonctionnalités (pas d'ETF)
|
||||
- soit je trouve des services... chers. De l'ordre d'au moins plusieurs dizaines d'euros par mois, ce
|
||||
qui est complètement en dehors du cadre d'un projet de ce type.
|
||||
|
||||
De plus, la majorité des API disponibles sont basées sur les marchés américains. Étant dans des marchés
|
||||
européens, il est encore plus difficile d'obtenir les informations voulues.
|
||||
|
||||
#### Scraping d'Euronext
|
||||
|
||||
Finalement, la solution la plus simple revient à faire du scraping d'un site pertinent (cela expose
|
||||
aux protections anti-bot, mais je n'en ai pas vu sur le site d'Euronext que j'ai décidé d'utiliser,
|
||||
et le volume de requêtes est en pratique très faible).
|
||||
|
||||
Mon point de départ est la fiche d'une action: prenons notre habituel [MSCI world](https://live.euronext.com/fr/product/etfs/fr0011869353-xpar/lyxor-msci-wor-pea/ewld).
|
||||
|
||||
Toutes les valeurs intéressantes se trouvent sur un bandeau de la fiche de la valeur. Ici il s'agit du
|
||||
*dernier cours traité* de 27.188€.
|
||||
|
||||
![Bandeau des valeurs de l'action MSCI world sur le site Euronext](euronext.png)
|
||||
|
||||
En observant le contenu de la page web, on obtient d'ailleurs ceci :
|
||||
|
||||
```html
|
||||
<div class="col text-ui-grey-0 font-weight-bold data-header-cash ">
|
||||
<div class="lastprice_min_height pb-1 ">
|
||||
<span class="data-50 " id="header-instrument-currency">€</span>
|
||||
<span class="data-60" id="header-instrument-price">27,188</span>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
Il suffirait donc de faire un petit script de scraping accédant à l'élément possédant l'id `#header-instrument-price`
|
||||
pour obtenir directement la valeur voulue. Mais on peut faire encore mieux en observant les requêtes
|
||||
AJAX effectués depuis le navigateur.
|
||||
|
||||
Une requête en particulier est intéressante, puisqu'elle interroge la route `https://live.euronext.com/intraday_chart/getChartData/FR0011869353-XPAR/intraday`
|
||||
et renvoie un tableau JSON ayant la forme suivante (le tableau est tronqué, mais contient plus de 1000 éléments):
|
||||
|
||||
```json
|
||||
[
|
||||
{"time":"2023-12-12 17:29","price":26.879999999999999,"volume":11},
|
||||
{"time":"2023-12-13 09:04","price":26.974,"volume":1888},
|
||||
...
|
||||
{"time":"2023-12-19 17:35","price":27.187999999999999,"volume":32}
|
||||
]
|
||||
```
|
||||
|
||||
Chaque ligne du tableau contient un ordre d'achat passé au cours de la journée. La valeur intéressante
|
||||
correspond à la dernière ligne de ce tableau : il s'agit du dernier ordre passé, et correspond donc
|
||||
exactement à la valeur affichée sur la fiche de la valeur, et est donc la valeur que l'on souhaite
|
||||
obtenir.
|
||||
|
||||
À partir de là, la fonction Python suivante est suffisante pour obtenir la valeur d'une action donnée
|
||||
|
||||
```python
|
||||
EURONEXT_BASE_URL = "https://live.euronext.com/intraday_chart/getChartData"
|
||||
|
||||
|
||||
class StoredAction(NamedTuple):
|
||||
name: str
|
||||
value: float
|
||||
date: str
|
||||
|
||||
|
||||
class Action(NamedTuple):
|
||||
name: str
|
||||
code: str
|
||||
|
||||
|
||||
def get_last_value_for_action(action: Action) -> StoredAction:
|
||||
url = f"{EURONEXT_BASE_URL}/{action.code}/intraday"
|
||||
resp = requests.get(url)
|
||||
resp_json = resp.json()
|
||||
if len(resp_json) < 1:
|
||||
raise ValueError("Empty list of values")
|
||||
last_value = resp_json[-1]
|
||||
try:
|
||||
return StoredAction(
|
||||
name=action.name,
|
||||
value=last_value["price"],
|
||||
date=last_value["time"],
|
||||
)
|
||||
except KeyError as exc:
|
||||
raise ValueError("Invalid format for response") from exc
|
||||
```
|
||||
|
||||
Il suffit de trouver le **code** associé à l'action voulue. Je le trouve à partir de l'URL de la fiche
|
||||
produit de l'action cherchée. Dans notre exemple, il s'agit de `https://live.euronext.com/fr/product/etfs/fr0011869353-xpar/lyxor-msci-wor-pea/ewld`.
|
||||
Le code est donc `fr0011869353-xpar`.
|
||||
|
||||
{{< callout type="warning" >}}
|
||||
De façon surprenante, la deuxième partie du code est sensible à la casse et doit être en majuscules :
|
||||
- `fr0011869353-xpar` ne renverra aucun résultat
|
||||
- `fr0011869353-XPAR` renverra le résultat attendu
|
||||
- `FR0011869353-XPAR` renverra aussi le résultat attendu
|
||||
- `FR0011869353-xpar` ne renverra aucun résultat
|
||||
{{< /callout >}}
|
||||
|
||||
À partir de ces éléments, il m'est donc possible de calculer la valeur de mon portefeuille à partir
|
||||
de son contenu. Il me reste deux questions à résoudre :
|
||||
- comment stocker le contenu de ce portefeuille, et le mettre à jour à chaque fois que j'achète une
|
||||
action ? (ou vend)
|
||||
- comment mettre à jour la valeur totale de mon portefeuille telle qu'elle apparaît sur FireflyIII ?
|
||||
|
||||
## Lien avec Firefly
|
|
@ -1,9 +0,0 @@
|
|||
<h{{ .Level }} id="{{ .Anchor | safeURL }}">
|
||||
<a class="hash-link nohover" href="#{{ .Anchor | safeURL }}">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="red" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<title>Link to this section</title>
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path>
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path>
|
||||
</svg>
|
||||
</a> {{ .Text | safeHTML }}
|
||||
</h{{ .Level }}>
|
|
@ -1,13 +0,0 @@
|
|||
.hash-link {
|
||||
/* -webkit-transform: translateX(-100%); */
|
||||
/* left: 0;
|
||||
opacity: 0;
|
||||
padding: 0 5px;
|
||||
position: absolute;
|
||||
transform: translateX(-100%);
|
||||
transition: opacity .1s ease-in;
|
||||
will-change: transform, opacity; */
|
||||
svg {
|
||||
stroke: rgb(138, 173, 244);
|
||||
}
|
||||
}
|
File diff suppressed because one or more lines are too long
Before Width: | Height: | Size: 149 KiB |
File diff suppressed because one or more lines are too long
Before Width: | Height: | Size: 159 KiB |
Binary file not shown.
Before Width: | Height: | Size: 51 KiB |
File diff suppressed because one or more lines are too long
Before Width: | Height: | Size: 43 KiB |
|
@ -41,6 +41,7 @@ This theme was highly inspired by the [hello-friend](https://github.com/panr/hug
|
|||
- Support for sharing buttons
|
||||
- Support for [Commento](https://commento.io)
|
||||
- Support for [Plausible](https://plausible.io) (thanks to [@Joffcom](https://github.com/Joffcom))
|
||||
- Support for [utterances](https://utteranc.es/) comment system
|
||||
|
||||
## How to start
|
||||
|
||||
|
@ -102,11 +103,13 @@ paginate = 10
|
|||
[languages]
|
||||
[languages.en]
|
||||
title = "Hello Friend NG"
|
||||
subtitle = "A simple theme for Hugo"
|
||||
keywords = ""
|
||||
copyright = '<a href="https://creativecommons.org/licenses/by-nc/4.0/" target="_blank" rel="noopener">CC BY-NC 4.0</a>'
|
||||
readOtherPosts = "Read other posts"
|
||||
|
||||
[languages.en.params]
|
||||
subtitle = "A simple theme for Hugo"
|
||||
|
||||
[languages.en.params.logo]
|
||||
logoText = "hello friend ng"
|
||||
logoHomeLink = "/"
|
||||
|
|
|
@ -1,50 +1,50 @@
|
|||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-family: "Inter";
|
||||
font-style: normal;
|
||||
font-display: auto;
|
||||
font-weight: 400;
|
||||
src: url("../fonts/Inter-Regular.woff2") format("woff2"),
|
||||
url("../fonts/Inter-Regular.woff") format("woff");
|
||||
src: url("fonts/Inter-Regular.woff2") format("woff2"),
|
||||
url("fonts/Inter-Regular.woff") format("woff");
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-family: "Inter";
|
||||
font-style: italic;
|
||||
font-display: auto;
|
||||
font-weight: 400;
|
||||
src: url("../fonts/Inter-Italic.woff2") format("woff2"),
|
||||
url("../fonts/Inter-Italic.woff") format("woff");
|
||||
src: url("fonts/Inter-Italic.woff2") format("woff2"),
|
||||
url("fonts/Inter-Italic.woff") format("woff");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-family: "Inter";
|
||||
font-style: normal;
|
||||
font-display: auto;
|
||||
font-weight: 600;
|
||||
src: url("../fonts/Inter-Medium.woff2") format("woff2"),
|
||||
url("../fonts/Inter-Medium.woff") format("woff");
|
||||
src: url("fonts/Inter-Medium.woff2") format("woff2"),
|
||||
url("fonts/Inter-Medium.woff") format("woff");
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-family: "Inter";
|
||||
font-style: italic;
|
||||
font-display: auto;
|
||||
font-weight: 600;
|
||||
src: url("../fonts/Inter-MediumItalic.woff2") format("woff2"),
|
||||
url("../fonts/Inter-MediumItalic.woff") format("woff");
|
||||
src: url("fonts/Inter-MediumItalic.woff2") format("woff2"),
|
||||
url("fonts/Inter-MediumItalic.woff") format("woff");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-family: "Inter";
|
||||
font-style: normal;
|
||||
font-display: auto;
|
||||
font-weight: 800;
|
||||
src: url("../fonts/Inter-Bold.woff2") format("woff2"),
|
||||
url("../fonts/Inter-Bold.woff") format("woff");
|
||||
src: url("fonts/Inter-Bold.woff2") format("woff2"),
|
||||
url("fonts/Inter-Bold.woff") format("woff");
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-family: "Inter";
|
||||
font-style: italic;
|
||||
font-display: auto;
|
||||
font-weight: 800;
|
||||
src: url("../fonts/Inter-BoldItalic.woff2") format("woff2"),
|
||||
url("../fonts/Inter-BoldItalic.woff") format("woff");
|
||||
src: url("fonts/Inter-BoldItalic.woff2") format("woff2"),
|
||||
url("fonts/Inter-BoldItalic.woff") format("woff");
|
||||
}
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
da: dk
|
||||
de: de
|
||||
en: gb
|
||||
es: es
|
||||
|
|
|
@ -10,6 +10,7 @@
|
|||
- [codechef](https://simpleicons.org/?q=codechef)
|
||||
- [codepen](https://simpleicons.org/?q=codepen)
|
||||
- [cs:go](https://simpleicons.org/?q=counterstrike)
|
||||
- [cyberdefenders](https://simpleicons.org/?q=cyberdefenders)
|
||||
- dev
|
||||
- [deviantart](https://simpleicons.org/?q=deviantart)
|
||||
- [discogs](https://simpleicons.org/?q=discogs)
|
||||
|
@ -58,6 +59,7 @@
|
|||
- [peertube](https://simpleicons.org/?q=peertube)
|
||||
- [pinterest](https://simpleicons.org/?q=pinterest)
|
||||
- [pixelfed](https://github.com/pixelfed/pixelfed/blob/dev/public/img/pixelfed-icon-black.svg)
|
||||
- [pleroma](https://simpleicons.org/?q=pleroma)
|
||||
- [podcasts-apple](https://simpleicons.org/?q=podcast)
|
||||
- [podcasts-google](https://simpleicons.org/?q=podcast)
|
||||
- [polywork](https://simpleicons.org/?q=polywork)
|
||||
|
@ -67,6 +69,7 @@
|
|||
- [signal](https://simpleicons.org/?q=signal)
|
||||
- [slack](https://simpleicons.org/?q=slack)
|
||||
- [soundcloud](https://simpleicons.org/?q=soundcloud)
|
||||
- [sourcehut](https://simpleicons.org/?q=sourcehut)
|
||||
- [spotify](https://simpleicons.org/?q=spotify)
|
||||
- [stackoverflow](https://simpleicons.org/?q=stackoverflow)
|
||||
- [steam](https://simpleicons.org/?q=Steam)
|
||||
|
|
|
@ -36,9 +36,6 @@ disableHugoGeneratorInject = false
|
|||
[permalinks]
|
||||
posts = "/posts/:year/:month/:title/"
|
||||
|
||||
[author]
|
||||
name = "Jane Doe"
|
||||
|
||||
[blackfriday]
|
||||
hrefTargetBlank = true
|
||||
|
||||
|
@ -48,6 +45,9 @@ disableHugoGeneratorInject = false
|
|||
series = "series"
|
||||
|
||||
[params]
|
||||
[params.author]
|
||||
name = "Jane Doe"
|
||||
|
||||
dateform = "Jan 2, 2006"
|
||||
dateformShort = "Jan 2"
|
||||
dateformNum = "2006-01-02"
|
||||
|
@ -78,7 +78,7 @@ disableHugoGeneratorInject = false
|
|||
#
|
||||
# This options enables the theme toggle for the theme.
|
||||
# Per default, this option is off.
|
||||
# The theme is respecting the prefers-color-scheme of the operating systeme.
|
||||
# The theme is respecting the prefers-color-scheme of the operating system.
|
||||
# With this option on, the page user is able to set the scheme he wants.
|
||||
enableThemeToggle = false
|
||||
|
||||
|
@ -114,7 +114,7 @@ disableHugoGeneratorInject = false
|
|||
# If you want, you can easily override the default footer with your own content.
|
||||
#
|
||||
[params.footer]
|
||||
trademark = true
|
||||
trademark = 2023
|
||||
rss = true
|
||||
copyright = true
|
||||
author = true
|
||||
|
@ -145,6 +145,19 @@ disableHugoGeneratorInject = false
|
|||
# Append the current url pathname to logoText
|
||||
# logoCursorPathname = true
|
||||
|
||||
# # # # # # # # # # # # # # # # # # # # # # # # # # # # #
|
||||
# Addon: Startpage portrait
|
||||
# # # # # # # # # # # # # # # # # # # # # # # # # # # # #
|
||||
# Uncomment this if you want a portrait on your start page
|
||||
#
|
||||
# [params.portrait]
|
||||
# path = "/img/image.jpg"
|
||||
# alt = "Portrait"
|
||||
# maxWidth = "50px"
|
||||
|
||||
# # # # # # # # # # # # # # # # # # # # # # # # # # # # #
|
||||
# Addon: Commento
|
||||
# # # # # # # # # # # # # # # # # # # # # # # # # # # # #
|
||||
# Commento is more than just a comments widget you can embed —
|
||||
# it’s a return to the roots of the internet.
|
||||
# An internet without the tracking and invasions of privacy.
|
||||
|
@ -156,14 +169,50 @@ disableHugoGeneratorInject = false
|
|||
# [params.commento]
|
||||
# url = ""
|
||||
|
||||
# Uncomment this if you want a portrait on your start page
|
||||
# # # # # # # # # # # # # # # # # # # # # # # # # # # # #
|
||||
# Addon: Utteranc.es
|
||||
# # # # # # # # # # # # # # # # # # # # # # # # # # # # #
|
||||
# Utteranc.es is a lightweight comments widget built on GitHub issues.
|
||||
# See https://utteranc.es/ for documentation
|
||||
# Uncomment this to enable Utteranc.es.
|
||||
#
|
||||
# [params.utterances]
|
||||
# Your GitHub repository
|
||||
#
|
||||
# repository = ""
|
||||
#
|
||||
# label used for GitHub issues
|
||||
#
|
||||
# label = ""
|
||||
#
|
||||
# Blog Post <-> Issue mapping. This parameter is optional. Possible values are:
|
||||
# - pathname
|
||||
# - url
|
||||
# - title
|
||||
# - og:title
|
||||
# - issue number
|
||||
# - specific term
|
||||
#
|
||||
# issueTerm = ""
|
||||
#
|
||||
# Theme used: possible values are:
|
||||
# - github-light
|
||||
# - github-dark
|
||||
# - preferred-color-scheme
|
||||
# - github-dark-orange
|
||||
# - icy-dark
|
||||
# - dark-blue
|
||||
# - photon-dark
|
||||
# - boxy-light
|
||||
# - gruvbox-dark
|
||||
#
|
||||
# theme = ""
|
||||
#
|
||||
# [params.portrait]
|
||||
# path = "/img/image.jpg"
|
||||
# alt = "Portrait"
|
||||
# maxWidth = "50px"
|
||||
|
||||
# Social icons
|
||||
# # # # # # # # # # # # # # # # # # # # # # # # # # # # #
|
||||
# Addon: Social icons
|
||||
# # # # # # # # # # # # # # # # # # # # # # # # # # # # #
|
||||
#
|
||||
[[params.social]]
|
||||
name = "twitter"
|
||||
url = "https://twitter.com/"
|
||||
|
@ -186,14 +235,16 @@ disableHugoGeneratorInject = false
|
|||
|
||||
[languages]
|
||||
[languages.en]
|
||||
subtitle = "Hello Friend NG Theme"
|
||||
weight = 1
|
||||
copyright = '<a href="https://creativecommons.org/licenses/by-nc/4.0/" target="_blank" rel="noopener">CC BY-NC 4.0</a>'
|
||||
[languages.en.params]
|
||||
subtitle = "Hello Friend NG Theme"
|
||||
|
||||
[languages.fr]
|
||||
subtitle = "Hello Friend NG Theme"
|
||||
weight = 2
|
||||
copyright = '<a href="https://creativecommons.org/licenses/by-nc/4.0/" target="_blank" rel="noopener">CC BY-NC 4.0</a>'
|
||||
[languages.fr.params]
|
||||
subtitle = "Hello Friend NG Theme"
|
||||
|
||||
[menu]
|
||||
[[menu.main]]
|
||||
|
|
39
themes/hello-friend-ng/i18n/da.toml
Normal file
39
themes/hello-friend-ng/i18n/da.toml
Normal file
|
@ -0,0 +1,39 @@
|
|||
# Translations for Danish
|
||||
# https://gohugo.io/content-management/multilingual/#translation-of-strings
|
||||
|
||||
# Generic
|
||||
#
|
||||
[translations]
|
||||
other = "Oversættelser"
|
||||
|
||||
[postAvailable]
|
||||
other = "Også tilgængelig på"
|
||||
|
||||
|
||||
# 404.html
|
||||
#
|
||||
[archives]
|
||||
other = "Arkiver"
|
||||
|
||||
[home]
|
||||
other = "Hjem"
|
||||
|
||||
[notFound]
|
||||
other = "Ups, siden blev ikke fundet…"
|
||||
|
||||
|
||||
# posts/single.html
|
||||
#
|
||||
[readingTime]
|
||||
one = "Ét minut"
|
||||
other = "{{ .Count }} minutter"
|
||||
|
||||
[tableOfContents]
|
||||
other = "Indholdsfortegnelse"
|
||||
|
||||
[wordCount]
|
||||
one = "Ét Ord"
|
||||
other = "{{ .Count }} Ord"
|
||||
|
||||
[lastModified]
|
||||
other = "Sidst opdateret"
|
|
@ -37,3 +37,8 @@ other = "{{ .Count }} Wörter"
|
|||
|
||||
[lastModified]
|
||||
other = "Letzte Aktualisierung"
|
||||
|
||||
# partials/pagination-list.html
|
||||
[pagination]
|
||||
newerPosts = "Neuere Beiträge"
|
||||
olderPosts = "Ältere Beiträge"
|
||||
|
|
|
@ -37,3 +37,8 @@ other = "{{ .Count }} Words"
|
|||
|
||||
[lastModified]
|
||||
other = "Last updated"
|
||||
|
||||
# partials/pagination-list.html
|
||||
[pagination]
|
||||
newerPosts = "Newer posts"
|
||||
olderPosts = "Older posts"
|
||||
|
|
|
@ -37,3 +37,8 @@ other = "{{ .Count }} Palabras"
|
|||
|
||||
[lastModified]
|
||||
other = "Ultima actualización"
|
||||
|
||||
# partials/pagination-list.html
|
||||
[pagination]
|
||||
newerPosts = "Entradas más recientes"
|
||||
olderPosts = "Entradas antiguas"
|
||||
|
|
|
@ -37,3 +37,8 @@ other = "{{ .Count }} Mots"
|
|||
|
||||
[lastModified]
|
||||
other = "Mise à jour"
|
||||
|
||||
# partials/pagination-list.html
|
||||
[pagination]
|
||||
newerPosts = "Articles plus récents"
|
||||
olderPosts = "Articles plus anciens"
|
||||
|
|
|
@ -34,3 +34,8 @@ other = "Táboa de contidos"
|
|||
[wordCount]
|
||||
one = "Unha Palabra"
|
||||
other = "{{ .Count }} Palabras"
|
||||
|
||||
# partials/pagination-list.html
|
||||
[pagination]
|
||||
newerPosts = "Entradas más recientes"
|
||||
olderPosts = "Entradas antiguas"
|
||||
|
|
|
@ -37,3 +37,8 @@ other = "{{ .Count }} Kata"
|
|||
|
||||
[lastModified]
|
||||
other = "Terakhir diupdate"
|
||||
|
||||
# partials/pagination-list.html
|
||||
[pagination]
|
||||
newerPosts = "Postingan yang lebih baru"
|
||||
olderPosts = "Postingan yang lebih lama"
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# Translations for English
|
||||
# Translations for Italian
|
||||
# https://gohugo.io/content-management/multilingual/#translation-of-strings
|
||||
|
||||
# Generic
|
||||
|
@ -37,3 +37,8 @@ other = "{{ .Count }} parole"
|
|||
|
||||
[lastModified]
|
||||
other = "Ultimo aggiornamento"
|
||||
|
||||
# partials/pagination-list.html
|
||||
[pagination]
|
||||
newerPosts = "Voci più recenti"
|
||||
olderPosts = "Voci più vecchie"
|
||||
|
|
|
@ -37,3 +37,8 @@ other = "{{ .Count }}文字"
|
|||
|
||||
[lastModified]
|
||||
other = "最終更新"
|
||||
|
||||
# partials/pagination-list.html
|
||||
[pagination]
|
||||
newerPosts = "新しいエントリー"
|
||||
olderPosts = "古いエントリー"
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# Translations for English
|
||||
# Translations for Lombardian
|
||||
# https://gohugo.io/content-management/multilingual/#translation-of-strings
|
||||
|
||||
# Generic
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# Translations for Portuguese
|
||||
# Translations for Portuguese (Brasilian)
|
||||
# https://gohugo.io/content-management/multilingual/#translation-of-strings
|
||||
|
||||
# Generic
|
||||
|
@ -37,3 +37,8 @@ other = "{{ .Count }} Palavras"
|
|||
|
||||
[lastModified]
|
||||
other = "Última actualização"
|
||||
|
||||
# partials/pagination-list.html
|
||||
[pagination]
|
||||
newerPosts = "Entradas mais recentes"
|
||||
olderPosts = "Entradas mais antigas"
|
||||
|
|
|
@ -37,3 +37,8 @@ other = "{{ .Count }} de cuvinte"
|
|||
|
||||
[lastModified]
|
||||
other = "Ultima modificare"
|
||||
|
||||
# partials/pagination-list.html
|
||||
[pagination]
|
||||
newerPosts = "Intrări mai noi"
|
||||
olderPosts = "Intrări mai vechi"
|
||||
|
|
|
@ -41,3 +41,8 @@ other = "{{ .Count }} слов"
|
|||
|
||||
[lastModified]
|
||||
other = "Последнее обновление"
|
||||
|
||||
# partials/pagination-list.html
|
||||
[pagination]
|
||||
newerPosts = "Более новые записи"
|
||||
olderPosts = "Старые записи"
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# Translations for English
|
||||
# Translations for Turkish
|
||||
# https://gohugo.io/content-management/multilingual/#translation-of-strings
|
||||
|
||||
# Generic
|
||||
|
@ -37,3 +37,8 @@ other = "{{ .Count }} Kelime"
|
|||
|
||||
[lastModified]
|
||||
other = "Son güncelleme"
|
||||
|
||||
# partials/pagination-list.html
|
||||
[pagination]
|
||||
newerPosts = "Daha yeni girişler"
|
||||
olderPosts = "Eski girişler"
|
||||
|
|
|
@ -41,3 +41,8 @@ other = "{{ .Count }} слів"
|
|||
|
||||
[lastModified]
|
||||
other = "Останнє оновлення"
|
||||
|
||||
# partials/pagination-list.html
|
||||
[pagination]
|
||||
newerPosts = "Нові записи"
|
||||
olderPosts = "Старіші записи"
|
||||
|
|
|
@ -37,3 +37,8 @@ other = "{{ .Count }}字"
|
|||
|
||||
[lastModified]
|
||||
other = "最后修改"
|
||||
|
||||
# partials/pagination-list.html
|
||||
[pagination]
|
||||
newerPosts = "较新条目"
|
||||
olderPosts = "旧条目"
|
||||
|
|
|
@ -1,14 +1,10 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="{{ .Site.Language }}">
|
||||
|
||||
<head>
|
||||
{{ partial "head.html" . }}
|
||||
<link rel="stylesheet" href="/mermaid.css">
|
||||
<link rel="stylesheet" href="/css/heading.css">
|
||||
</head>
|
||||
|
||||
{{ block "body" . }}
|
||||
|
||||
<body>
|
||||
{{ end }}
|
||||
|
||||
|
@ -26,5 +22,4 @@
|
|||
|
||||
{{ partial "javascript.html" . }}
|
||||
</body>
|
||||
|
||||
</html>
|
|
@ -2,7 +2,7 @@
|
|||
{{if or (.Site.Params.footer.trademark) (.Site.Params.footer.author) (.Site.Params.footer.copyright) (.Site.Params.footer.rss) (.Site.Params.footer.topText) }}
|
||||
<div class="footer__inner">
|
||||
<div class="footer__content">
|
||||
{{ if .Site.Params.footer.trademark }}<span>© {{ now.Format "2006" }}</span>{{ end }}
|
||||
{{ if .Site.Params.footer.trademark }}<span>© {{ .Site.Params.footer.trademark }}</span>{{ end }}
|
||||
{{ if .Site.Params.footer.author }}<span><a href="{{ .Site.BaseURL }}">{{ .Site.Author.name }}</a></span>{{ end }}
|
||||
{{ if .Site.Params.footer.copyright }}<span>{{ .Site.Copyright| safeHTML }}</span>{{ end }}
|
||||
{{ if .Site.Params.footer.rss }}<span><a href="{{ "posts/index.xml" | absLangURL }}" target="_blank" title="rss"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-rss"><path d="M4 11a9 9 0 0 1 9 9"></path><path d="M4 4a16 16 0 0 1 16 16"></path><circle cx="5" cy="19" r="1"></circle></svg></a></span>{{ end }}
|
||||
|
|
|
@ -9,3 +9,29 @@
|
|||
<script src="{{ $val }}"></script>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
|
||||
{{ if .Page.Store.Get "hasMermaid" }}
|
||||
<script type="module">
|
||||
import mermaid from "https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.esm.min.mjs";
|
||||
const settings = localStorage.getItem("theme") === "dark" ?
|
||||
{
|
||||
startOnLoad: true,
|
||||
theme: "dark",
|
||||
darkMode: true,
|
||||
themeVariables: {
|
||||
tertiaryColor: "#dee3ed"
|
||||
}
|
||||
}
|
||||
:
|
||||
{
|
||||
startOnLoad: true,
|
||||
theme: "base",
|
||||
darkMode: false,
|
||||
themeVariables: {
|
||||
tertiaryColor: "#dee3ed"
|
||||
}
|
||||
}
|
||||
;
|
||||
mermaid.initialize(settings);
|
||||
</script>
|
||||
{{ end }}
|
|
@ -2,7 +2,7 @@
|
|||
<ul class="menu__inner">
|
||||
{{- $currentPage := . -}}
|
||||
{{ range .Site.Menus.main -}}
|
||||
<li><a href="{{ .URL | relLangURL }}">{{ or (T .Identifier) .Name }}</a></li>
|
||||
<li><a href="{{ .URL | relLangURL }}">{{ .Name }}</a></li>
|
||||
{{- end }}
|
||||
|
||||
{{- if .Site.Params.EnableGlobalLanguageMenu }}
|
||||
|
|
|
@ -4,14 +4,14 @@
|
|||
<span class="button previous">
|
||||
<a href="{{ .Paginator.Prev.URL }}">
|
||||
<span class="button__icon">←</span>
|
||||
<span class="button__text">Newer posts</span>
|
||||
<span class="button__text">{{ i18n "pagination.newerPosts" }}</span>
|
||||
</a>
|
||||
</span>
|
||||
{{ end }}
|
||||
{{ if .Paginator.HasNext }}
|
||||
<span class="button next">
|
||||
<a href="{{ .Paginator.Next.URL }}">
|
||||
<span class="button__text">Older posts</span>
|
||||
<span class="button__text">{{ i18n "pagination.olderPosts" }}</span>
|
||||
<span class="button__icon">→</span>
|
||||
</a>
|
||||
</span>
|
||||
|
|
|
@ -22,8 +22,10 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 22 8.5 22 15.5 12 22 2 15.5 2 8.5 12 2"></polygon><line x1="12" y1="22" x2="12" y2="15.5"></line><polyline points="22 8.5 12 15.5 2 8.5"></polyline><polyline points="2 15.5 12 8.5 22 15.5"></polyline><line x1="12" y1="2" x2="12" y2="8.5"></line></svg>
|
||||
{{- else if (eq .name "csgo") -}}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor" stroke="none"><path d="M9.103.435c.4347-.3913 1.087-.5362 1.6522-.3623.2174.0725.4058.203.6087.3333.1595.1015.3479.145.4928.261.0725.058.0145.1594.0145.2318.1884.4493.2899.9421.1305 1.4204-.1305.1594-.3624.203-.5508.2754-.029.2029.0435.3913.0725.5942-.0435.029-.0725.058-.116.087.2754-.0145.5508-.0725.8262-.1304.1014-.1015.2608-.0435.3913-.058.0145-.203.087-.3914.087-.5943.029 0 .087-.0145.1159-.029.0145.145 0 .29.0435.4349.0724.058.1884.029.2754.0434 0 .058 0 .116.0144.174 1.6813-.0145 3.377 0 5.0583 0v.2464h.1595v-.9421h.1884c0 .2609-.0145.5072 0 .7681a.1107.1107 0 0 0 .0725.029c0 .029.0144.087.0144.116.058-.058.1305-.1015.2174-.0725.0145.0435.029.087.0435.145-.058.058-.087.1304-.058.2173.4639.0145.9277 0 1.406 0 .0434-.058.1159-.087.1884-.116.029.0146.0724.0436.087.058h.6811c.029.116.029.232.0145.3334h-.6957c-.0145.0145-.058.058-.087.0725-.0724-.0435-.1304-.0725-.2029-.116h-1.2609c-.2464.0725-.5073.058-.7537.0145v.2754h-2.0726c-.087.0725-.1739.116-.2898.1305.0434.2174-.203.2753-.29.4348-.0579.087-.1448.1449-.2318.1739-.0725.4493.087.8696.203 1.29-.1305.029-.2755.0724-.406.1014-.0724.2899-.1449.5942-.2028.884-.058.261-.261.4784-.5073.5798-.174.203-.4058.4059-.6812.4204-.1015.029-.174-.0435-.2464-.1015-.3623.029-.6957-.145-1.0146-.2899-.3478-.1594-.6667-.3623-1-.5507.029.2029-.0725.3768-.145.5507.1595.0725.3769.1305.4638.3044.058.1304.116.2754.116.4348-.0145.5218-.0725 1.0435-.1015 1.5653.0145.3769-.1739.7537-.4348 1.029-.1739-.0144-.3188-.0869-.4783-.1594-.058.1305-.1884.261-.116.4204.058.1884.058.3913.145.5652.4928.5218.9131 1.1015 1.2175 1.7537.3043.6233.5362 1.2755.7826 1.9277.0435 0 .1305-.0145.174-.0145.058.1884 0 .4058.116.5798.1014.1594.0724.3478.0724.5362-.029.4348-.058.8696-.1015 1.3044-.029.3044-.1014.6088-.1449.9132.0145.2318.116.4637.1014.6956-.0144.2175-.0144.4493-.1884.6088.0145.4928-.116.9855.058 1.4638.232.3189.4928.6233.7682.8986.3043.145.6667.174.9276.4349.1014.1594.0434.3478.0145.5217a6.7323 6.7323 0 0 1-1.8697 0c-.2464-.058-.4783-.1594-.7247-.1884-.3334.0145-.7247.145-1.029-.087-.029-.3913.1159-.7681.1884-1.145.029-.1304.1594-.2174.1449-.3478-.029-.4493-.058-.9131-.087-1.3624-.058-.029-.1594-.058-.1449-.145 0-.2173-.0725-.4347-.1304-.6377-.1015-.5507-.145-1.116-.1595-1.6812-.0145-.1595.087-.2754.203-.3769.029-.2464.058-.5072.0724-.7536-.0435-.1305-.145-.232-.203-.3479-.2608.029-.6376.087-.797-.1884-.3769-.5653-.7682-1.145-1.145-1.7102-.1595-.0145-.3479 0-.4928-.1015-.1595-.174-.261-.4058-.3624-.6232-.0435.1739-.0725.3623-.174.5072-.0869.145-.2318.2464-.3333.3769-.1014.2319-.1884.4638-.2753.6957-.1015.2898-.2464.5797-.2754.8986-.0145.1594-.0435.3044-.1015.4493-.0724.116-.2029.1594-.3188.2174-.087.1884-.145.3768-.2754.5363-.087.1014-.232.1304-.2899.2608-.058.174-.145.3334-.2174.4928-.029.174.087.3624.029.5363-.1015.4348-.3189.8406-.5218 1.232-.1014.2898-.1739.5942-.3188.8696-.058.116-.203.145-.3189.1594-.1304.3189-.2754.6232-.3623.9566-.0435.3188-.0435.6522-.029.971 0 .145.087.261.145.3914.0579.174.0144.3478-.0146.5218-.5652.0724-1.145.1304-1.6957-.0435-.058-.0435-.0435-.116-.058-.174-.0435-.2608-.0725-.5362.0145-.7826.1884-.6812.3478-1.3624.5362-2.0436-.0724-.0725-.1739-.1304-.1739-.2464-.0145-.1884 0-.3913.0435-.5797.087-.319.3189-.5653.4348-.8697.0435-.1304.029-.2609.0435-.3913 0-.3044.174-.5508.3044-.8116.1304-.2174.2318-.4493.4058-.6378.116-.1014.116-.2608.2029-.3913.087-.1594.2319-.2899.2319-.4783.029-.2319-.058-.4638-.029-.6957.058-.6812.1884-1.3479.3044-2.029-.058-.0726-.145-.145-.174-.2465.0145-.0724.029-.1304.0435-.2029l-.1304-.2174c.058-.087.116-.1884.174-.2754-.058-.0435-.1305-.1014-.1885-.145.0725-.2173.0435-.5362.3043-.6376.029.0145.1015.029.1305.0434-.0435-.3768-.0435-.7681-.087-1.145-.1014-.4058-.116-.826-.0724-1.232.1449-.2173.4203-.3043.6667-.3188-.3189-.0724-.6378-.1014-.9421-.2029-.0145-.2609.029-.5218.0725-.7826.1304-.5073.0724-1.029.1449-1.5509.0725-.1449.2609-.1739.4203-.1449.1884.029.3768-.029.5653-.087 0-.0724.0145-.1594 0-.2319-.116-.5072-.087-1.029 0-1.5218.116-.6377.3768-1.261.855-1.7102.319-.3044.7827-.4494 1.2176-.4349.1449 0 .2318.145.3478.232.058-.058.116-.116.1594-.174-.0724-.2464-.1884-.5073-.1739-.7681.029-.5798.2174-1.174.6522-1.5654m4.522 4.1017c.029.029.029.029 0 0m.203.029c.0144.1015.0434.203-.0145.2899-.0725.029-.1595.029-.232.0725.203 0 .4059.0145.6088 0 .1594-.0435.1015-.2464.1015-.3624-.1015-.116-.3189-.0435-.4638 0m-.5073.6088c.145.1594.2174.4058.3478.5652.1884-.2464.3334-.5073.5508-.7247-.2609-.0145-.5218.0145-.7827-.0145-.0435.058-.0724.116-.116.174Z"/></svg>
|
||||
{{- else if (eq .name "cyberdefenders") -}}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor" stroke="none"><path d="M18.918 17.48c-.126 2.727-2.384 4.696-5.364 4.696H7.34v-6.123l-2.185-.957V24h8.381c4.334 0 7.549-2.962 7.549-6.881v-.163c-.65.235-1.372.415-2.167.524Zm1.355-9.501C18.611 4.313 17.726.989 15.432.213c-1.336-.452-2.005-.091-2.637.217-.199.09-.235.361-.072.505.361.307.813.687 1.336 1.174-1.95-1.138-7.333-2.835-7.874-.776-.488 1.86-1.319 4.587-1.319 4.587S.603 5.487.116 7.293c-.488 1.806 3.323 5.274 9.627 7.134 6.303 1.861 11.198 1.373 13.311-.921 2.113-2.294.072-5.473-2.781-5.527Zm-1.247.036c-.487.47-2.077 1.68-5.563 1.427-3.738-.271-6.809-2.474-7.604-3.088-.126-.091-.18-.235-.126-.398.054-.18.126-.469.253-.849.072-.234.343-.343.542-.216 1.571.903 4.1 2.221 6.791 2.402 2.402.163 3.847-.542 4.786-1.066.199-.108.452-.018.542.199l.47 1.156c.036.162.018.325-.091.433Z"/></svg>
|
||||
{{- else if (eq .name "dev") -}}
|
||||
<svg role="img" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 448 512"><path fill="currentColor" d="M120.12 208.29c-3.88-2.9-7.77-4.35-11.65-4.35H91.03v104.47h17.45c3.88 0 7.77-1.45 11.65-4.35 3.88-2.9 5.82-7.25 5.82-13.06v-69.65c-.01-5.8-1.96-10.16-5.83-13.06zM404.1 32H43.9C19.7 32 .06 51.59 0 75.8v360.4C.06 460.41 19.7 480 43.9 480h360.2c24.21 0 43.84-19.59 43.9-43.8V75.8c-.06-24.21-19.7-43.8-43.9-43.8zM154.2 291.19c0 18.81-11.61 47.31-48.36 47.25h-46.4V172.98h47.38c35.44 0 47.36 28.46 47.37 47.28l.01 70.93zm100.68-88.66H201.6v38.42h32.57v29.57H201.6v38.41h53.29v29.57h-62.18c-11.16.29-20.44-8.53-20.72-19.69V193.7c-.27-11.15 8.56-20.41 19.71-20.69h63.19l-.01 29.52zm103.64 115.29c-13.2 30.75-36.85 24.63-47.44 0l-38.53-144.8h32.57l29.71 113.72 29.57-113.72h32.58l-38.46 144.8z"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 448 512" fill="currentColor" stroke ="none"><path d="M120.12 208.29c-3.88-2.9-7.77-4.35-11.65-4.35H91.03v104.47h17.45c3.88 0 7.77-1.45 11.65-4.35 3.88-2.9 5.82-7.25 5.82-13.06v-69.65c-.01-5.8-1.96-10.16-5.83-13.06zM404.1 32H43.9C19.7 32 .06 51.59 0 75.8v360.4C.06 460.41 19.7 480 43.9 480h360.2c24.21 0 43.84-19.59 43.9-43.8V75.8c-.06-24.21-19.7-43.8-43.9-43.8zM154.2 291.19c0 18.81-11.61 47.31-48.36 47.25h-46.4V172.98h47.38c35.44 0 47.36 28.46 47.37 47.28l.01 70.93zm100.68-88.66H201.6v38.42h32.57v29.57H201.6v38.41h53.29v29.57h-62.18c-11.16.29-20.44-8.53-20.72-19.69V193.7c-.27-11.15 8.56-20.41 19.71-20.69h63.19l-.01 29.52zm103.64 115.29c-13.2 30.75-36.85 24.63-47.44 0l-38.53-144.8h32.57l29.71 113.72 29.57-113.72h32.58l-38.46 144.8z"/></svg>
|
||||
{{- else if (eq .name "deviantart") -}}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor" stroke="none"><path d="M19.207 4.794l.23-.43V0H15.07l-.436.44-2.058 3.925-.646.436H4.58v5.993h4.04l.36.436-4.175 7.98-.24.43V24H8.93l.436-.44 2.07-3.925.644-.436h7.35v-5.993h-4.05l-.36-.438 4.186-7.977z"/></svg>
|
||||
{{- else if (eq .name "discogs") -}}
|
||||
|
@ -122,6 +124,8 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor" stroke="none"><path d="M12.017 0C5.396 0 .029 5.367.029 11.987c0 5.079 3.158 9.417 7.618 11.162-.105-.949-.199-2.403.041-3.439.219-.937 1.406-5.957 1.406-5.957s-.359-.72-.359-1.781c0-1.663.967-2.911 2.168-2.911 1.024 0 1.518.769 1.518 1.688 0 1.029-.653 2.567-.992 3.992-.285 1.193.6 2.165 1.775 2.165 2.128 0 3.768-2.245 3.768-5.487 0-2.861-2.063-4.869-5.008-4.869-3.41 0-5.409 2.562-5.409 5.199 0 1.033.394 2.143.889 2.741.099.12.112.225.085.345-.09.375-.293 1.199-.334 1.363-.053.225-.172.271-.401.165-1.495-.69-2.433-2.878-2.433-4.646 0-3.776 2.748-7.252 7.92-7.252 4.158 0 7.392 2.967 7.392 6.923 0 4.135-2.607 7.462-6.233 7.462-1.214 0-2.354-.629-2.758-1.379l-.749 2.848c-.269 1.045-1.004 2.352-1.498 3.146 1.123.345 2.306.535 3.55.535 6.607 0 11.985-5.365 11.985-11.987C23.97 5.39 18.592.026 11.985.026L12.017 0z"/></svg>
|
||||
{{- else if (eq .name "pixelfed") }}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor" stroke="none"><path d="M 12 24 C 5.37258 24 0 18.6274 0 12 C 0 5.37258 5.37258 0 12 0 C 18.6274 0 24 5.37258 24 12 C 24 18.6274 18.6274 24 12 24 Z M 11.0474 14.6198 L 13.2487 14.6198 C 15.3225 14.6198 17.0036 12.9833 17.0036 10.9645 C 17.0036 8.94566 15.3225 7.30909 13.2487 7.30909 L 10.0715 7.30909 C 8.8751 7.30909 7.90523 8.25326 7.90523 9.41796 L 7.90523 17.626 Z"/></svg>
|
||||
{{- else if (eq .name "pleroma") }}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor" stroke="none"><path d="M6.36 0A1.868 1.868 0 004.49 1.868V24h5.964V0zm7.113 0v12h4.168a1.868 1.868 0 001.868-1.868V0zm0 18.036V24h4.168a1.868 1.868 0 001.868-1.868v-4.096Z"/></svg>
|
||||
{{- else if (eq .name "podcasts-apple") -}}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor" stroke="none"><path d="M5.34 0A5.328 5.328 0 000 5.34v13.32A5.328 5.328 0 005.34 24h13.32A5.328 5.328 0 0024 18.66V5.34A5.328 5.328 0 0018.66 0zm6.525 2.568c2.336 0 4.448.902 6.056 2.587 1.224 1.272 1.912 2.619 2.264 4.392.12.59.12 2.2.007 2.864a8.506 8.506 0 01-3.24 5.296c-.608.46-2.096 1.261-2.336 1.261-.088 0-.096-.091-.056-.46.072-.592.144-.715.48-.856.536-.224 1.448-.874 2.008-1.435a7.644 7.644 0 002.008-3.536c.208-.824.184-2.656-.048-3.504-.728-2.696-2.928-4.792-5.624-5.352-.784-.16-2.208-.16-3 0-2.728.56-4.984 2.76-5.672 5.528-.184.752-.184 2.584 0 3.336.456 1.832 1.64 3.512 3.192 4.512.304.2.672.408.824.472.336.144.408.264.472.856.04.36.03.464-.056.464-.056 0-.464-.176-.896-.384l-.04-.03c-2.472-1.216-4.056-3.274-4.632-6.012-.144-.706-.168-2.392-.03-3.04.36-1.74 1.048-3.1 2.192-4.304 1.648-1.737 3.768-2.656 6.128-2.656zm.134 2.81c.409.004.803.04 1.106.106 2.784.62 4.76 3.408 4.376 6.174-.152 1.114-.536 2.03-1.216 2.88-.336.43-1.152 1.15-1.296 1.15-.023 0-.048-.272-.048-.603v-.605l.416-.496c1.568-1.878 1.456-4.502-.256-6.224-.664-.67-1.432-1.064-2.424-1.246-.64-.118-.776-.118-1.448-.008-1.02.167-1.81.562-2.512 1.256-1.72 1.704-1.832 4.342-.264 6.222l.413.496v.608c0 .336-.027.608-.06.608-.03 0-.264-.16-.512-.36l-.034-.011c-.832-.664-1.568-1.842-1.872-2.997-.184-.698-.184-2.024.008-2.72.504-1.878 1.888-3.335 3.808-4.019.41-.145 1.133-.22 1.814-.211zm-.13 2.99c.31 0 .62.06.844.178.488.253.888.745 1.04 1.259.464 1.578-1.208 2.96-2.72 2.254h-.015c-.712-.331-1.096-.956-1.104-1.77 0-.733.408-1.371 1.112-1.745.224-.117.534-.176.844-.176zm-.011 4.728c.988-.004 1.706.349 1.97.97.198.464.124 1.932-.218 4.302-.232 1.656-.36 2.074-.68 2.356-.44.39-1.064.498-1.656.288h-.003c-.716-.257-.87-.605-1.164-2.644-.341-2.37-.416-3.838-.218-4.302.262-.616.974-.966 1.97-.97z"/></svg>
|
||||
{{- else if (eq .name "podcasts-google") -}}
|
||||
|
@ -140,6 +144,8 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor" stroke="none"><path d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zM6.313 15.165a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zM8.834 6.313a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zM18.956 8.834a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zM17.688 8.834a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zM15.165 18.956a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zM15.165 17.688a2.527 2.527 0 0 1-2.52-2.523 2.526 2.526 0 0 1 2.52-2.52h6.313A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z"/></svg>
|
||||
{{- else if (eq .name "soundcloud") -}}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor" stroke="none"><path d="M1.175 12.225c-.051 0-.094.046-.101.1l-.233 2.154.233 2.105c.007.058.05.098.101.098.05 0 .09-.04.099-.098l.255-2.105-.27-2.154c0-.057-.045-.1-.09-.1m-.899.828c-.06 0-.091.037-.104.094L0 14.479l.165 1.308c0 .055.045.094.09.094s.089-.045.104-.104l.21-1.319-.21-1.334c0-.061-.044-.09-.09-.09m1.83-1.229c-.061 0-.12.045-.12.104l-.21 2.563.225 2.458c0 .06.045.12.119.12.061 0 .105-.061.121-.12l.254-2.474-.254-2.548c-.016-.06-.061-.12-.121-.12m.945-.089c-.075 0-.135.06-.15.135l-.193 2.64.21 2.544c.016.077.075.138.149.138.075 0 .135-.061.15-.15l.24-2.532-.24-2.623c0-.075-.06-.135-.135-.135l-.031-.017zm1.155.36c-.005-.09-.075-.149-.159-.149-.09 0-.158.06-.164.149l-.217 2.43.2 2.563c0 .09.075.157.159.157.074 0 .148-.068.148-.158l.227-2.563-.227-2.444.033.015zm.809-1.709c-.101 0-.18.09-.18.181l-.21 3.957.187 2.563c0 .09.08.164.18.164.094 0 .174-.09.18-.18l.209-2.563-.209-3.972c-.008-.104-.088-.18-.18-.18m.959-.914c-.105 0-.195.09-.203.194l-.18 4.872.165 2.548c0 .12.09.209.195.209.104 0 .194-.089.21-.209l.193-2.548-.192-4.856c-.016-.12-.105-.21-.21-.21m.989-.449c-.121 0-.211.089-.225.209l-.165 5.275.165 2.52c.014.119.104.225.225.225.119 0 .225-.105.225-.225l.195-2.52-.196-5.275c0-.12-.105-.225-.225-.225m1.245.045c0-.135-.105-.24-.24-.24-.119 0-.24.105-.24.24l-.149 5.441.149 2.503c.016.135.121.24.256.24s.24-.105.24-.24l.164-2.503-.164-5.456-.016.015zm.749-.134c-.135 0-.255.119-.255.254l-.15 5.322.15 2.473c0 .15.12.255.255.255s.255-.12.255-.27l.15-2.474-.165-5.307c0-.148-.12-.27-.271-.27m1.005.166c-.164 0-.284.135-.284.285l-.103 5.143.135 2.474c0 .149.119.277.284.277.149 0 .271-.12.284-.285l.121-2.443-.135-5.112c-.012-.164-.135-.285-.285-.285m1.184-.945c-.045-.029-.105-.044-.165-.044s-.119.015-.165.044c-.09.054-.149.15-.149.255v.061l-.104 6.048.115 2.449v.008c.008.06.03.135.074.18.058.061.142.104.234.104.08 0 .158-.044.209-.09.058-.06.091-.135.091-.225l.015-.24.117-2.203-.135-6.086c0-.104-.061-.193-.135-.239l-.002-.022zm1.006-.547c-.045-.045-.09-.061-.15-.061-.074 0-.149.016-.209.061-.075.061-.119.15-.119.24v.029l-.137 6.609.076 1.215.061 1.185c0 .164.148.314.328.314.181 0 .33-.15.33-.329l.15-2.414-.15-6.637c0-.12-.074-.221-.165-.277m8.934 3.777c-.405 0-.795.086-1.139.232-.24-2.654-2.46-4.736-5.188-4.736-.659 0-1.305.135-1.889.359-.225.09-.27.18-.285.359v9.368c.016.18.15.33.33.345h8.185C22.681 17.218 24 15.914 24 14.28s-1.319-2.952-2.938-2.952"/></svg>
|
||||
{{- else if (eq .name "sourcehut") -}}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor" stroke="none"><path d="M12 0C5.371 0 0 5.371 0 12s5.371 12 12 12 12-5.371 12-12S18.629 0 12 0Zm0 21.677A9.675 9.675 0 0 1 2.323 12 9.675 9.675 0 0 1 12 2.323 9.675 9.675 0 0 1 21.677 12 9.675 9.675 0 0 1 12 21.677Z"/></svg>
|
||||
{{- else if (eq .name "spotify") -}}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor" stroke="none"><path d="M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.66 0 12 0zm5.521 17.34c-.24.359-.66.48-1.021.24-2.82-1.74-6.36-2.101-10.561-1.141-.418.122-.779-.179-.899-.539-.12-.421.18-.78.54-.9 4.56-1.021 8.52-.6 11.64 1.32.42.18.479.659.301 1.02zm1.44-3.3c-.301.42-.841.6-1.262.3-3.239-1.98-8.159-2.58-11.939-1.38-.479.12-1.02-.12-1.14-.6-.12-.48.12-1.021.6-1.141C9.6 9.9 15 10.561 18.72 12.84c.361.181.54.78.241 1.2zm.12-3.36C15.24 8.4 8.82 8.16 5.16 9.301c-.6.179-1.2-.181-1.38-.721-.18-.601.18-1.2.72-1.381 4.26-1.26 11.28-1.02 15.721 1.621.539.3.719 1.02.419 1.56-.299.421-1.02.599-1.559.3z"/></svg>
|
||||
{{- else if (eq .name "stackoverflow") -}}
|
||||
|
|
|
@ -3,9 +3,7 @@
|
|||
|
||||
<div class="post-info">
|
||||
<p>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||
class="feather feather-clock">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-clock">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<polyline points="12 6 12 12 16 14"></polyline>
|
||||
</svg>
|
||||
|
@ -57,13 +55,6 @@
|
|||
|
||||
<div class="post-content">
|
||||
{{ .Content }}
|
||||
{{ if .Page.Store.Get "hasMermaid" }}
|
||||
<script type="module">
|
||||
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.esm.min.mjs';
|
||||
mermaid.initialize({ startOnLoad: true });
|
||||
</script>
|
||||
{{ end }}
|
||||
|
||||
</div>
|
||||
</article>
|
||||
|
||||
|
@ -74,9 +65,7 @@
|
|||
{{ partial "categories.html" . }}
|
||||
|
||||
<p>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||
class="feather feather-file-text">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-file-text">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
|
||||
<polyline points="14 2 14 8 20 8"></polyline>
|
||||
<line x1="16" y1="13" x2="8" y2="13"></line>
|
||||
|
@ -87,9 +76,7 @@
|
|||
</p>
|
||||
|
||||
<p>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||
class="feather feather-calendar">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-calendar">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
|
||||
<line x1="16" y1="2" x2="16" y2="6"></line>
|
||||
<line x1="8" y1="2" x2="8" y2="6"></line>
|
||||
|
@ -114,18 +101,14 @@
|
|||
|
||||
{{- if .GitInfo }}
|
||||
<p>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||
class="feather feather-git-commit">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-git-commit">
|
||||
<circle cx="12" cy="12" r="4"></circle>
|
||||
<line x1="1.05" y1="12" x2="7" y2="12"></line>
|
||||
<line x1="17.01" y1="12" x2="22.96" y2="12"></line>
|
||||
</svg>
|
||||
|
||||
<a href="{{ .Site.Params.gitUrl -}}{{ .GitInfo.Hash }}" target="_blank" rel="noopener">{{ .GitInfo.AbbreviatedHash
|
||||
}}</a>
|
||||
@ {{ if .Site.Params.dateformNum }}{{ dateFormat .Site.Params.dateformNum .GitInfo.AuthorDate.Local }}{{ else }}{{
|
||||
dateFormat "2006-01-02" .GitInfo.AuthorDate.Local }}{{ end }}
|
||||
<a href="{{ .Site.Params.gitUrl -}}{{ .GitInfo.Hash }}" target="_blank" rel="noopener">{{ .GitInfo.AbbreviatedHash }}</a>
|
||||
@ {{ if .Site.Params.dateformNum }}{{ dateFormat .Site.Params.dateformNum .GitInfo.AuthorDate.Local }}{{ else }}{{ dateFormat "2006-01-02" .GitInfo.AuthorDate.Local }}{{ end }}
|
||||
</p>
|
||||
{{- end }}
|
||||
</div>
|
||||
|
@ -152,5 +135,16 @@
|
|||
<div id="commento"></div>
|
||||
{{ end }}
|
||||
|
||||
{{ if .Site.Params.Utterances.Repository }}
|
||||
<script src="https://utteranc.es/client.js"
|
||||
repo="{{ .Site.Params.Utterances.Repository }}"
|
||||
{{ if .Site.Params.Utterances.Label }}label="{{ .Site.Params.Utterances.Label }}"{{ end }}
|
||||
issue-term="{{ .Site.Params.Utterances.IssueTerm }}"
|
||||
theme="{{ .Site.Params.Utterances.Theme }}"
|
||||
crossorigin="anonymous"
|
||||
async>
|
||||
</script>
|
||||
{{ end }}
|
||||
|
||||
</main>
|
||||
{{ end }}
|
Loading…
Reference in a new issue