Compare commits
No commits in common. "main" and "main" have entirely different histories.
15 changed files with 95 additions and 183 deletions
43
README.md
43
README.md
|
@ -38,41 +38,6 @@ There's already a `.vscode` directory which should direct VSCode to enable the D
|
||||||
|
|
||||||
# Infrastructure
|
# Infrastructure
|
||||||
|
|
||||||
## Setting up SSH access
|
|
||||||
|
|
||||||
You'll need to trust the SSH certificate authority that generates SSH keys for Epesooj's hosts.
|
|
||||||
The CA's public key is in `./host_config/ssh_certs/host_ca.pub`.
|
|
||||||
|
|
||||||
This is the template for the SSH `known_hosts` entry:
|
|
||||||
|
|
||||||
```
|
|
||||||
@cert-authority <dns name or ip address> <CONTENTS OF host_ca.pub>
|
|
||||||
```
|
|
||||||
|
|
||||||
For example:
|
|
||||||
|
|
||||||
```
|
|
||||||
@cert-authority code.akols.com ssh-ed25519 AAAA...
|
|
||||||
```
|
|
||||||
|
|
||||||
## Signing a user's public SSH key to give them host access
|
|
||||||
|
|
||||||
Run `just sign_user_key <username> <user_pub_key_path>`.
|
|
||||||
This will by default give them `root` access.
|
|
||||||
Check the definition of this `just` command to see how to give them access to different user(s).
|
|
||||||
|
|
||||||
Once this is done, give them the signed public key (it'll be a file in the same directory as `<user_pub_key_path>` with the `-cert.pub` suffix) and tell them to add the `CertificateFile` option to their SSH config to make sure it'll also present the signed public key.
|
|
||||||
For example:
|
|
||||||
|
|
||||||
```
|
|
||||||
Host epesooj
|
|
||||||
User root
|
|
||||||
HostName code.akols.com
|
|
||||||
IdentityFile ~/.ssh/epesooj_personal.pub
|
|
||||||
CertificateFile ~/.ssh/epesooj_personal-cert.pub
|
|
||||||
IdentitiesOnly yes
|
|
||||||
```
|
|
||||||
|
|
||||||
## Nixifying a new host
|
## Nixifying a new host
|
||||||
|
|
||||||
If you have a bunch of SSH keys in your SSH agent and get errors when trying to SSH into a fresh host, you may need to temporarily add the following config to your SSH config (obviously change the details for your case).
|
If you have a bunch of SSH keys in your SSH agent and get errors when trying to SSH into a fresh host, you may need to temporarily add the following config to your SSH config (obviously change the details for your case).
|
||||||
|
@ -85,15 +50,7 @@ Host 188.245.194.78
|
||||||
IdentitiesOnly yes
|
IdentitiesOnly yes
|
||||||
```
|
```
|
||||||
|
|
||||||
Once you can SSH into the host normally, run `just nixify_host <hostname> "code" "<dns name>,<ip address>"`.
|
|
||||||
For example: `just nixify_host epesooj-code-0001 code "code.akols.com,188.245.194.78"`.
|
|
||||||
|
|
||||||
This command requires you to have the key for the Epesooj Host SSH certificate authority.
|
|
||||||
If you don't have it, contact someone who does.
|
|
||||||
|
|
||||||
## Deploying the webring
|
## Deploying the webring
|
||||||
|
|
||||||
You should have a `.env` file with the id and deploy key for each script in the webring, as well as a key to deploy the index page to bunny.
|
You should have a `.env` file with the id and deploy key for each script in the webring, as well as a key to deploy the index page to bunny.
|
||||||
When you have this, run `deno task deploy`.
|
When you have this, run `deno task deploy`.
|
||||||
You'll need the API keys required to deploy these.
|
|
||||||
If you don't have them, contact someone who does.
|
|
||||||
|
|
|
@ -96,10 +96,6 @@
|
||||||
settings = {
|
settings = {
|
||||||
PasswordAuthentication = false;
|
PasswordAuthentication = false;
|
||||||
};
|
};
|
||||||
extraConfig = ''
|
|
||||||
HostCertificate /persisted/etc/ssh/ssh_host_ed25519_key-cert.pub
|
|
||||||
TrustedUserCAKeys /persisted/etc/ssh/user_cas.pub
|
|
||||||
'';
|
|
||||||
};
|
};
|
||||||
|
|
||||||
users.users.root = {
|
users.users.root = {
|
103
flake.lock
generated
103
flake.lock
generated
|
@ -7,11 +7,11 @@
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1750903843,
|
"lastModified": 1739841949,
|
||||||
"narHash": "sha256-Ng9+f0H5/dW+mq/XOKvB9uwvGbsuiiO6HrPdAcVglCs=",
|
"narHash": "sha256-lSOXdgW/1zi/SSu7xp71v+55D5Egz8ACv0STkj7fhbs=",
|
||||||
"owner": "nix-community",
|
"owner": "nix-community",
|
||||||
"repo": "disko",
|
"repo": "disko",
|
||||||
"rev": "83c4da299c1d7d300f8c6fd3a72ac46cb0d59aae",
|
"rev": "15dbf8cebd8e2655a883b74547108e089f051bf0",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
@ -25,11 +25,11 @@
|
||||||
"nixpkgs-lib": "nixpkgs-lib"
|
"nixpkgs-lib": "nixpkgs-lib"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1749398372,
|
"lastModified": 1738453229,
|
||||||
"narHash": "sha256-tYBdgS56eXYaWVW3fsnPQ/nFlgWi/Z2Ymhyu21zVM98=",
|
"narHash": "sha256-7H9XgNiGLKN1G1CgRh0vUL4AheZSYzPm+zmZ7vxbJdo=",
|
||||||
"owner": "hercules-ci",
|
"owner": "hercules-ci",
|
||||||
"repo": "flake-parts",
|
"repo": "flake-parts",
|
||||||
"rev": "9305fe4e5c2a6fcf5ba6a3ff155720fbe4076569",
|
"rev": "32ea77a06711b758da0ad9bd6a844c5740a87abd",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
@ -38,18 +38,38 @@
|
||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"nil": {
|
"flake-utils": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"nixpkgs": [
|
"systems": "systems"
|
||||||
"nixpkgs"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1751341694,
|
"lastModified": 1731533236,
|
||||||
"narHash": "sha256-zXag1+8iZC3H5yVFP7KhIi4ps9z8xKrFIkyaeXlZ7Uo=",
|
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nil": {
|
||||||
|
"inputs": {
|
||||||
|
"flake-utils": "flake-utils",
|
||||||
|
"nixpkgs": [
|
||||||
|
"nixpkgs"
|
||||||
|
],
|
||||||
|
"rust-overlay": "rust-overlay"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1732053863,
|
||||||
|
"narHash": "sha256-DCIVdlb81Fct2uwzbtnawLBC/U03U2hqx8trqTJB7WA=",
|
||||||
"owner": "oxalica",
|
"owner": "oxalica",
|
||||||
"repo": "nil",
|
"repo": "nil",
|
||||||
"rev": "b043bfe1f3f4c4be4b688e24c5ae96e81f525805",
|
"rev": "2e24c9834e3bb5aa2a3701d3713b43a6fb106362",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
@ -60,11 +80,11 @@
|
||||||
},
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1751271578,
|
"lastModified": 1740126099,
|
||||||
"narHash": "sha256-P/SQmKDu06x8yv7i0s8bvnnuJYkxVGBWLWHaU+tt4YY=",
|
"narHash": "sha256-ozoOtE2hGsqh4XkTJFsrTkNxkRgShxpQxDynaPZUGxk=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "3016b4b15d13f3089db8a41ef937b13a9e33a8df",
|
"rev": "32fb99ba93fea2798be0e997ea331dd78167f814",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
@ -76,17 +96,14 @@
|
||||||
},
|
},
|
||||||
"nixpkgs-lib": {
|
"nixpkgs-lib": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1748740939,
|
"lastModified": 1738452942,
|
||||||
"narHash": "sha256-rQaysilft1aVMwF14xIdGS3sj1yHlI6oKQNBRTF40cc=",
|
"narHash": "sha256-vJzFZGaCpnmo7I6i416HaBLpC+hvcURh/BQwROcGIp8=",
|
||||||
"owner": "nix-community",
|
"type": "tarball",
|
||||||
"repo": "nixpkgs.lib",
|
"url": "https://github.com/NixOS/nixpkgs/archive/072a6db25e947df2f31aab9eccd0ab75d5b2da11.tar.gz"
|
||||||
"rev": "656a64127e9d791a334452c6b6606d17539476e2",
|
|
||||||
"type": "github"
|
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
"owner": "nix-community",
|
"type": "tarball",
|
||||||
"repo": "nixpkgs.lib",
|
"url": "https://github.com/NixOS/nixpkgs/archive/072a6db25e947df2f31aab9eccd0ab75d5b2da11.tar.gz"
|
||||||
"type": "github"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"root": {
|
"root": {
|
||||||
|
@ -96,6 +113,42 @@
|
||||||
"nil": "nil",
|
"nil": "nil",
|
||||||
"nixpkgs": "nixpkgs"
|
"nixpkgs": "nixpkgs"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"rust-overlay": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": [
|
||||||
|
"nil",
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1731983527,
|
||||||
|
"narHash": "sha256-JECaBgC0pQ91Hq3W4unH6K9to8s2Zl2sPNu7bLOv4ek=",
|
||||||
|
"owner": "oxalica",
|
||||||
|
"repo": "rust-overlay",
|
||||||
|
"rev": "71287228d96e9568e1e70c6bbfa3f992d145947b",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "oxalica",
|
||||||
|
"repo": "rust-overlay",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"systems": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1681028828,
|
||||||
|
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"root": "root",
|
"root": "root",
|
||||||
|
|
|
@ -21,7 +21,7 @@
|
||||||
systems = [ "x86_64-linux" ];
|
systems = [ "x86_64-linux" ];
|
||||||
|
|
||||||
imports = [
|
imports = [
|
||||||
./host_config
|
./code_server.nix
|
||||||
];
|
];
|
||||||
|
|
||||||
flake =
|
flake =
|
||||||
|
|
|
@ -1,47 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
|
|
||||||
set -euxo pipefail
|
|
||||||
|
|
||||||
SCRIPT_DIR=$(dirname "$(readlink -f "$0")")
|
|
||||||
host_ca_key="${SCRIPT_DIR}/ssh_certs/host_ca"
|
|
||||||
user_ca_pub="${SCRIPT_DIR}/ssh_certs/user_ca.pub"
|
|
||||||
|
|
||||||
if [ ! -f "${host_ca_key}" ]
|
|
||||||
then
|
|
||||||
echo "Host CA key not found."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ ! -f "${user_ca_pub}" ]
|
|
||||||
then
|
|
||||||
echo "Public User CA key not found."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
temp=$(mktemp -d)
|
|
||||||
|
|
||||||
cleanup() {
|
|
||||||
rm -rf "${temp}"
|
|
||||||
}
|
|
||||||
# trap cleanup EXIT
|
|
||||||
|
|
||||||
host_type=$1
|
|
||||||
hostname=$2
|
|
||||||
extra_names=${3:-}
|
|
||||||
|
|
||||||
principal_names="${hostname}"
|
|
||||||
|
|
||||||
if [ ! -z "${extra_names}" ]
|
|
||||||
then
|
|
||||||
principal_names="${principal_names},${extra_names}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
install -d -m755 "${temp}/persisted/etc/ssh"
|
|
||||||
ssh-keygen -t ed25519 -f "${temp}/persisted/etc/ssh/ssh_host_ed25519_key" -C '' -N ''
|
|
||||||
ssh-keygen -s ${host_ca_key} -I ${hostname} -h -n "${principal_names}" -V +52w "${temp}/persisted/etc/ssh/ssh_host_ed25519_key.pub"
|
|
||||||
|
|
||||||
cp "${user_ca_pub}" "${temp}/persisted/etc/ssh/user_cas.pub"
|
|
||||||
|
|
||||||
echo "${temp}"
|
|
||||||
|
|
||||||
#nix run github:nix-community/nixos-anywhere -- --extra-files "${temp}" --flake .#${host_type} --target-host root@${hostname}
|
|
|
@ -1,25 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
|
|
||||||
set -euxo pipefail
|
|
||||||
|
|
||||||
SCRIPT_DIR=$(dirname "$(readlink -f "$0")")
|
|
||||||
user_ca_key="${SCRIPT_DIR}/ssh_certs/user_ca"
|
|
||||||
|
|
||||||
if [ ! -f "${user_ca_key}" ]
|
|
||||||
then
|
|
||||||
echo "User CA key not found."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
username=$1
|
|
||||||
principals=$2
|
|
||||||
user_pub=$3
|
|
||||||
|
|
||||||
if [ ! -f "${user_pub}" ]
|
|
||||||
then
|
|
||||||
echo "User public key not found."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
ssh-keygen -s "${user_ca_key}" -I "${username}" -n "${principals}" -V +52w "${user_pub}"
|
|
||||||
echo "Done!"
|
|
2
host_config/ssh_certs/.gitignore
vendored
2
host_config/ssh_certs/.gitignore
vendored
|
@ -1,2 +0,0 @@
|
||||||
host_ca
|
|
||||||
user_ca
|
|
|
@ -1 +0,0 @@
|
||||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJygYxMUdGgApUE3KirRQVgG2X5zWurIBPbwEc10FxDi epesooj host ca
|
|
|
@ -1 +0,0 @@
|
||||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINdiqYdA3pm9yKgR5hFlL7ZeSV3xeKH9HwyNwaxY6yZZ epesooj user ca
|
|
7
justfile
7
justfile
|
@ -4,8 +4,5 @@ default:
|
||||||
build_nixos_config host_type="code":
|
build_nixos_config host_type="code":
|
||||||
nix build .#nixosConfigurations.{{host_type}}.config.system.build.toplevel
|
nix build .#nixosConfigurations.{{host_type}}.config.system.build.toplevel
|
||||||
|
|
||||||
nixify_host hostname host_type="code" extra_names="":
|
nixify_host hostname host_type="code":
|
||||||
./host_config/bootstrap_host.sh {{host_type}} {{hostname}} {{extra_names}}
|
nix run github:nix-community/nixos-anywhere -- --flake .#{{host_type}} --target-host root@{{hostname}}
|
||||||
|
|
||||||
sign_user_key username user_pub_key principals="root":
|
|
||||||
./host_config/sign_user_pub.sh {{username}} {{principals}} {{user_pub_key}}
|
|
||||||
|
|
|
@ -90,21 +90,21 @@ export async function uploadFile(filepath: string, contents: Uint8Array) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function purgeCDNCache() {
|
export async function purgePath(filepath: string) {
|
||||||
const pullZoneIdEnvName = 'BUNNY_PULL_ZONE_ID';
|
const cdnBaseUrlEnvName = 'BUNNY_CDN_BASE_URL';
|
||||||
const pullZoneId = Deno.env.get(pullZoneIdEnvName);
|
const cdnBaseUrl = Deno.env.get(cdnBaseUrlEnvName);
|
||||||
const accessKeyEnvName = 'BUNNY_ACCESS_KEY';
|
const accessKeyEnvName = 'BUNNY_ACCESS_KEY';
|
||||||
const accessKey = Deno.env.get(accessKeyEnvName);
|
const accessKey = Deno.env.get(accessKeyEnvName);
|
||||||
|
|
||||||
if (pullZoneId === undefined) {
|
if (cdnBaseUrl === undefined) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Can't purge CDN cache for because we don't know the pull zone ID. Please set it by setting the environment variable '${pullZoneIdEnvName}'.`
|
`Can't purge cache for '${filepath}' because we don't know the CDN base URL. Please set it by setting the environment variable '${cdnBaseUrlEnvName}'.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (accessKey === undefined) {
|
if (accessKey === undefined) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Can't purge CDN cache because we don't have an API key. Please set it by setting the environment variable '${accessKeyEnvName}'.`
|
`Can't purge cache for '${filepath}' because we don't have an API key. Please set it by setting the environment variable '${accessKeyEnvName}'.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -114,7 +114,9 @@ export async function purgeCDNCache() {
|
||||||
accesskey: accessKey,
|
accesskey: accessKey,
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchUrl = `https://api.bunny.net/pullzone/${pullZoneId}/purgeCache`;
|
const fetchUrl = new URL(`https://api.bunny.net/purge`);
|
||||||
|
fetchUrl.searchParams.append('async', 'false');
|
||||||
|
fetchUrl.searchParams.append('url', `${cdnBaseUrl}/${filepath}`);
|
||||||
|
|
||||||
const res = await fetch(fetchUrl.toString(), {
|
const res = await fetch(fetchUrl.toString(), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
@ -123,6 +125,6 @@ export async function purgeCDNCache() {
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
console.error(await res.text());
|
console.error(await res.text());
|
||||||
throw new Error(`Failed to purge CDN cache: ${res.statusText}`);
|
throw new Error(`Failed to purge cache: ${res.statusText}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { join } from '@std/path';
|
import { join } from '@std/path';
|
||||||
import { deployScript, purgeCDNCache, uploadFile } from './bunny_api/main.ts';
|
import { deployScript, purgePath, uploadFile } from './bunny_api/main.ts';
|
||||||
|
|
||||||
if (import.meta.main) {
|
if (import.meta.main) {
|
||||||
console.log(`Attempting to upload index.html`);
|
console.log(`Attempting to upload index.html`);
|
||||||
|
@ -8,8 +8,8 @@ if (import.meta.main) {
|
||||||
await uploadFile('index.html', indexContents);
|
await uploadFile('index.html', indexContents);
|
||||||
console.log(`Done!`);
|
console.log(`Done!`);
|
||||||
|
|
||||||
console.log(`Attempting to purge the CDN cache.`);
|
console.log(`Attempting to purge the cache for index.html`);
|
||||||
await purgeCDNCache();
|
await purgePath('index.html');
|
||||||
console.log(`Done!`);
|
console.log(`Done!`);
|
||||||
|
|
||||||
for (const dirEntry of Deno.readDirSync(
|
for (const dirEntry of Deno.readDirSync(
|
||||||
|
|
|
@ -5,6 +5,7 @@
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<style>
|
<style>
|
||||||
body {
|
body {
|
||||||
|
overflow: hidden;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
|
|
|
@ -52,23 +52,5 @@
|
||||||
"title": "Data Collection",
|
"title": "Data Collection",
|
||||||
"author": "graphdev",
|
"author": "graphdev",
|
||||||
"url": "https://blog.graphicalmethods.com"
|
"url": "https://blog.graphicalmethods.com"
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "scottsmitelli",
|
|
||||||
"title": "Scott Smitelli - Articles",
|
|
||||||
"author": "Scott Smitelli",
|
|
||||||
"url": "https://www.scottsmitelli.com/articles/"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "srxl",
|
|
||||||
"title": "ruby's site",
|
|
||||||
"author": "ruby iris juric",
|
|
||||||
"url": "https://foxgirl.engineering"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "entropic",
|
|
||||||
"title": "Entropic",
|
|
||||||
"author": "Entropic",
|
|
||||||
"url": "https://entropic.mataroa.blog/"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue