caddy support multi domain
This commit is contained in:
parent
f15e521895
commit
fdcc829acf
13 changed files with 344 additions and 141 deletions
|
|
@ -15,6 +15,14 @@ in
|
|||
options.my.monitoring.grafana = {
|
||||
enable = lib.mkEnableOption "Enable grafana as a data visualization";
|
||||
|
||||
adminPasswordFile = lib.mkOption {
|
||||
default = "";
|
||||
type = lib.types.str;
|
||||
description = ''
|
||||
Path to the file containing the admin password for Grafana
|
||||
'';
|
||||
};
|
||||
|
||||
proxy = {
|
||||
enable = lib.mkEnableOption "Set the proxy entry for this service";
|
||||
|
||||
|
|
@ -26,6 +34,14 @@ in
|
|||
'';
|
||||
};
|
||||
|
||||
subdomain = lib.mkOption {
|
||||
default = "grafana";
|
||||
type = lib.types.str;
|
||||
description = ''
|
||||
The subdomain where Grafana is reachable
|
||||
'';
|
||||
};
|
||||
|
||||
host = lib.mkOption {
|
||||
default = "localhost";
|
||||
type = lib.types.str;
|
||||
|
|
@ -41,13 +57,6 @@ in
|
|||
config = lib.mkMerge [
|
||||
(lib.mkIf cfg.enable {
|
||||
|
||||
age.secrets = {
|
||||
grafana-admin-pwd = {
|
||||
file = ../../secrets/grafana-admin-pwd.age;
|
||||
owner = "grafana";
|
||||
};
|
||||
};
|
||||
|
||||
services = {
|
||||
|
||||
grafana = {
|
||||
|
|
@ -63,13 +72,14 @@ in
|
|||
};
|
||||
security = {
|
||||
admin_user = "pazpi";
|
||||
admin_password = "$__file{${config.age.secrets.grafana-admin-pwd.path}}";
|
||||
admin_password = "$__file{${cfg.adminPasswordFile}}";
|
||||
};
|
||||
server = {
|
||||
domain = "grafana.neon-dory.ts.net";
|
||||
# domain = "grafana.neon-dory.ts.net";
|
||||
domain = cfg.proxy.domain;
|
||||
http_addr = "0.0.0.0";
|
||||
http_port = 3000;
|
||||
# root_url = "https://grafana.${cfg.proxy.domain}";
|
||||
root_url = "https://${cfg.proxy.subdomain}.${cfg.proxy.domain}";
|
||||
enable_gzip = true;
|
||||
};
|
||||
users = {
|
||||
|
|
@ -111,9 +121,9 @@ in
|
|||
|
||||
(lib.mkIf cfg.proxy.enable {
|
||||
services.caddy = with cfg.proxy; {
|
||||
virtualHosts."grafana.${domain}".extraConfig = ''
|
||||
virtualHosts."${subdomain}.${domain}".extraConfig = ''
|
||||
reverse_proxy http://${host}:3000
|
||||
import cloudflare
|
||||
import cloudflare_${domain}
|
||||
'';
|
||||
};
|
||||
})
|
||||
|
|
|
|||
|
|
@ -26,6 +26,14 @@ in
|
|||
'';
|
||||
};
|
||||
|
||||
subdomain = lib.mkOption {
|
||||
default = "prometheus";
|
||||
type = lib.types.str;
|
||||
description = ''
|
||||
The subdomain where Prometheus is reachable
|
||||
'';
|
||||
};
|
||||
|
||||
host = lib.mkOption {
|
||||
default = "localhost";
|
||||
type = lib.types.str;
|
||||
|
|
@ -94,9 +102,9 @@ in
|
|||
|
||||
(lib.mkIf cfg.proxy.enable {
|
||||
services.caddy = with cfg.proxy; {
|
||||
virtualHosts."prometheus.${domain}".extraConfig = ''
|
||||
virtualHosts."${subdomain}.${domain}".extraConfig = ''
|
||||
reverse_proxy http://${host}:9090
|
||||
import cloudflare
|
||||
import cloudflare_${domain}
|
||||
'';
|
||||
};
|
||||
})
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ in
|
|||
services.caddy = with cfg.proxy; {
|
||||
virtualHosts."up.${domain}".extraConfig = ''
|
||||
reverse_proxy http://${host}:${port}
|
||||
import cloudflare
|
||||
import cloudflare_${domain}
|
||||
'';
|
||||
};
|
||||
})
|
||||
|
|
|
|||
|
|
@ -14,53 +14,81 @@ in
|
|||
options.my.networking.caddy = {
|
||||
enable = lib.mkEnableOption "Enable caddy as reverse proxy";
|
||||
|
||||
domain = lib.mkOption {
|
||||
default = "example.com";
|
||||
type = lib.types.str;
|
||||
domainsList = lib.mkOption {
|
||||
type = lib.types.listOf (lib.types.attrsOf lib.types.str);
|
||||
description = ''
|
||||
The domain where Caddy is reachable
|
||||
A list of sets, each containing three parameters of type string: domain, email, and cloudflareApiKeyFile.
|
||||
'';
|
||||
default = [
|
||||
{
|
||||
domain = "example.com";
|
||||
email = "user@domain.com";
|
||||
cloudflareApiKeyFile = "/path/to/cloudflare/api/key";
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
email = lib.mkOption {
|
||||
default = "user@domain.com";
|
||||
type = lib.types.str;
|
||||
description = ''
|
||||
Email for Certbot
|
||||
'';
|
||||
};
|
||||
# claudflareApiKeyFile = lib.mkOption {
|
||||
# default = "";
|
||||
# type = lib.types.str;
|
||||
# description = ''
|
||||
# Cloudflare API key file
|
||||
# '';
|
||||
# };
|
||||
|
||||
# domain = lib.mkOption {
|
||||
# default = "example.com";
|
||||
# type = lib.types.str;
|
||||
# description = ''
|
||||
# The domain where Caddy is reachable
|
||||
# '';
|
||||
# };
|
||||
|
||||
# email = lib.mkOption {
|
||||
# default = "user@domain.com";
|
||||
# type = lib.types.str;
|
||||
# description = ''
|
||||
# Email for Certbot
|
||||
# '';
|
||||
# };
|
||||
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
|
||||
age.secrets = {
|
||||
cloudflare-tegola-apiKey = {
|
||||
file = ../../secrets/cloudflare-tegola-apiKey.age;
|
||||
owner = config.services.caddy.user;
|
||||
group = config.services.caddy.group;
|
||||
};
|
||||
};
|
||||
|
||||
# Insted on relying on caddy to provide TLS, we use certbot to get a certificate
|
||||
# https://aottr.dev/posts/2024/08/homelab-setting-up-caddy-reverse-proxy-with-ssl-on-nixos/
|
||||
security.acme = {
|
||||
acceptTerms = true;
|
||||
defaults.email = cfg.email;
|
||||
|
||||
# TESTING ONLY!
|
||||
# defaults.server = "https://acme-staging-v02.api.letsencrypt.org/directory";
|
||||
|
||||
certs."${cfg.domain}" = {
|
||||
group = config.services.caddy.group;
|
||||
certs = lib.mkMerge (
|
||||
map (domainConfig: {
|
||||
"${domainConfig.domain}" = {
|
||||
group = config.services.caddy.group;
|
||||
email = domainConfig.email;
|
||||
domain = domainConfig.domain;
|
||||
extraDomainNames = [ "*.${domainConfig.domain}" ];
|
||||
dnsProvider = "cloudflare";
|
||||
dnsResolver = "1.1.1.1:53";
|
||||
dnsPropagationCheck = true;
|
||||
environmentFile = domainConfig.cloudflareApiKeyFile;
|
||||
};
|
||||
}) cfg.domainsList
|
||||
);
|
||||
|
||||
domain = "${cfg.domain}";
|
||||
extraDomainNames = [ "*.${cfg.domain}" ];
|
||||
dnsProvider = "cloudflare";
|
||||
dnsResolver = "1.1.1.1:53";
|
||||
dnsPropagationCheck = true;
|
||||
environmentFile = config.age.secrets.cloudflare-tegola-apiKey.path;
|
||||
};
|
||||
# certs."${cfg.domain}" = {
|
||||
# group = config.services.caddy.group;
|
||||
|
||||
# domain = "${cfg.domain}";
|
||||
# extraDomainNames = [ "*.${cfg.domain}" ];
|
||||
# dnsProvider = "cloudflare";
|
||||
# dnsResolver = "1.1.1.1:53";
|
||||
# dnsPropagationCheck = true;
|
||||
# environmentFile = cfg.claudflareApiKeyFile;
|
||||
# };
|
||||
};
|
||||
|
||||
services.caddy = {
|
||||
|
|
@ -72,17 +100,33 @@ in
|
|||
}
|
||||
'';
|
||||
|
||||
extraConfig =
|
||||
let
|
||||
certPath = config.security.acme.certs."${cfg.domain}".directory;
|
||||
in
|
||||
''
|
||||
(cloudflare) {
|
||||
tls ${certPath}/cert.pem ${certPath}/key.pem {
|
||||
protocols tls1.3
|
||||
extraConfig = lib.concatStringsSep "\n" (
|
||||
map (
|
||||
domainConfig:
|
||||
let
|
||||
certPath = config.security.acme.certs."${domainConfig.domain}".directory;
|
||||
in
|
||||
''
|
||||
(cloudflare_${domainConfig.domain}) {
|
||||
tls ${certPath}/cert.pem ${certPath}/key.pem {
|
||||
protocols tls1.3
|
||||
}
|
||||
}
|
||||
}
|
||||
'';
|
||||
''
|
||||
) cfg.domainsList
|
||||
);
|
||||
|
||||
# extraConfig =
|
||||
# let
|
||||
# certPath = config.security.acme.certs."${cfg.domain}".directory;
|
||||
# in
|
||||
# ''
|
||||
# (cloudflare) {
|
||||
# tls ${certPath}/cert.pem ${certPath}/key.pem {
|
||||
# protocols tls1.3
|
||||
# }
|
||||
# }
|
||||
# '';
|
||||
};
|
||||
|
||||
systemd.services.caddy.serviceConfig = {
|
||||
|
|
|
|||
|
|
@ -135,31 +135,31 @@ in
|
|||
services.caddy = with cfg.proxy; {
|
||||
virtualHosts."prowlarr.${domain}".extraConfig = ''
|
||||
reverse_proxy http://${host}:9696
|
||||
import cloudflare
|
||||
import cloudflare_${domain}
|
||||
'';
|
||||
virtualHosts."radarr.${domain}".extraConfig = ''
|
||||
reverse_proxy http://${host}:7878
|
||||
import cloudflare
|
||||
import cloudflare_${domain}
|
||||
'';
|
||||
virtualHosts."sonarr.${domain}".extraConfig = ''
|
||||
reverse_proxy http://${host}:8989
|
||||
import cloudflare
|
||||
import cloudflare_${domain}
|
||||
'';
|
||||
virtualHosts."lidarr.${domain}".extraConfig = ''
|
||||
reverse_proxy http://${host}:8686
|
||||
import cloudflare
|
||||
import cloudflare_${domain}
|
||||
'';
|
||||
virtualHosts."readarr.${domain}".extraConfig = ''
|
||||
reverse_proxy http://${host}:8787
|
||||
import cloudflare
|
||||
import cloudflare_${domain}
|
||||
'';
|
||||
virtualHosts."bazarr.${domain}".extraConfig = ''
|
||||
reverse_proxy http://${host}:6767
|
||||
import cloudflare
|
||||
import cloudflare_${domain}
|
||||
'';
|
||||
virtualHosts."jellyseerr.${domain}".extraConfig = ''
|
||||
reverse_proxy http://${host}:5055
|
||||
import cloudflare
|
||||
import cloudflare_${domain}
|
||||
'';
|
||||
};
|
||||
})
|
||||
|
|
|
|||
|
|
@ -12,6 +12,14 @@ in
|
|||
options.my.services.nextcloud = {
|
||||
enable = lib.mkEnableOption "Enable Nextcloud module";
|
||||
|
||||
adminPasswordFile = lib.mkOption {
|
||||
default = "";
|
||||
type = lib.types.str;
|
||||
description = ''
|
||||
Path to the file containing the admin password for Nextcloud
|
||||
'';
|
||||
};
|
||||
|
||||
proxy = {
|
||||
enable = lib.mkEnableOption "Set the proxy entry for this service";
|
||||
|
||||
|
|
@ -23,6 +31,22 @@ in
|
|||
'';
|
||||
};
|
||||
|
||||
subdomain = lib.mkOption {
|
||||
default = "nextcloud";
|
||||
type = lib.types.str;
|
||||
description = ''
|
||||
The subdomain where Nextcloud is reachable
|
||||
'';
|
||||
};
|
||||
|
||||
officeSubdomain = lib.mkOption {
|
||||
default = "office";
|
||||
type = lib.types.str;
|
||||
description = ''
|
||||
The subdomain where Collabora Online is reachable
|
||||
'';
|
||||
};
|
||||
|
||||
host = lib.mkOption {
|
||||
default = "localhost";
|
||||
type = lib.types.str;
|
||||
|
|
@ -37,15 +61,6 @@ in
|
|||
config = lib.mkMerge [
|
||||
(lib.mkIf cfg.enable {
|
||||
|
||||
age.secrets = {
|
||||
nextcloud-admin-pwd = {
|
||||
file = ../../secrets/nextcloud-admin-pwd.age;
|
||||
owner = "nextcloud";
|
||||
group = "nextcloud";
|
||||
mode = "770";
|
||||
};
|
||||
};
|
||||
|
||||
services = {
|
||||
|
||||
nextcloud = {
|
||||
|
|
@ -90,7 +105,7 @@ in
|
|||
config = {
|
||||
dbtype = "pgsql";
|
||||
adminuser = "admin";
|
||||
adminpassFile = config.age.secrets.nextcloud-admin-pwd.path;
|
||||
adminpassFile = cfg.adminPasswordFile;
|
||||
};
|
||||
|
||||
# Let NixOS install and configure the database automatically.
|
||||
|
|
@ -160,12 +175,12 @@ in
|
|||
|
||||
(lib.mkIf cfg.proxy.enable {
|
||||
services.caddy = with cfg.proxy; {
|
||||
virtualHosts."cloud.${domain}".extraConfig = ''
|
||||
virtualHosts."${subdomain}.${domain}".extraConfig = ''
|
||||
reverse_proxy http://${host}:80
|
||||
import cloudflare
|
||||
import cloudflare_${domain}
|
||||
'';
|
||||
virtualHosts."office.${domain}".extraConfig = ''
|
||||
import cloudflare
|
||||
virtualHosts."${officeSubdomain}.${domain}".extraConfig = ''
|
||||
import cloudflare_${domain}
|
||||
reverse_proxy http://${host}:${toString config.services.collabora-online.port} {
|
||||
# Required to circumvent bug of Onlyoffice loading mixed non-https content
|
||||
header_up X-Forwarded-Proto https
|
||||
|
|
|
|||
|
|
@ -12,6 +12,14 @@ in
|
|||
options.my.services.searx = {
|
||||
enable = lib.mkEnableOption "Enable searXNG module";
|
||||
|
||||
secretFile = lib.mkOption {
|
||||
default = "";
|
||||
type = lib.types.str;
|
||||
description = ''
|
||||
Path to the file containing the secret for searXNG
|
||||
'';
|
||||
};
|
||||
|
||||
proxy = {
|
||||
enable = lib.mkEnableOption "Set the proxy entry for this service";
|
||||
|
||||
|
|
@ -37,12 +45,10 @@ in
|
|||
config = lib.mkMerge [
|
||||
(lib.mkIf cfg.enable {
|
||||
|
||||
age.secrets.searx-secret.file = ../../secrets/searx-secret.age;
|
||||
|
||||
services.searcx = {
|
||||
services.searx = {
|
||||
enable = true;
|
||||
redisCreateLocally = true;
|
||||
environmentFile = config.age.secrets.searx-secret.path;
|
||||
environmentFile = cfg.secretFile;
|
||||
settings = {
|
||||
general = {
|
||||
open_metrics = "@METRICS_SECRET@";
|
||||
|
|
@ -63,7 +69,7 @@ in
|
|||
services.caddy = with cfg.proxy; {
|
||||
virtualHosts."search.${domain}".extraConfig = ''
|
||||
reverse_proxy http://${host}:8080
|
||||
import cloudflare
|
||||
import cloudflare_${domain}
|
||||
'';
|
||||
};
|
||||
})
|
||||
|
|
|
|||
|
|
@ -13,6 +13,14 @@ in
|
|||
options.my.services.vaultwarden = {
|
||||
enable = lib.mkEnableOption "Enable Vaultwarden module";
|
||||
|
||||
adminPasswordFile = lib.mkOption {
|
||||
default = "";
|
||||
type = lib.types.str;
|
||||
description = ''
|
||||
Path to the file containing the admin password for Vaultwarden
|
||||
'';
|
||||
};
|
||||
|
||||
proxy = {
|
||||
enable = lib.mkEnableOption "Set the proxy entry for this service";
|
||||
|
||||
|
|
@ -24,6 +32,14 @@ in
|
|||
'';
|
||||
};
|
||||
|
||||
subdomain = lib.mkOption {
|
||||
default = "vault";
|
||||
type = lib.types.str;
|
||||
description = ''
|
||||
The subdomain where Vaultwarden is reachable
|
||||
'';
|
||||
};
|
||||
|
||||
host = lib.mkOption {
|
||||
default = "localhost";
|
||||
type = lib.types.str;
|
||||
|
|
@ -38,8 +54,6 @@ in
|
|||
config = lib.mkMerge [
|
||||
(lib.mkIf cfg.enable {
|
||||
|
||||
age.secrets.vaultwarden-admin-pwd.file = ../../secrets/vaultwarden-admin-pwd.age;
|
||||
|
||||
my.services.postgresql = {
|
||||
enable = true;
|
||||
ensures = [
|
||||
|
|
@ -53,7 +67,7 @@ in
|
|||
services.vaultwarden = {
|
||||
enable = true;
|
||||
dbBackend = "postgresql";
|
||||
environmentFile = config.age.secrets.vaultwarden-admin-pwd.path;
|
||||
environmentFile = cfg.adminPasswordFile;
|
||||
config = {
|
||||
DOMAIN = "https://vault.${cfg.proxy.domain}";
|
||||
SENDS_ALLOWED = true;
|
||||
|
|
@ -75,9 +89,9 @@ in
|
|||
|
||||
(lib.mkIf cfg.proxy.enable {
|
||||
services.caddy = with cfg.proxy; {
|
||||
virtualHosts."vault.${domain}".extraConfig = ''
|
||||
virtualHosts."${subdomain}.${domain}".extraConfig = ''
|
||||
reverse_proxy http://${host}:${toString rocketPort}
|
||||
import cloudflare
|
||||
import cloudflare_${domain}
|
||||
'';
|
||||
};
|
||||
})
|
||||
|
|
|
|||
|
|
@ -43,61 +43,103 @@ in
|
|||
'';
|
||||
};
|
||||
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
|
||||
my.virtualisation.docker.enable = true;
|
||||
|
||||
virtualisation.oci-containers = {
|
||||
backend = "docker"; # Use Docker as the backend
|
||||
|
||||
containers = {
|
||||
portainer = {
|
||||
image = "portainer/portainer-ce:latest";
|
||||
ports = [ "9000:9000" ];
|
||||
volumes = [
|
||||
"/var/run/docker.sock:/var/run/docker.sock"
|
||||
"${cfg.portainerDataDir}:/data" # Add persistent volume for Portainer data
|
||||
];
|
||||
environmentFiles = [ cfg.environmentSecrets ];
|
||||
labels = {
|
||||
"com.centurylinklabs.watchtower.enable" = "true";
|
||||
};
|
||||
autoStart = true;
|
||||
};
|
||||
|
||||
watchtower = lib.mkIf cfg.enableWatchtower {
|
||||
image = "containrrr/watchtower";
|
||||
volumes = [ "/var/run/docker.sock:/var/run/docker.sock" ];
|
||||
autoStart = true;
|
||||
environmentFiles = [ cfg.environmentSecrets ];
|
||||
environment = {
|
||||
"TZ" = "Europe/Rome";
|
||||
"WATCHTOWER_CLEANUP" = "true";
|
||||
"WATCHTOWER_SCHEDULE" = "0 0 4 * * *"; # Run every day at 4am
|
||||
"WATCHTOWER_LABEL_ENABLE" = "true"; # Only update labeled containers
|
||||
"WATCHTOWER_NOTIFICATIONS" = "shoutrrr"; # Use shoutrrr for notifications
|
||||
};
|
||||
};
|
||||
proxy = {
|
||||
enable = lib.mkEnableOption "Set the proxy entry for this service";
|
||||
|
||||
domain = lib.mkOption {
|
||||
default = "example.com";
|
||||
type = lib.types.str;
|
||||
description = ''
|
||||
The domain where Caddy is reachable
|
||||
'';
|
||||
};
|
||||
|
||||
subdomain = lib.mkOption {
|
||||
default = "portainer";
|
||||
type = lib.types.str;
|
||||
description = ''
|
||||
The subdomain where Portainer will be reachable
|
||||
'';
|
||||
};
|
||||
|
||||
host = lib.mkOption {
|
||||
default = "localhost";
|
||||
type = lib.types.str;
|
||||
description = ''
|
||||
host name where the download manager stack is running
|
||||
'';
|
||||
};
|
||||
|
||||
};
|
||||
|
||||
# Ensure the directory exists and has the correct permissions
|
||||
systemd.tmpfiles.settings = {
|
||||
"10-portainerDataDir" = {
|
||||
${cfg.portainerDataDir} = {
|
||||
d = {
|
||||
group = "root";
|
||||
mode = "0755";
|
||||
user = "root";
|
||||
};
|
||||
|
||||
config = lib.mkMerge [
|
||||
(lib.mkIf cfg.enable {
|
||||
|
||||
my.virtualisation.docker.enable = true;
|
||||
|
||||
virtualisation.oci-containers = {
|
||||
backend = "docker"; # Use Docker as the backend
|
||||
|
||||
containers = {
|
||||
portainer = {
|
||||
image = "portainer/portainer-ce:latest";
|
||||
ports = [ "9000:9000" ];
|
||||
volumes = [
|
||||
"/var/run/docker.sock:/var/run/docker.sock"
|
||||
"${cfg.portainerDataDir}:/data" # Add persistent volume for Portainer data
|
||||
];
|
||||
environmentFiles = [ cfg.environmentSecrets ];
|
||||
labels = {
|
||||
"com.centurylinklabs.watchtower.enable" = "true";
|
||||
};
|
||||
autoStart = true;
|
||||
};
|
||||
|
||||
watchtower = lib.mkIf cfg.enableWatchtower {
|
||||
image = "containrrr/watchtower";
|
||||
volumes = [ "/var/run/docker.sock:/var/run/docker.sock" ];
|
||||
autoStart = true;
|
||||
environmentFiles = [ cfg.environmentSecrets ];
|
||||
environment = {
|
||||
"TZ" = "Europe/Rome";
|
||||
"WATCHTOWER_CLEANUP" = "true";
|
||||
"WATCHTOWER_SCHEDULE" = "0 0 4 * * *"; # Run every day at 4am
|
||||
"WATCHTOWER_LABEL_ENABLE" = "true"; # Only update labeled containers
|
||||
"WATCHTOWER_NOTIFICATIONS" = "shoutrrr"; # Use shoutrrr for notifications
|
||||
};
|
||||
};
|
||||
|
||||
};
|
||||
};
|
||||
|
||||
# Ensure the directory exists and has the correct permissions
|
||||
systemd.tmpfiles.settings = {
|
||||
"10-portainerDataDir" = {
|
||||
${cfg.portainerDataDir} = {
|
||||
d = {
|
||||
group = "root";
|
||||
mode = "0755";
|
||||
user = "root";
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
networking.firewall.allowedTCPPorts = [ 9000 ];
|
||||
networking.firewall.allowedTCPPorts = [ 9000 ];
|
||||
|
||||
})
|
||||
|
||||
(lib.mkIf cfg.proxy.enable {
|
||||
services.caddy = with cfg.proxy; {
|
||||
virtualHosts."${subdomain}.${domain}".extraConfig = ''
|
||||
reverse_proxy http://${host}:9000
|
||||
import cloudflare_${domain}
|
||||
'';
|
||||
};
|
||||
})
|
||||
|
||||
];
|
||||
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue