Compare commits
3 commits
main
...
article/fi
Author | SHA1 | Date | |
---|---|---|---|
d69ddb02a5 | |||
368ee09953 | |||
f3daa66e7e |
45 changed files with 633 additions and 1744 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
|
||||
|
||||
[](https://ci-server.internal.faercol.me/repos/1)
|
||||
[](https://drone.faercol.me/faercol/hugo-relie)
|
||||
|
||||
Personal blog using Hugo
|
||||
|
||||
|
|
24
config.toml
24
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"
|
||||
|
@ -91,10 +93,10 @@ logoHomeLink = "/fr/"
|
|||
|
||||
[menu]
|
||||
[[menu.main]]
|
||||
identifier = "blog"
|
||||
name = "Blog"
|
||||
url = "/posts"
|
||||
weight = 1
|
||||
identifier = "blog"
|
||||
name = "Blog"
|
||||
url = "/posts"
|
||||
weight = 1
|
||||
|
||||
[[menu.main]]
|
||||
identifier = "about_me"
|
||||
|
|
|
@ -1,690 +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.
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
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-sha512;
|
||||
secret "oK6SqKRvGNXHyNyIEy3hijQ1pclreZw4Vn5v+Q4rTLs=";
|
||||
};
|
||||
```
|
||||
|
||||
This creates a key with the given name using the default algorithm (which is `hmac-sha512`).
|
||||
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-sha512;
|
||||
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.
|
||||
|
||||

|
||||
|
||||
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)
|
||||
- 2024-12-09: Fixed some typos
|
|
@ -1,718 +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.
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
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 servir 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-challenge/<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.
|
||||
|
||||

|
||||
|
||||
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 exemple, 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-sha512;
|
||||
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-sha512`). 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-sha512;
|
||||
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.
|
||||
|
||||

|
||||
|
||||
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)
|
||||
- 2024-12-09: Correction de quelques typos
|
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€.
|
||||
|
||||

|
||||
|
||||
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.portrait]
|
||||
# path = "/img/image.jpg"
|
||||
# alt = "Portrait"
|
||||
# maxWidth = "50px"
|
||||
# [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 = ""
|
||||
#
|
||||
|
||||
# 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"
|
||||
|
|
|
@ -36,4 +36,9 @@ one = "Satu Kata"
|
|||
other = "{{ .Count }} Kata"
|
||||
|
||||
[lastModified]
|
||||
other = "Terakhir diupdate"
|
||||
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,4 +1,4 @@
|
|||
<pre class="mermaid">
|
||||
{{- .Inner | safeHTML }}
|
||||
</pre>
|
||||
{{- .Inner | safeHTML }}
|
||||
</pre>
|
||||
{{ .Page.Store.Set "hasMermaid" true }}
|
|
@ -1,30 +1,25 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="{{ .Site.Language }}">
|
||||
<head>
|
||||
{{ partial "head.html" . }}
|
||||
</head>
|
||||
|
||||
<head>
|
||||
{{ partial "head.html" . }}
|
||||
<link rel="stylesheet" href="/mermaid.css">
|
||||
<link rel="stylesheet" href="/css/heading.css">
|
||||
</head>
|
||||
|
||||
{{ block "body" . }}
|
||||
|
||||
<body>
|
||||
{{ block "body" . }}
|
||||
<body>
|
||||
{{ end }}
|
||||
|
||||
<div class="container">
|
||||
{{ partial "header.html" . }}
|
||||
|
||||
<div class="container">
|
||||
{{ partial "header.html" . }}
|
||||
<div class="content">
|
||||
{{ block "main" . }}{{ end }}
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
{{ block "main" . }}{{ end }}
|
||||
{{ block "footer" . }}
|
||||
{{ partial "footer.html" . }}
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
{{ block "footer" . }}
|
||||
{{ partial "footer.html" . }}
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
{{ partial "javascript.html" . }}
|
||||
</body>
|
||||
|
||||
</html>
|
||||
{{ 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") -}}
|
||||
|
|
|
@ -1,156 +1,150 @@
|
|||
{{ define "main" }}
|
||||
<main class="post">
|
||||
<main class="post">
|
||||
|
||||
<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">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<polyline points="12 6 12 12 16 14"></polyline>
|
||||
</svg>
|
||||
{{ i18n "readingTime" .Page.ReadingTime }}
|
||||
<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">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<polyline points="12 6 12 12 16 14"></polyline>
|
||||
</svg>
|
||||
{{ i18n "readingTime" .Page.ReadingTime }}
|
||||
|
||||
{{ if .IsTranslated }} | {{ i18n "postAvailable" }}
|
||||
{{ range .Translations }}
|
||||
<a href="{{ .Permalink }}"><span class="flag fi fi-{{ index $.Site.Data.langFlags (.Lang) }}"></span></a>
|
||||
{{ end}}
|
||||
{{ if .IsTranslated }} | {{ i18n "postAvailable" }}
|
||||
{{ range .Translations }}
|
||||
<a href="{{ .Permalink }}"><span class="flag fi fi-{{ index $.Site.Data.langFlags (.Lang) }}"></span></a>
|
||||
{{ end}}
|
||||
{{ end }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<article>
|
||||
<h1 class="post-title">
|
||||
<a href="{{ .Permalink }}">{{ .Title | markdownify }}</a>
|
||||
</h1>
|
||||
|
||||
{{ with .Params.Description }}
|
||||
<div class="post-excerpt">{{ . }}</div>
|
||||
{{ end }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<article>
|
||||
<h1 class="post-title">
|
||||
<a href="{{ .Permalink }}">{{ .Title | markdownify }}</a>
|
||||
</h1>
|
||||
{{ if .Params.Cover }}
|
||||
<figure class="post-cover">
|
||||
<img src="{{ .Params.Cover | absURL }}" alt="{{ .Title }}" />
|
||||
|
||||
{{ with .Params.Description }}
|
||||
<div class="post-excerpt">{{ . }}</div>
|
||||
{{ end }}
|
||||
|
||||
{{ if .Params.Cover }}
|
||||
<figure class="post-cover">
|
||||
<img src="{{ .Params.Cover | absURL }}" alt="{{ .Title }}" />
|
||||
|
||||
{{ if .Params.CoverCaption }}
|
||||
<figcaption class="center">{{ .Params.CoverCaption | markdownify }}</figcaption>
|
||||
{{ if .Params.CoverCaption }}
|
||||
<figcaption class="center">{{ .Params.CoverCaption | markdownify }}</figcaption>
|
||||
{{ end }}
|
||||
</figure>
|
||||
{{ end }}
|
||||
</figure>
|
||||
{{ end }}
|
||||
|
||||
{{- if .Params.toc }}
|
||||
<hr />
|
||||
<aside id="toc">
|
||||
<div class="toc-title">{{ i18n "tableOfContents" }}</div>
|
||||
{{ .TableOfContents }}
|
||||
</aside>
|
||||
{{- if .Params.toc }}
|
||||
<hr />
|
||||
<aside id="toc">
|
||||
<div class="toc-title">{{ i18n "tableOfContents" }}</div>
|
||||
{{ .TableOfContents }}
|
||||
</aside>
|
||||
<hr />
|
||||
{{- end }}
|
||||
|
||||
{{ if .Params.Audio }}
|
||||
<div class="post-audio">
|
||||
<audio controls>
|
||||
<source src="{{ .Params.Audio }}">
|
||||
</audio>
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
<div class="post-content">
|
||||
{{ .Content }}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<hr />
|
||||
|
||||
<div class="post-info">
|
||||
{{ partial "tags.html" .Params.tags }}
|
||||
{{ 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">
|
||||
<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>
|
||||
<line x1="16" y1="17" x2="8" y2="17"></line>
|
||||
<polyline points="10 9 9 9 8 9"></polyline>
|
||||
</svg>
|
||||
{{ i18n "wordCount" .Page.WordCount }}
|
||||
</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">
|
||||
<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>
|
||||
<line x1="3" y1="10" x2="21" y2="10"></line>
|
||||
</svg>
|
||||
{{ if .Site.Params.dateformNumTime }}
|
||||
{{ dateFormat .Site.Params.dateformNumTime .Date.Local }}
|
||||
{{ else }}
|
||||
{{ dateFormat "2006-01-02 15:04" .Date.Local }}
|
||||
{{ end }}
|
||||
|
||||
{{ if .Lastmod }}
|
||||
{{ if not (eq .Lastmod .Date )}}
|
||||
{{ if .Site.Params.dateformNumTime }}
|
||||
({{ i18n "lastModified" }}: {{ dateFormat .Site.Params.dateformNumTime .Lastmod.Local }})
|
||||
{{ else }}
|
||||
({{ i18n "lastModified" }}: {{ dateFormat "2006-01-02 15:04" .Lastmod.Local }})
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</p>
|
||||
|
||||
{{- 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">
|
||||
<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 }}
|
||||
</p>
|
||||
{{- end }}
|
||||
</div>
|
||||
|
||||
{{- if .Site.Params.EnableSharingButtons }}
|
||||
<hr />
|
||||
<div class="sharing-buttons">
|
||||
{{ partial "sharing-buttons.html" . }}
|
||||
</div>
|
||||
{{- end }}
|
||||
|
||||
{{ if .Params.Audio }}
|
||||
<div class="post-audio">
|
||||
<audio controls>
|
||||
<source src="{{ .Params.Audio }}">
|
||||
</audio>
|
||||
</div>
|
||||
{{ partial "pagination-single.html" . }}
|
||||
|
||||
{{ if .Site.DisqusShortname }}
|
||||
{{ if not (eq .Params.Comments "false") }}
|
||||
<div id="comments">
|
||||
{{ template "_internal/disqus.html" . }}
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
|
||||
<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 }}
|
||||
{{ if .Site.Params.Commento.Url }}
|
||||
<script defer src={{ .Site.Params.Commento.Url }}></script>
|
||||
<div id="commento"></div>
|
||||
{{ end }}
|
||||
|
||||
</div>
|
||||
</article>
|
||||
{{ 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 }}
|
||||
|
||||
<hr />
|
||||
|
||||
<div class="post-info">
|
||||
{{ partial "tags.html" .Params.tags }}
|
||||
{{ 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">
|
||||
<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>
|
||||
<line x1="16" y1="17" x2="8" y2="17"></line>
|
||||
<polyline points="10 9 9 9 8 9"></polyline>
|
||||
</svg>
|
||||
{{ i18n "wordCount" .Page.WordCount }}
|
||||
</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">
|
||||
<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>
|
||||
<line x1="3" y1="10" x2="21" y2="10"></line>
|
||||
</svg>
|
||||
{{ if .Site.Params.dateformNumTime }}
|
||||
{{ dateFormat .Site.Params.dateformNumTime .Date.Local }}
|
||||
{{ else }}
|
||||
{{ dateFormat "2006-01-02 15:04" .Date.Local }}
|
||||
{{ end }}
|
||||
|
||||
{{ if .Lastmod }}
|
||||
{{ if not (eq .Lastmod .Date )}}
|
||||
{{ if .Site.Params.dateformNumTime }}
|
||||
({{ i18n "lastModified" }}: {{ dateFormat .Site.Params.dateformNumTime .Lastmod.Local }})
|
||||
{{ else }}
|
||||
({{ i18n "lastModified" }}: {{ dateFormat "2006-01-02 15:04" .Lastmod.Local }})
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</p>
|
||||
|
||||
{{- 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">
|
||||
<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 }}
|
||||
</p>
|
||||
{{- end }}
|
||||
</div>
|
||||
|
||||
{{- if .Site.Params.EnableSharingButtons }}
|
||||
<hr />
|
||||
<div class="sharing-buttons">
|
||||
{{ partial "sharing-buttons.html" . }}
|
||||
</div>
|
||||
{{- end }}
|
||||
|
||||
{{ partial "pagination-single.html" . }}
|
||||
|
||||
{{ if .Site.DisqusShortname }}
|
||||
{{ if not (eq .Params.Comments "false") }}
|
||||
<div id="comments">
|
||||
{{ template "_internal/disqus.html" . }}
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
|
||||
{{ if .Site.Params.Commento.Url }}
|
||||
<script defer src={{ .Site.Params.Commento.Url }}></script>
|
||||
<div id="commento"></div>
|
||||
{{ end }}
|
||||
|
||||
</main>
|
||||
{{ end }}
|
||||
</main>
|
||||
{{ end }}
|
||||
|
|
Loading…
Add table
Reference in a new issue