Compare commits

..

4 commits

20 changed files with 537 additions and 1136 deletions

1
.gitignore vendored
View file

@ -6,4 +6,3 @@ result*
output output
todo todo
/.pre-commit-config.yaml

View file

@ -46,26 +46,6 @@ NOTE: it sometimes takes a while for the services to start up, and in the meanti
```bash ```bash
pixelfed-manage user:create --name=test --username=test --email=test@test.com --password=testtest --confirm_email=1 pixelfed-manage user:create --name=test --username=test --email=test@test.com --password=testtest --confirm_email=1
``` ```
# Building an installer image
Build an installer image for the desired configuration, e.g. for `peertube`:
```bash
nix build .#installers.peertube
```
Upload the image in `./result` to Proxmox when creating a VM.
Booting the image will format the disk and install NixOS with the desired configuration.
# Deploying an updated machine configuration
> TODO: There is currently no way to specify an actual target machine by name.
Assuming you have SSH configuration with access to the remote `root` user stored for a machine called e.g. `peertube`, deploy the configuration by the same name:
```bash
nix run .#deploy.peertube
```
## debugging notes ## debugging notes

View file

@ -1,13 +0,0 @@
{ writeShellApplication }:
name: _config:
writeShellApplication {
name = "deploy";
text = ''
result="$(nix build --print-out-paths ${./.}#nixosConfigurations#${name} --eval-store auto --store ssh-ng://${name})"
# shellcheck disable=SC2087
ssh ${name} << EOF
nix-env -p /nix/var/nix/profiles/system --set "$result"
"$result"/bin/switch-to-configuration switch
EOF
'';
}

View file

@ -1,36 +0,0 @@
{ ... }:
{
disko.devices.disk.main = {
device = "/dev/sda";
type = "disk";
content = {
type = "gpt";
partitions = {
MBR = {
priority = 0;
size = "1M";
type = "EF02";
};
ESP = {
priority = 1;
size = "500M";
type = "EF00";
content = {
type = "filesystem";
format = "vfat";
mountpoint = "/boot";
};
};
root = {
priority = 2;
size = "100%";
content = {
type = "filesystem";
format = "ext4";
mountpoint = "/";
};
};
};
};
};
}

View file

@ -2,11 +2,10 @@
let let
inherit (builtins) toString; inherit (builtins) toString;
inherit (lib) mkOption mkEnableOption mkForce; inherit (lib) mkOption mkEnableOption;
inherit (lib.types) types; inherit (lib.types) types;
in in {
{
imports = [ imports = [
./garage.nix ./garage.nix
./mastodon.nix ./mastodon.nix
@ -32,27 +31,9 @@ in
pixelfed.enable = mkEnableOption "default Fediversity Pixelfed configuration"; pixelfed.enable = mkEnableOption "default Fediversity Pixelfed configuration";
peertube.enable = mkEnableOption "default Fediversity PeerTube configuration"; peertube.enable = mkEnableOption "default Fediversity PeerTube configuration";
temp = mkOption {
description = "options that are only used while developing; should be removed eventually";
default = { };
type = types.submodule {
options = {
cores = mkOption {
description = "number of cores; should be obtained from NixOps4";
type = types.int;
};
peertubeSecretsFile = mkOption {
description = "should it be provided by NixOps4? or maybe we should just ask for a main secret from which to derive all the others?";
type = types.path;
};
};
};
};
internal = mkOption { internal = mkOption {
description = "options that are only meant to be used internally; change at your own risk"; description = "options that are only meant to be used internally; change at your own risk";
default = { }; default = {};
type = types.submodule { type = types.submodule {
options = { options = {
garage = { garage = {
@ -83,17 +64,17 @@ in
type = types.str; type = types.str;
default = "web.garage.${config.fediversity.domain}"; default = "web.garage.${config.fediversity.domain}";
}; };
internalPort = mkOption { port = mkOption {
type = types.int; type = types.int;
default = 3902; default = 3902;
}; };
domainForBucket = mkOption { rootDomainAndPort = mkOption {
type = types.functionTo types.str; type = types.str;
default = bucket: "${bucket}.${config.fediversity.internal.garage.web.rootDomain}"; default = "${config.fediversity.internal.garage.web.rootDomain}:${toString config.fediversity.internal.garage.web.port}";
}; };
urlForBucket = mkOption { urlFor = mkOption {
type = types.functionTo types.str; type = types.functionTo types.str;
default = bucket: "http://${config.fediversity.internal.garage.web.domainForBucket bucket}"; default = bucket: "http://${bucket}.${config.fediversity.internal.garage.web.rootDomainAndPort}";
}; };
}; };
}; };
@ -108,7 +89,7 @@ in
}; };
mastodon.domain = mkOption { mastodon.domain = mkOption {
type = types.str; type = types.str;
default = "mastodon.${config.fediversity.domain}"; default = "mastdodon.${config.fediversity.domain}";
}; };
peertube.domain = mkOption { peertube.domain = mkOption {
type = types.str; type = types.str;
@ -119,19 +100,4 @@ in
}; };
}; };
}; };
config = {
## FIXME: This should clearly go somewhere else; and we should have a
## `staging` vs. `production` setting somewhere.
security.acme = {
acceptTerms = true;
defaults.email = "nicolas.jeannerod+fediversity@moduscreate.com";
# defaults.server = "https://acme-staging-v02.api.letsencrypt.org/directory";
};
## NOTE: For a one-machine deployment, this removes the need to provide an
## `s3.garage.<domain>` domain. However, this will quickly stop working once
## we go to multi-machines deployment.
fediversity.internal.garage.api.domain = mkForce "s3.garage.localhost";
};
} }

View file

@ -8,49 +8,25 @@ let
in in
# TODO: expand to a multi-machine setup # TODO: expand to a multi-machine setup
{ { config, lib, pkgs, ... }:
config,
lib,
pkgs,
...
}:
let let
inherit (builtins) toString; inherit (builtins) toString;
inherit (lib) inherit (lib) types mkOption mkEnableOption optionalString concatStringsSep;
types
mkOption
mkEnableOption
optionalString
concatStringsSep
;
inherit (lib.strings) escapeShellArg; inherit (lib.strings) escapeShellArg;
inherit (lib.attrsets) filterAttrs mapAttrs';
cfg = config.services.garage; cfg = config.services.garage;
fedicfg = config.fediversity.internal.garage;
concatMapAttrs = scriptFn: attrset: concatStringsSep "\n" (lib.mapAttrsToList scriptFn attrset); concatMapAttrs = scriptFn: attrset: concatStringsSep "\n" (lib.mapAttrsToList scriptFn attrset);
ensureBucketScriptFn = ensureBucketScriptFn = bucket: { website, aliases, corsRules }:
bucket:
{
website,
aliases,
corsRules,
}:
let let
bucketArg = escapeShellArg bucket; bucketArg = escapeShellArg bucket;
corsRulesJSON = escapeShellArg ( corsRulesJSON = escapeShellArg (builtins.toJSON {
builtins.toJSON { CORSRules = [{
CORSRules = [ AllowedHeaders = corsRules.allowedHeaders;
{ AllowedMethods = corsRules.allowedMethods;
AllowedHeaders = corsRules.allowedHeaders; AllowedOrigins = corsRules.allowedOrigins;
AllowedMethods = corsRules.allowedMethods; }];
AllowedOrigins = corsRules.allowedOrigins; });
} in ''
];
}
);
in
''
# garage bucket info tells us if the bucket already exists # garage bucket info tells us if the bucket already exists
garage bucket info ${bucketArg} || garage bucket create ${bucketArg} garage bucket info ${bucketArg} || garage bucket create ${bucketArg}
@ -59,43 +35,26 @@ let
garage bucket website --allow ${bucketArg} garage bucket website --allow ${bucketArg}
''} ''}
${concatStringsSep "\n" ( ${concatStringsSep "\n" (map (alias: ''
map (alias: '' garage bucket alias ${bucketArg} ${escapeShellArg alias}
garage bucket alias ${bucketArg} ${escapeShellArg alias} '') aliases)}
'') aliases
)}
${optionalString corsRules.enable '' ${optionalString corsRules.enable ''
garage bucket allow --read --write --owner ${bucketArg} --key tmp garage bucket allow --read --write --owner ${bucketArg} --key tmp
# TODO: endpoin-url should not be hard-coded # TODO: endpoin-url should not be hard-coded
aws --region ${cfg.settings.s3_api.s3_region} --endpoint-url ${fedicfg.api.url} s3api put-bucket-cors --bucket ${bucketArg} --cors-configuration ${corsRulesJSON} aws --region ${cfg.settings.s3_api.s3_region} --endpoint-url ${config.fediversity.internal.garage.api.url} s3api put-bucket-cors --bucket ${bucketArg} --cors-configuration ${corsRulesJSON}
garage bucket deny --read --write --owner ${bucketArg} --key tmp garage bucket deny --read --write --owner ${bucketArg} --key tmp
''} ''}
''; '';
ensureBucketsScript = concatMapAttrs ensureBucketScriptFn cfg.ensureBuckets; ensureBucketsScript = concatMapAttrs ensureBucketScriptFn cfg.ensureBuckets;
ensureAccessScriptFn = ensureAccessScriptFn = key: bucket: { read, write, owner }: ''
key: bucket: garage bucket allow ${optionalString read "--read"} ${optionalString write "--write"} ${optionalString owner "--owner"} \
{ ${escapeShellArg bucket} --key ${escapeShellArg key}
read, '';
write, ensureKeyScriptFn = key: {id, secret, ensureAccess}: ''
owner, garage key import --yes -n ${escapeShellArg key} ${escapeShellArg id} ${escapeShellArg secret}
}: ${concatMapAttrs (ensureAccessScriptFn key) ensureAccess}
'' '';
garage bucket allow ${optionalString read "--read"} ${optionalString write "--write"} ${optionalString owner "--owner"} \
${escapeShellArg bucket} --key ${escapeShellArg key}
'';
ensureKeyScriptFn =
key:
{
id,
secret,
ensureAccess,
}:
''
## FIXME: Check whether the key exist and skip this step if that is the case. Get rid of this `|| :`
garage key import --yes -n ${escapeShellArg key} ${escapeShellArg id} ${escapeShellArg secret} || :
${concatMapAttrs (ensureAccessScriptFn key) ensureAccess}
'';
ensureKeysScript = concatMapAttrs ensureKeyScriptFn cfg.ensureKeys; ensureKeysScript = concatMapAttrs ensureKeyScriptFn cfg.ensureKeys;
in in
@ -104,88 +63,94 @@ in
options = { options = {
services.garage = { services.garage = {
ensureBuckets = mkOption { ensureBuckets = mkOption {
type = types.attrsOf ( type = types.attrsOf (types.submodule {
types.submodule { options = {
options = { website = mkOption {
website = mkOption { type = types.bool;
type = types.bool; default = false;
default = false; };
}; # I think setting corsRules should allow another website to show images from your bucket
# I think setting corsRules should allow another website to show images from your bucket corsRules = {
corsRules = { enable = mkEnableOption "CORS Rules";
enable = mkEnableOption "CORS Rules"; allowedHeaders = mkOption {
allowedHeaders = mkOption {
type = types.listOf types.str;
default = [ ];
};
allowedMethods = mkOption {
type = types.listOf types.str;
default = [ ];
};
allowedOrigins = mkOption {
type = types.listOf types.str;
default = [ ];
};
};
aliases = mkOption {
type = types.listOf types.str; type = types.listOf types.str;
default = [ ]; default = [];
};
allowedMethods = mkOption {
type = types.listOf types.str;
default = [];
};
allowedOrigins = mkOption {
type = types.listOf types.str;
default = [];
}; };
}; };
} aliases = mkOption {
); type = types.listOf types.str;
default = { }; default = [];
};
};
});
default = {};
}; };
ensureKeys = mkOption { ensureKeys = mkOption {
type = types.attrsOf ( type = types.attrsOf (types.submodule {
types.submodule { # TODO: these should be managed as secrets, not in the nix store
# TODO: these should be managed as secrets, not in the nix store options = {
options = { id = mkOption {
id = mkOption { type = types.str;
type = types.str;
};
secret = mkOption {
type = types.str;
};
# TODO: assert at least one of these is true
# NOTE: this currently needs to be done at the top level module
ensureAccess = mkOption {
type = types.attrsOf (
types.submodule {
options = {
read = mkOption {
type = types.bool;
default = false;
};
write = mkOption {
type = types.bool;
default = false;
};
owner = mkOption {
type = types.bool;
default = false;
};
};
}
);
default = [ ];
};
}; };
} secret = mkOption {
); type = types.str;
default = { }; };
# TODO: assert at least one of these is true
# NOTE: this currently needs to be done at the top level module
ensureAccess = mkOption {
type = types.attrsOf (types.submodule {
options = {
read = mkOption {
type = types.bool;
default = false;
};
write = mkOption {
type = types.bool;
default = false;
};
owner = mkOption {
type = types.bool;
default = false;
};
};
});
default = [];
};
};
});
default = {};
}; };
}; };
}; };
config = lib.mkIf config.fediversity.enable { config = lib.mkIf config.fediversity.enable {
environment.systemPackages = [ virtualisation.diskSize = 2048;
pkgs.minio-client virtualisation.forwardPorts = [
pkgs.awscli {
from = "host";
host.port = config.fediversity.internal.garage.rpc.port;
guest.port = config.fediversity.internal.garage.rpc.port;
}
{
from = "host";
host.port = config.fediversity.internal.garage.web.port;
guest.port = config.fediversity.internal.garage.web.port;
}
]; ];
environment.systemPackages = [ pkgs.minio-client pkgs.awscli ];
networking.firewall.allowedTCPPorts = [ networking.firewall.allowedTCPPorts = [
fedicfg.rpc.port config.fediversity.internal.garage.rpc.port
config.fediversity.internal.garage.web.port
]; ];
services.garage = { services.garage = {
enable = true; enable = true;
@ -195,59 +160,30 @@ in
# TODO: use a secret file # TODO: use a secret file
rpc_secret = "d576c4478cc7d0d94cfc127138cbb82018b0155c037d1c827dfb6c36be5f6625"; rpc_secret = "d576c4478cc7d0d94cfc127138cbb82018b0155c037d1c827dfb6c36be5f6625";
# TODO: why does this have to be set? is there not a sensible default? # TODO: why does this have to be set? is there not a sensible default?
rpc_bind_addr = "[::]:${toString fedicfg.rpc.port}"; rpc_bind_addr = "[::]:${toString config.fediversity.internal.garage.rpc.port}";
rpc_public_addr = "[::1]:${toString fedicfg.rpc.port}"; rpc_public_addr = "[::1]:${toString config.fediversity.internal.garage.rpc.port}";
s3_api.api_bind_addr = "[::]:${toString fedicfg.api.port}"; s3_api.api_bind_addr = "[::]:${toString config.fediversity.internal.garage.api.port}";
s3_web.bind_addr = "[::]:${toString fedicfg.web.internalPort}"; s3_web.bind_addr = "[::]:${toString config.fediversity.internal.garage.web.port}";
s3_web.root_domain = ".${fedicfg.web.rootDomain}"; s3_web.root_domain = ".${config.fediversity.internal.garage.web.rootDomain}";
index = "index.html"; index = "index.html";
s3_api.s3_region = "garage"; s3_api.s3_region = "garage";
s3_api.root_domain = ".${fedicfg.api.domain}"; s3_api.root_domain = ".${config.fediversity.internal.garage.api.domain}";
}; };
}; };
## Create a proxy from <bucket>.web.garage.<domain> to localhost:3902 for
## each bucket that has `website = true`.
services.nginx.virtualHosts =
let
value = {
forceSSL = true;
enableACME = true;
locations."/" = {
proxyPass = "http://localhost:3902";
extraConfig = ''
## copied from https://garagehq.deuxfleurs.fr/documentation/cookbook/reverse-proxy/
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# Disable buffering to a temporary file.
proxy_max_temp_file_size 0;
'';
};
};
in
mapAttrs' (bucket: _: {
name = fedicfg.web.domainForBucket bucket;
inherit value;
}) (filterAttrs (_: { website, ... }: website) cfg.ensureBuckets);
systemd.services.ensure-garage = { systemd.services.ensure-garage = {
after = [ "garage.service" ]; after = [ "garage.service" ];
wantedBy = [ "garage.service" ]; wantedBy = [ "garage.service" ];
serviceConfig = { serviceConfig = {
Type = "oneshot"; Type = "oneshot";
}; };
path = [ path = [ cfg.package pkgs.perl pkgs.awscli ];
cfg.package
pkgs.perl
pkgs.awscli
];
script = '' script = ''
set -xeuo pipefail set -xeuo pipefail
# Give Garage time to start up by waiting until somethings speaks HTTP # Give Garage time to start up by waiting until somethings speaks HTTP
# behind Garage's API URL. # behind Garage's API URL.
until ${pkgs.curl}/bin/curl -sio /dev/null ${fedicfg.api.url}; do sleep 1; done until ${pkgs.curl}/bin/curl -sio /dev/null ${config.fediversity.internal.garage.api.url}; do sleep 1; done
# XXX: this is very sensitive to being a single instance # XXX: this is very sensitive to being a single instance
# (doing the bare minimum to get garage up and running) # (doing the bare minimum to get garage up and running)
@ -261,8 +197,7 @@ in
# XXX: this is a hack because we want to write to the buckets here but we're not guaranteed any access keys # XXX: this is a hack because we want to write to the buckets here but we're not guaranteed any access keys
# TODO: generate this key here rather than using a well-known key # TODO: generate this key here rather than using a well-known key
# TODO: if the key already exists, we get an error; hacked with this `|| :` which needs to be removed garage key import --yes -n tmp ${snakeoil_key.id} ${snakeoil_key.secret}
garage key import --yes -n tmp ${snakeoil_key.id} ${snakeoil_key.secret} || :
export AWS_ACCESS_KEY_ID=${snakeoil_key.id}; export AWS_ACCESS_KEY_ID=${snakeoil_key.id};
export AWS_SECRET_ACCESS_KEY=${snakeoil_key.secret}; export AWS_SECRET_ACCESS_KEY=${snakeoil_key.secret};

View file

@ -5,14 +5,10 @@ let
}; };
in in
{ { config, lib, pkgs, ... }:
config,
lib,
...
}:
lib.mkIf (config.fediversity.enable && config.fediversity.mastodon.enable) { lib.mkIf (config.fediversity.enable && config.fediversity.mastodon.enable) {
#### garage setup #### garage setup
services.garage = { services.garage = {
ensureBuckets = { ensureBuckets = {
mastodon = { mastodon = {
@ -50,7 +46,7 @@ lib.mkIf (config.fediversity.enable && config.fediversity.mastodon.enable) {
AWS_ACCESS_KEY_ID = snakeoil_key.id; AWS_ACCESS_KEY_ID = snakeoil_key.id;
AWS_SECRET_ACCESS_KEY = snakeoil_key.secret; AWS_SECRET_ACCESS_KEY = snakeoil_key.secret;
S3_PROTOCOL = "http"; S3_PROTOCOL = "http";
S3_HOSTNAME = config.fediversity.internal.garage.web.rootDomain; S3_HOSTNAME = config.fediversity.internal.garage.web.rootDomainAndPort;
# by default it tries to use "<S3_HOSTNAME>/<S3_BUCKET>" # by default it tries to use "<S3_HOSTNAME>/<S3_BUCKET>"
S3_ALIAS_HOST = "${S3_BUCKET}.${S3_HOSTNAME}"; S3_ALIAS_HOST = "${S3_BUCKET}.${S3_HOSTNAME}";
# SEE: the last section in https://docs.joinmastodon.org/admin/optional/object-storage/ # SEE: the last section in https://docs.joinmastodon.org/admin/optional/object-storage/
@ -61,11 +57,8 @@ lib.mkIf (config.fediversity.enable && config.fediversity.mastodon.enable) {
#### mastodon setup #### mastodon setup
# open up access to the mastodon web interface. 80 is necessary if only for ACME # open up access to the mastodon web interface
networking.firewall.allowedTCPPorts = [ networking.firewall.allowedTCPPorts = [ 443 ];
80
443
];
services.mastodon = { services.mastodon = {
enable = true; enable = true;
@ -73,10 +66,6 @@ lib.mkIf (config.fediversity.enable && config.fediversity.mastodon.enable) {
localDomain = config.fediversity.internal.mastodon.domain; localDomain = config.fediversity.internal.mastodon.domain;
configureNginx = true; configureNginx = true;
# from the documentation: recommended is the amount of your CPU cores minus
# one. but it also must be a positive integer
streamingProcesses = lib.max 1 (config.fediversity.temp.cores - 1);
# TODO: configure a mailserver so this works # TODO: configure a mailserver so this works
smtp = { smtp = {
fromAddress = "noreply@${config.fediversity.internal.mastodon.domain}"; fromAddress = "noreply@${config.fediversity.internal.mastodon.domain}";

View file

@ -5,17 +5,10 @@ let
}; };
in in
{ { config, lib, pkgs, ... }:
config,
lib,
...
}:
lib.mkIf (config.fediversity.enable && config.fediversity.peertube.enable) { lib.mkIf (config.fediversity.enable && config.fediversity.peertube.enable) {
networking.firewall.allowedTCPPorts = [ networking.firewall.allowedTCPPorts = [ 80 9000 ];
80
443
];
services.garage = { services.garage = {
ensureBuckets = { ensureBuckets = {
@ -29,7 +22,7 @@ lib.mkIf (config.fediversity.enable && config.fediversity.peertube.enable) {
allowedOrigins = [ "*" ]; allowedOrigins = [ "*" ];
}; };
}; };
# TODO: these are too broad, after getting everything works narrow it down to the domain we actually want # TODO: these are too broad, after getting everything works narrow it down to the domain we actually want
peertube-playlists = { peertube-playlists = {
website = true; website = true;
corsRules = { corsRules = {
@ -66,8 +59,7 @@ lib.mkIf (config.fediversity.enable && config.fediversity.peertube.enable) {
# TODO: in most of nixpkgs, these are true by default. upstream that unless there's a good reason not to. # TODO: in most of nixpkgs, these are true by default. upstream that unless there's a good reason not to.
redis.createLocally = true; redis.createLocally = true;
database.createLocally = true; database.createLocally = true;
configureNginx = true;
secrets.secretsFile = config.fediversity.temp.peertubeSecretsFile;
settings = { settings = {
object_storage = { object_storage = {
@ -75,24 +67,24 @@ lib.mkIf (config.fediversity.enable && config.fediversity.peertube.enable) {
endpoint = config.fediversity.internal.garage.api.url; endpoint = config.fediversity.internal.garage.api.url;
region = "garage"; region = "garage";
# not supported by garage # not supported by garage
# SEE: https://garagehq.deuxfleurs.fr/documentation/connect/apps/#peertube # SEE: https://garagehq.deuxfleurs.fr/documentation/connect/apps/#peertube
proxy.proxyify_private_files = false; proxy.proxyify_private_files = false;
web_videos = rec { web_videos = rec {
bucket_name = "peertube-videos"; bucket_name = "peertube-videos";
prefix = ""; prefix = "";
base_url = config.fediversity.internal.garage.web.urlForBucket bucket_name; base_url = config.fediversity.internal.garage.web.urlFor bucket_name;
}; };
videos = rec { videos = rec {
bucket_name = "peertube-videos"; bucket_name = "peertube-videos";
prefix = ""; prefix = "";
base_url = config.fediversity.internal.garage.web.urlForBucket bucket_name; base_url = config.fediversity.internal.garage.web.urlFor bucket_name;
}; };
streaming_playlists = rec { streaming_playlists = rec {
bucket_name = "peertube-playlists"; bucket_name = "peertube-playlists";
prefix = ""; prefix = "";
base_url = config.fediversity.internal.garage.web.urlForBucket bucket_name; base_url = config.fediversity.internal.garage.web.urlFor bucket_name;
}; };
}; };
}; };
@ -102,12 +94,4 @@ lib.mkIf (config.fediversity.enable && config.fediversity.peertube.enable) {
AWS_ACCESS_KEY_ID=${snakeoil_key.id} AWS_ACCESS_KEY_ID=${snakeoil_key.id}
AWS_SECRET_ACCESS_KEY=${snakeoil_key.secret} AWS_SECRET_ACCESS_KEY=${snakeoil_key.secret}
''; '';
## Proxying through Nginx
services.peertube.configureNginx = true;
services.nginx.virtualHosts.${config.services.peertube.localDomain} = {
forceSSL = true;
enableACME = true;
};
} }

View file

@ -5,12 +5,7 @@ let
}; };
in in
{ { config, lib, pkgs, ... }:
config,
lib,
pkgs,
...
}:
lib.mkIf (config.fediversity.enable && config.fediversity.pixelfed.enable) { lib.mkIf (config.fediversity.enable && config.fediversity.pixelfed.enable) {
services.garage = { services.garage = {
@ -43,37 +38,16 @@ lib.mkIf (config.fediversity.enable && config.fediversity.pixelfed.enable) {
services.pixelfed = { services.pixelfed = {
enable = true; enable = true;
domain = config.fediversity.internal.pixelfed.domain; domain = config.fediversity.internal.pixelfed.domain;
# TODO: secrets management!!!
secretFile = pkgs.writeText "secrets.env" ''
APP_KEY=adKK9EcY8Hcj3PLU7rzG9rJ6KKTOtYfA
'';
## Taeer feels like this way of configuring Nginx is odd; there should
## instead be a `services.pixefed.nginx.enable` option and the actual Nginx
## configuration should be in `services.nginx`. See eg. `pretix`.
##
## TODO: If that indeed makes sense, upstream.
nginx = {
forceSSL = true;
enableACME = true;
# locations."/public/".proxyPass = "${config.fediversity.internal.garage.web.urlForBucket "pixelfed"}/public/";
};
}; };
services.pixelfed.settings = { services.pixelfed.settings = {
## NOTE: This depends on the targets, eg. universities might want control
## over who has an account. We probably want a universal
## `fediversity.openRegistration` option.
OPEN_REGISTRATION = true;
# DANGEROUSLY_SET_FILESYSTEM_DRIVER = "s3"; # DANGEROUSLY_SET_FILESYSTEM_DRIVER = "s3";
FILESYSTEM_CLOUD = "s3"; FILESYSTEM_CLOUD = "s3";
PF_ENABLE_CLOUD = true; PF_ENABLE_CLOUD = true;
AWS_ACCESS_KEY_ID = snakeoil_key.id; AWS_ACCESS_KEY_ID = snakeoil_key.id;
AWS_SECRET_ACCESS_KEY = snakeoil_key.secret; AWS_SECRET_ACCESS_KEY = snakeoil_key.secret;
AWS_DEFAULT_REGION = "garage"; AWS_DEFAULT_REGION = "garage";
AWS_URL = config.fediversity.internal.garage.web.urlForBucket "pixelfed"; AWS_URL = config.fediversity.internal.garage.web.urlFor "pixelfed";
AWS_BUCKET = "pixelfed"; AWS_BUCKET = "pixelfed";
AWS_ENDPOINT = config.fediversity.internal.garage.api.url; AWS_ENDPOINT = config.fediversity.internal.garage.api.url;
AWS_USE_PATH_STYLE_ENDPOINT = false; AWS_USE_PATH_STYLE_ENDPOINT = false;
@ -85,8 +59,4 @@ lib.mkIf (config.fediversity.enable && config.fediversity.pixelfed.enable) {
after = [ "ensure-garage.service" ]; after = [ "ensure-garage.service" ];
}; };
networking.firewall.allowedTCPPorts = [
80
443
];
} }

View file

@ -1,151 +1,12 @@
{ {
"nodes": { "nodes": {
"disko": {
"inputs": {
"nixpkgs": "nixpkgs"
},
"locked": {
"lastModified": 1727347829,
"narHash": "sha256-y7cW6TjJKy+tu7efxeWI6lyg4VVx/9whx+OmrhmRShU=",
"owner": "nix-community",
"repo": "disko",
"rev": "1879e48907c14a70302ff5d0539c3b9b6f97feaa",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "disko",
"type": "github"
}
},
"flake-compat": {
"flake": false,
"locked": {
"lastModified": 1696426674,
"narHash": "sha256-kvjfFW7WAETZlt09AgDn1MrtKzP7t90Vf7vypd3OL1U=",
"owner": "edolstra",
"repo": "flake-compat",
"rev": "0f9255e01c2351cc7d116c072cb317785dd33b33",
"type": "github"
},
"original": {
"owner": "edolstra",
"repo": "flake-compat",
"type": "github"
}
},
"git-hooks": {
"inputs": {
"flake-compat": "flake-compat",
"gitignore": "gitignore",
"nixpkgs": "nixpkgs_2",
"nixpkgs-stable": "nixpkgs-stable"
},
"locked": {
"lastModified": 1730814269,
"narHash": "sha256-fWPHyhYE6xvMI1eGY3pwBTq85wcy1YXqdzTZF+06nOg=",
"owner": "cachix",
"repo": "git-hooks.nix",
"rev": "d70155fdc00df4628446352fc58adc640cd705c2",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "git-hooks.nix",
"type": "github"
}
},
"gitignore": {
"inputs": {
"nixpkgs": [
"git-hooks",
"nixpkgs"
]
},
"locked": {
"lastModified": 1709087332,
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
"owner": "hercules-ci",
"repo": "gitignore.nix",
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "gitignore.nix",
"type": "github"
}
},
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1725194671, "lastModified": 1723726852,
"narHash": "sha256-tLGCFEFTB5TaOKkpfw3iYT9dnk4awTP/q4w+ROpMfuw=", "narHash": "sha256-lRzlx4fPRtzA+dgz9Rh4WK5yAW3TsAXx335DQqxY2XY=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "b833ff01a0d694b910daca6e2ff4a3f26dee478c",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs-latest": {
"locked": {
"lastModified": 1727220152,
"narHash": "sha256-6ezRTVBZT25lQkvaPrfJSxYLwqcbNWm6feD/vG1FO0o=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "24959f933187217890b206788a85bfa73ba75949",
"type": "github"
},
"original": {
"owner": "nixos",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs-stable": {
"locked": {
"lastModified": 1730741070,
"narHash": "sha256-edm8WG19kWozJ/GqyYx2VjW99EdhjKwbY3ZwdlPAAlo=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "d063c1dd113c91ab27959ba540c0d9753409edf3",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-24.05",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1730768919,
"narHash": "sha256-8AKquNnnSaJRXZxc5YmF/WfmxiHX6MMZZasRP6RRQkE=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "a04d33c0c3f1a59a2c1cb0c6e34cd24500e5a1dc",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_3": {
"locked": {
"lastModified": 1730137230,
"narHash": "sha256-0kW6v0alzWIc/Dc/DoVZ7A9qNScv77bj/zYTKI67HZM=",
"owner": "radvendii", "owner": "radvendii",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "df815998652a1d00ce7c059a1e5ef7d7c0548c90", "rev": "9286249a1673cf5b14a4793e22dd44b70cb69a0d",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -155,30 +16,9 @@
"type": "github" "type": "github"
} }
}, },
"pixelfed": {
"flake": false,
"locked": {
"lastModified": 1719823820,
"narHash": "sha256-CKjqnxp7p2z/13zfp4HQ1OAmaoUtqBKS6HFm6TV8Jwg=",
"owner": "pixelfed",
"repo": "pixelfed",
"rev": "4c245cf429330d01fcb8ebeb9aa8c84a9574a645",
"type": "github"
},
"original": {
"owner": "pixelfed",
"ref": "v0.12.3",
"repo": "pixelfed",
"type": "github"
}
},
"root": { "root": {
"inputs": { "inputs": {
"disko": "disko", "nixpkgs": "nixpkgs"
"git-hooks": "git-hooks",
"nixpkgs": "nixpkgs_3",
"nixpkgs-latest": "nixpkgs-latest",
"pixelfed": "pixelfed"
} }
} }
}, },

210
flake.nix
View file

@ -1,143 +1,93 @@
{ {
description = "Testing mastodon configurations";
inputs = { inputs = {
nixpkgs.url = "github:radvendii/nixpkgs/nixos_rebuild_tests"; nixpkgs.url = "github:radvendii/nixpkgs/nixos_rebuild_tests";
nixpkgs-latest.url = "github:nixos/nixpkgs";
git-hooks.url = "github:cachix/git-hooks.nix";
pixelfed = {
url = "github:pixelfed/pixelfed?ref=v0.12.3";
flake = false;
};
disko.url = "github:nix-community/disko";
}; };
outputs = outputs = inputs@{ self, nixpkgs }:
{ let
self, system = "x86_64-linux";
nixpkgs, pkgs = nixpkgs.legacyPackages.${system};
nixpkgs-latest, in {
git-hooks,
pixelfed,
disko,
}:
let
system = "x86_64-linux";
lib = nixpkgs.lib;
pkgs = nixpkgs.legacyPackages.${system};
pkgsLatest = nixpkgs-latest.legacyPackages.${system};
bleedingFediverseOverlay = (
_: _: {
pixelfed = pkgsLatest.pixelfed.overrideAttrs (old: {
src = pixelfed;
patches = (old.patches or [ ]) ++ [ ./fediversity/pixelfed-group-permissions.patch ];
});
## TODO: give mastodon, peertube the same treatment
}
);
in
{
nixosModules = {
## Bleeding-edge fediverse packages
bleedingFediverse = {
nixpkgs.overlays = [ bleedingFediverseOverlay ];
};
## Fediversity modules
fediversity = import ./fediversity;
## VM-specific modules packages.${system} = {
interactive-vm = import ./vm/interactive-vm.nix; pixelfed = pkgs.pixelfed.overrideAttrs (old: {
garage-vm = import ./vm/garage-vm.nix; patches = (old.patches or [ ]) ++ [ ./fediversity/pixelfed-group-permissions.patch ];
mastodon-vm = import ./vm/mastodon-vm.nix; });
peertube-vm = import ./vm/peertube-vm.nix; };
pixelfed-vm = import ./vm/pixelfed-vm.nix;
disk-layout = import ./disk-layout.nix; nixosModules = {
## Fediversity modules
fediversity = { pkgs, ... }: {
imports = [ ./fediversity ];
services.pixelfed.package = self.packages.${pkgs.stdenv.hostPlatform.system}.pixelfed;
}; };
nixosConfigurations = { ## VM-specific modules
mastodon = nixpkgs.lib.nixosSystem { interactive-vm = {
inherit system; imports = [
modules = with self.nixosModules; [ ./vm/interactive-vm.nix
disko.nixosModules.default self.nixosModules.fediversity
disk-layout ];
bleedingFediverse };
fediversity mastodon-vm = {
interactive-vm imports = [
garage-vm ./vm/mastodon-vm.nix
mastodon-vm self.nixosModules.fediversity
]; ];
}; };
peertube-vm = {
peertube = nixpkgs.lib.nixosSystem { imports = [
inherit system; ./vm/peertube-vm.nix
modules = with self.nixosModules; [ self.nixosModules.fediversity
disko.nixosModules.default ];
disk-layout };
bleedingFediverse pixelfed-vm = {
fediversity imports = [
interactive-vm ./vm/pixelfed-vm.nix
garage-vm self.nixosModules.fediversity
peertube-vm
];
};
pixelfed = nixpkgs.lib.nixosSystem {
inherit system;
modules = with self.nixosModules; [
disko.nixosModules.default
disk-layout
bleedingFediverse
fediversity
interactive-vm
garage-vm
pixelfed-vm
];
};
all = nixpkgs.lib.nixosSystem {
inherit system;
modules = with self.nixosModules; [
disko.nixosModules.default
disk-layout
bleedingFediverse
fediversity
interactive-vm
garage-vm
peertube-vm
pixelfed-vm
mastodon-vm
];
};
};
## Fully-feature ISO installer
mkInstaller = import ./installer.nix;
installers = lib.mapAttrs (_: config: self.mkInstaller nixpkgs config) self.nixosConfigurations;
deploy =
let
deployCommand = (pkgs.callPackage ./deploy.nix { });
in
lib.mapAttrs (name: config: deployCommand name config) self.nixosConfigurations;
checks.${system} = {
mastodon-garage = import ./tests/mastodon-garage.nix { inherit pkgs self; };
pixelfed-garage = import ./tests/pixelfed-garage.nix { inherit pkgs self; };
pre-commit = git-hooks.lib.${system}.run {
src = ./.;
hooks = {
nixfmt-rfc-style.enable = true;
deadnix.enable = true;
};
};
};
devShells.${system}.default = pkgs.mkShell {
inputs = with pkgs; [
nil
]; ];
shellHook = self.checks.${system}.pre-commit.shellHook;
}; };
}; };
nixosConfigurations = {
mastodon = nixpkgs.lib.nixosSystem {
inherit system;
modules = with self.nixosModules; [ fediversity interactive-vm mastodon-vm ];
};
peertube = nixpkgs.lib.nixosSystem {
inherit system;
modules = with self.nixosModules; [ fediversity interactive-vm peertube-vm ];
};
pixelfed = nixpkgs.lib.nixosSystem {
inherit system;
modules = with self.nixosModules; [ fediversity interactive-vm pixelfed-vm ];
};
all = nixpkgs.lib.nixosSystem {
inherit system;
modules = with self.nixosModules; [
fediversity
interactive-vm
peertube-vm
pixelfed-vm
mastodon-vm
];
};
};
checks.${system} = {
mastodon-garage = import ./tests/mastodon-garage.nix { inherit pkgs self; };
pixelfed-garage = import ./tests/pixelfed-garage.nix { inherit pkgs self; };
};
devShells.${system}.default = pkgs.mkShell {
inputs = with pkgs; [
nil
];
};
};
} }

View file

@ -1,61 +0,0 @@
/**
Convert a NixOS configuration to one for a minimal installer ISO
WARNING: Running this installer will format the target disk!
*/
{
nixpkgs,
hostKeys ? { },
}:
machine:
let
inherit (builtins) concatStringsSep attrValues mapAttrs;
installer =
{
config,
pkgs,
lib,
...
}:
let
bootstrap = pkgs.writeShellApplication {
name = "bootstrap";
runtimeInputs = with pkgs; [ nixos-install-tools ];
text = ''
${machine.config.system.build.diskoScript}
nixos-install --no-root-password --no-channel-copy --system ${machine.config.system.build.toplevel}
${concatStringsSep "\n" (
attrValues (
mapAttrs (kind: keys: ''
cp ${keys.private} /mnt/etc/ssh/ssh_host_${kind}_key
chmod 600 /mnt/etc/ssh/ssh_host_${kind}_key
cp ${keys.public} /mnt/etc/ssh/ssh_host_${kind}_key.pub
chmod 644 /mnt/etc/ssh/ssh_host_${kind}_key.pub
'') hostKeys
)
)}
poweroff
'';
};
in
{
imports = [
"${nixpkgs}/nixos/modules/installer/cd-dvd/installation-cd-minimal.nix"
];
nixpkgs.hostPlatform = "x86_64-linux";
services.getty.autologinUser = lib.mkForce "root";
programs.bash.loginShellInit = nixpkgs.lib.getExe bootstrap;
isoImage = {
compressImage = false;
squashfsCompression = "lz4";
isoName = lib.mkForce "installer.iso";
## ^^ FIXME: Use a more interesting name or keep the default name and
## use `isoImage.isoName` in the tests.
};
};
in
(nixpkgs.lib.nixosSystem { modules = [ installer ]; }).config.system.build.isoImage

View file

@ -1,153 +1,139 @@
{ pkgs, self }: { pkgs, self }:
let let
lib = pkgs.lib; lib = pkgs.lib;
rebuildableTest = import ./rebuildableTest.nix pkgs;
seleniumScript = pkgs.writers.writePython3Bin "selenium-script"
{
libraries = with pkgs.python3Packages; [ selenium ];
} ''
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.firefox.options import Options
from selenium.webdriver.support.ui import WebDriverWait
## FIXME: this binding was not used, but maybe we want a side-effect or something? print(1)
# rebuildableTest = import ./rebuildableTest.nix pkgs;
seleniumScript = options = Options()
pkgs.writers.writePython3Bin "selenium-script" options.add_argument("--headless")
{ # devtools don't show up in headless screenshots
libraries = with pkgs.python3Packages; [ selenium ]; # options.add_argument("-devtools")
} service = webdriver.FirefoxService(executable_path="${lib.getExe pkgs.geckodriver}") # noqa: E501
''
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.firefox.options import Options
from selenium.webdriver.support.ui import WebDriverWait
print(1) driver = webdriver.Firefox(options=options, service=service)
driver.get("http://mastodon.localhost:55001/public/local")
options = Options() # wait until the statuses load
options.add_argument("--headless") WebDriverWait(driver, 90).until(
# devtools don't show up in headless screenshots lambda x: x.find_element(By.CLASS_NAME, "status"))
# options.add_argument("-devtools")
service = webdriver.FirefoxService(executable_path="${lib.getExe pkgs.geckodriver}") # noqa: E501
driver = webdriver.Firefox(options=options, service=service) driver.save_screenshot("/mastodon-screenshot.png")
driver.get("http://mastodon.localhost:55001/public/local")
# wait until the statuses load driver.close()
WebDriverWait(driver, 90).until( '';
lambda x: x.find_element(By.CLASS_NAME, "status"))
driver.save_screenshot("/mastodon-screenshot.png")
driver.close()
'';
in in
pkgs.nixosTest { pkgs.nixosTest {
name = "test-mastodon-garage"; name = "test-mastodon-garage";
nodes = { nodes = {
server = server = { config, ... }: {
{ config, ... }: virtualisation.memorySize = lib.mkVMOverride 4096;
{ imports = with self.nixosModules; [ mastodon-vm ];
virtualisation.memorySize = lib.mkVMOverride 4096; # TODO: pair down
imports = with self.nixosModules; [ environment.systemPackages = with pkgs; [
bleedingFediverse python3
fediversity firefox-unwrapped
garage-vm geckodriver
mastodon-vm toot
]; xh
# TODO: pair down seleniumScript
environment.systemPackages = with pkgs; [ helix
python3 imagemagick
firefox-unwrapped ];
geckodriver environment.variables = {
toot POST_MEDIA = ./green.png;
xh AWS_ACCESS_KEY_ID = config.services.garage.ensureKeys.mastodon.id;
seleniumScript AWS_SECRET_ACCESS_KEY = config.services.garage.ensureKeys.mastodon.secret;
helix
imagemagick
];
environment.variables = {
POST_MEDIA = ./green.png;
AWS_ACCESS_KEY_ID = config.services.garage.ensureKeys.mastodon.id;
AWS_SECRET_ACCESS_KEY = config.services.garage.ensureKeys.mastodon.secret;
};
}; };
};
}; };
testScript = testScript = { nodes, ... }: ''
{ nodes, ... }: import re
'' import time
import re
import time
server.start() server.start()
with subtest("Mastodon starts"): with subtest("Mastodon starts"):
server.wait_for_unit("mastodon-web.service") server.wait_for_unit("mastodon-web.service")
# make sure mastodon is fully up and running before we interact with it # make sure mastodon is fully up and running before we interact with it
# TODO: is there a way to test for this? # TODO: is there a way to test for this?
time.sleep(180) time.sleep(180)
with subtest("Account creation"): with subtest("Account creation"):
account_creation_output = server.succeed("mastodon-tootctl accounts create test --email test@test.com --confirmed --approve") account_creation_output = server.succeed("mastodon-tootctl accounts create test --email test@test.com --confirmed --approve")
password_match = re.match('.*New password: ([^\n]*).*', account_creation_output, re.S) password_match = re.match('.*New password: ([^\n]*).*', account_creation_output, re.S)
if password_match is None: if password_match is None:
raise Exception(f"account creation did not generate a password.\n{account_creation_output}") raise Exception(f"account creation did not generate a password.\n{account_creation_output}")
password = password_match.group(1) password = password_match.group(1)
with subtest("TTY Login"): with subtest("TTY Login"):
server.wait_until_tty_matches("1", "login: ") server.wait_until_tty_matches("1", "login: ")
server.send_chars("root\n"); server.send_chars("root\n");
with subtest("Log in with toot"): with subtest("Log in with toot"):
# toot doesn't provide a way to just specify our login details as arguments, so we have to pretend we're typing them in at the prompt # toot doesn't provide a way to just specify our login details as arguments, so we have to pretend we're typing them in at the prompt
server.send_chars("toot login_cli --instance http://mastodon.localhost:55001 --email test@test.com\n") server.send_chars("toot login_cli --instance http://mastodon.localhost:55001 --email test@test.com\n")
server.wait_until_tty_matches("1", "Password: ") server.wait_until_tty_matches("1", "Password: ")
server.send_chars(password + "\n") server.send_chars(password + "\n")
server.wait_until_tty_matches("1", "Successfully logged in.") server.wait_until_tty_matches("1", "Successfully logged in.")
with subtest("post text"): with subtest("post text"):
server.succeed("echo 'hello mastodon' | toot post") server.succeed("echo 'hello mastodon' | toot post")
with subtest("post image"): with subtest("post image"):
server.succeed("toot post --media $POST_MEDIA") server.succeed("toot post --media $POST_MEDIA")
with subtest("access garage"): with subtest("access garage"):
server.succeed("mc alias set garage ${nodes.server.fediversity.internal.garage.api.url} --api s3v4 --path off $AWS_ACCESS_KEY_ID $AWS_SECRET_ACCESS_KEY") server.succeed("mc alias set garage ${nodes.server.fediversity.internal.garage.api.url} --api s3v4 --path off $AWS_ACCESS_KEY_ID $AWS_SECRET_ACCESS_KEY")
server.succeed("mc ls garage/mastodon") server.succeed("mc ls garage/mastodon")
with subtest("access image in garage"): with subtest("access image in garage"):
image = server.succeed("mc find garage --regex original") image = server.succeed("mc find garage --regex original")
image = image.rstrip() image = image.rstrip()
if image == "": if image == "":
raise Exception("image posted to mastodon did not get stored in garage") raise Exception("image posted to mastodon did not get stored in garage")
server.succeed(f"mc cat {image} >/garage-image.webp") server.succeed(f"mc cat {image} >/garage-image.webp")
garage_image_hash = server.succeed("identify -quiet -format '%#' /garage-image.webp") garage_image_hash = server.succeed("identify -quiet -format '%#' /garage-image.webp")
image_hash = server.succeed("identify -quiet -format '%#' $POST_MEDIA") image_hash = server.succeed("identify -quiet -format '%#' $POST_MEDIA")
if garage_image_hash != image_hash: if garage_image_hash != image_hash:
raise Exception("image stored in garage did not match image uploaded") raise Exception("image stored in garage did not match image uploaded")
with subtest("Content security policy allows garage images"): with subtest("Content security policy allows garage images"):
headers = server.succeed("xh -h http://mastodon.localhost:55001/public/local") headers = server.succeed("xh -h http://mastodon.localhost:55001/public/local")
csp_match = None csp_match = None
# I can't figure out re.MULTILINE # I can't figure out re.MULTILINE
for header in headers.split("\n"): for header in headers.split("\n"):
csp_match = re.match('^Content-Security-Policy: (.*)$', header) csp_match = re.match('^Content-Security-Policy: (.*)$', header)
if csp_match is not None: if csp_match is not None:
break break
if csp_match is None: if csp_match is None:
raise Exception("mastodon did not send a content security policy header") raise Exception("mastodon did not send a content security policy header")
csp = csp_match.group(1) csp = csp_match.group(1)
# the img-src content security policy should include the garage server # the img-src content security policy should include the garage server
## TODO: use `nodes.server.fediversity.internal.garage.api.url` same as above, but beware of escaping the regex. Be careful with port 80 though. ## TODO: use `nodes.server.fediversity.internal.garage.api.url` same as above, but beware of escaping the regex.
garage_csp = re.match(".*; img-src[^;]*web\.garage\.localhost.*", csp) garage_csp = re.match(".*; img-src[^;]*web\.garage\.localhost:3902.*", csp)
if garage_csp is None: if garage_csp is None:
raise Exception("Mastodon's content security policy does not include garage server. image will not be displayed properly on mastodon.") raise Exception("Mastodon's content security policy does not include garage server. image will not be displayed properly on mastodon.")
# this could in theory give a false positive if mastodon changes it's colorscheme to include pure green. # this could in theory give a false positive if mastodon changes it's colorscheme to include pure green.
with subtest("image displays"): with subtest("image displays"):
server.succeed("selenium-script") server.succeed("selenium-script")
server.copy_from_vm("/mastodon-screenshot.png", "") server.copy_from_vm("/mastodon-screenshot.png", "")
displayed_colors = server.succeed("convert /mastodon-screenshot.png -define histogram:unique-colors=true -format %c histogram:info:") displayed_colors = server.succeed("convert /mastodon-screenshot.png -define histogram:unique-colors=true -format %c histogram:info:")
# check that the green image displayed somewhere # check that the green image displayed somewhere
green_check = re.match(".*#00FF00.*", displayed_colors, re.S) green_check = re.match(".*#00FF00.*", displayed_colors, re.S)
if green_check is None: if green_check is None:
raise Exception("cannot detect the uploaded image on mastodon page.") raise Exception("cannot detect the uploaded image on mastodon page.")
''; '';
} }

View file

@ -1,9 +1,7 @@
{ pkgs, self }: { pkgs, self }:
let let
lib = pkgs.lib; lib = pkgs.lib;
rebuildableTest = import ./rebuildableTest.nix pkgs;
## FIXME: this binding was not used but maybe we want a side effect or something?
# rebuildableTest = import ./rebuildableTest.nix pkgs;
email = "test@test.com"; email = "test@test.com";
password = "testtest"; password = "testtest";
@ -52,176 +50,159 @@ let
driver.quit() driver.quit()
''; '';
seleniumScriptPostPicture = seleniumScriptPostPicture = pkgs.writers.writePython3Bin "selenium-script-post-picture"
pkgs.writers.writePython3Bin "selenium-script-post-picture" {
{ libraries = with pkgs.python3Packages; [ selenium ];
libraries = with pkgs.python3Packages; [ selenium ]; } ''
} import os
'' import time
import os ${seleniumImports}
import time from selenium.webdriver.support.wait import WebDriverWait
${seleniumImports}
from selenium.webdriver.support.wait import WebDriverWait
${seleniumSetup} ${seleniumSetup}
${seleniumPixelfedLogin} ${seleniumPixelfedLogin}
time.sleep(3) time.sleep(3)
media_path = os.environ['POST_MEDIA'] media_path = os.environ['POST_MEDIA']
# Find the new post form, fill it in with our pictureand a caption. # Find the new post form, fill it in with our pictureand a caption.
print("Click on Create New Post...", file=sys.stderr) print("Click on Create New Post...", file=sys.stderr)
driver.find_element(By.LINK_TEXT, "Create New Post").click() driver.find_element(By.LINK_TEXT, "Create New Post").click()
print("Add file to input element...", file=sys.stderr) print("Add file to input element...", file=sys.stderr)
driver.find_element(By.XPATH, "//input[@type='file']").send_keys(media_path) driver.find_element(By.XPATH, "//input[@type='file']").send_keys(media_path)
print("Add a caption", file=sys.stderr) print("Add a caption", file=sys.stderr)
driver.find_element(By.CSS_SELECTOR, ".media-body textarea").send_keys( driver.find_element(By.CSS_SELECTOR, ".media-body textarea").send_keys(
"Fediversity test of image upload to pixelfed with garage storage." "Fediversity test of image upload to pixelfed with garage storage."
) )
time.sleep(3) time.sleep(3)
print("Click on Post button...", file=sys.stderr) print("Click on Post button...", file=sys.stderr)
driver.find_element(By.LINK_TEXT, "Post").click() driver.find_element(By.LINK_TEXT, "Post").click()
# Wait until the post loads, and in particular its picture, then take a # Wait until the post loads, and in particular its picture, then take a
# screenshot of the whole page. # screenshot of the whole page.
print("Wait for post and image to be loaded...", file=sys.stderr) print("Wait for post and image to be loaded...", file=sys.stderr)
img = driver.find_element( img = driver.find_element(
By.XPATH, By.XPATH,
"//div[@class='timeline-status-component-content']//img" "//div[@class='timeline-status-component-content']//img"
) )
WebDriverWait(driver, timeout=10).until( WebDriverWait(driver, timeout=10).until(
lambda d: d.execute_script("return arguments[0].complete", img) lambda d: d.execute_script("return arguments[0].complete", img)
) )
time.sleep(3) time.sleep(3)
${seleniumTakeScreenshot "\"/home/selenium/screenshot.png\""} ${seleniumTakeScreenshot "\"/home/selenium/screenshot.png\""}
${seleniumQuit}''; ${seleniumQuit}'';
seleniumScriptGetSrc = seleniumScriptGetSrc = pkgs.writers.writePython3Bin "selenium-script-get-src"
pkgs.writers.writePython3Bin "selenium-script-get-src" {
{ libraries = with pkgs.python3Packages; [ selenium ];
libraries = with pkgs.python3Packages; [ selenium ]; } ''
} ${seleniumImports}
'' ${seleniumSetup}
${seleniumImports} ${seleniumPixelfedLogin}
${seleniumSetup}
${seleniumPixelfedLogin}
img = driver.find_element( img = driver.find_element(
By.XPATH, By.XPATH,
"//div[@class='timeline-status-component-content']//img" "//div[@class='timeline-status-component-content']//img"
) )
# REVIEW: Need to wait for it to be loaded? # REVIEW: Need to wait for it to be loaded?
print(img.get_attribute('src')) print(img.get_attribute('src'))
${seleniumQuit}''; ${seleniumQuit}'';
in in
pkgs.nixosTest { pkgs.nixosTest {
name = "test-pixelfed-garage"; name = "test-pixelfed-garage";
nodes = { nodes = {
server = server = { config, ... }: {
{ config, ... }:
{
services = { services = {
xserver = { xserver = {
enable = true; enable = true;
displayManager.lightdm.enable = true; displayManager.lightdm.enable = true;
desktopManager.lxqt.enable = true; desktopManager.lxqt.enable = true;
};
displayManager.autoLogin = {
enable = true;
user = "selenium";
};
};
virtualisation.resolution = {
x = 1680;
y = 1050;
}; };
virtualisation = { displayManager.autoLogin = {
memorySize = lib.mkVMOverride 8192; enable = true;
cores = 8; user = "selenium";
};
imports = with self.nixosModules; [
bleedingFediverse
fediversity
garage-vm
pixelfed-vm
];
# TODO: pair down
environment.systemPackages = with pkgs; [
python3
chromium
chromedriver
xh
seleniumScriptPostPicture
seleniumScriptGetSrc
helix
imagemagick
];
environment.variables = {
POST_MEDIA = ./fediversity.png;
AWS_ACCESS_KEY_ID = config.services.garage.ensureKeys.pixelfed.id;
AWS_SECRET_ACCESS_KEY = config.services.garage.ensureKeys.pixelfed.secret;
## without this we get frivolous errors in the logs
MC_REGION = "garage";
};
# chrome does not like being run as root
users.users.selenium = {
isNormalUser = true;
}; };
}; };
virtualisation.resolution = { x = 1680; y = 1050; };
virtualisation = {
memorySize = lib.mkVMOverride 8192;
cores = 8;
};
imports = with self.nixosModules; [ pixelfed-vm ];
# TODO: pair down
environment.systemPackages = with pkgs; [
python3
chromium
chromedriver
xh
seleniumScriptPostPicture
seleniumScriptGetSrc
helix
imagemagick
];
environment.variables = {
POST_MEDIA = ./fediversity.png;
AWS_ACCESS_KEY_ID = config.services.garage.ensureKeys.pixelfed.id;
AWS_SECRET_ACCESS_KEY = config.services.garage.ensureKeys.pixelfed.secret;
};
# chrome does not like being run as root
users.users.selenium = {
isNormalUser = true;
};
};
}; };
testScript = testScript = { nodes, ... }: ''
{ nodes, ... }: import re
''
import re
server.start() server.start()
with subtest("Pixelfed starts"): with subtest("Pixelfed starts"):
server.wait_for_unit("phpfpm-pixelfed.service") server.wait_for_unit("phpfpm-pixelfed.service")
with subtest("Account creation"): with subtest("Account creation"):
server.succeed("pixelfed-manage user:create --name=test --username=test --email=${email} --password=${password} --confirm_email=1") server.succeed("pixelfed-manage user:create --name=test --username=test --email=${email} --password=${password} --confirm_email=1")
# NOTE: This could in theory give a false positive if pixelfed changes it's # NOTE: This could in theory give a false positive if pixelfed changes it's
# colorscheme to include pure green. (see same problem in pixelfed-garage.nix). # colorscheme to include pure green. (see same problem in pixelfed-garage.nix).
# TODO: For instance: post a red image and check that the green pixel IS NOT # TODO: For instance: post a red image and check that the green pixel IS NOT
# there, then post a green image and check that the green pixel IS there. # there, then post a green image and check that the green pixel IS there.
with subtest("Image displays"): with subtest("Image displays"):
server.succeed("su - selenium -c 'selenium-script-post-picture ${email} ${password}'") server.succeed("su - selenium -c 'selenium-script-post-picture ${email} ${password}'")
server.copy_from_vm("/home/selenium/screenshot.png", "") server.copy_from_vm("/home/selenium/screenshot.png", "")
displayed_colors = server.succeed("magick /home/selenium/screenshot.png -define histogram:unique-colors=true -format %c histogram:info:") displayed_colors = server.succeed("magick /home/selenium/screenshot.png -define histogram:unique-colors=true -format %c histogram:info:")
# check that the green image displayed somewhere # check that the green image displayed somewhere
image_check = re.match(".*#FF0500.*", displayed_colors, re.S) image_check = re.match(".*#FF0500.*", displayed_colors, re.S)
if image_check is None: if image_check is None:
raise Exception("cannot detect the uploaded image on pixelfed page.") raise Exception("cannot detect the uploaded image on pixelfed page.")
with subtest("access garage"): with subtest("access garage"):
server.succeed("mc alias set garage ${nodes.server.fediversity.internal.garage.api.url} --api s3v4 --path off $AWS_ACCESS_KEY_ID $AWS_SECRET_ACCESS_KEY") server.succeed("mc alias set garage ${nodes.server.fediversity.internal.garage.api.url} --api s3v4 --path off $AWS_ACCESS_KEY_ID $AWS_SECRET_ACCESS_KEY")
server.succeed("mc ls garage/pixelfed") server.succeed("mc ls garage/pixelfed")
with subtest("access image in garage"): with subtest("access image in garage"):
image = server.succeed("mc find garage --regex '\\.png' --ignore '*_thumb.png'") image = server.succeed("mc find garage --regex '\\.png' --ignore '*_thumb.png'")
image = image.rstrip() image = image.rstrip()
if image == "": if image == "":
raise Exception("image posted to Pixelfed did not get stored in garage") raise Exception("image posted to Pixelfed did not get stored in garage")
server.succeed(f"mc cat {image} >/garage-image.png") server.succeed(f"mc cat {image} >/garage-image.png")
garage_image_hash = server.succeed("identify -quiet -format '%#' /garage-image.png") garage_image_hash = server.succeed("identify -quiet -format '%#' /garage-image.png")
image_hash = server.succeed("identify -quiet -format '%#' $POST_MEDIA") image_hash = server.succeed("identify -quiet -format '%#' $POST_MEDIA")
if garage_image_hash != image_hash: if garage_image_hash != image_hash:
raise Exception("image stored in garage did not match image uploaded") raise Exception("image stored in garage did not match image uploaded")
with subtest("Check that image comes from garage"): with subtest("Check that image comes from garage"):
src = server.succeed("su - selenium -c 'selenium-script-get-src ${email} ${password}'") src = server.succeed("su - selenium -c 'selenium-script-get-src ${email} ${password}'")
if not src.startswith("${nodes.server.fediversity.internal.garage.web.urlForBucket "pixelfed"}"): if not src.startswith("${nodes.server.fediversity.internal.garage.web.urlFor "pixelfed"}"):
raise Exception("image does not come from garage") raise Exception("image does not come from garage")
''; '';
} }

View file

@ -1,42 +1,32 @@
pkgs: test: pkgs: test:
let let
inherit (pkgs.lib) inherit (pkgs.lib) mapAttrsToList concatStringsSep genAttrs mkIf;
mapAttrsToList
concatStringsSep
genAttrs
mkIf
;
inherit (builtins) attrNames; inherit (builtins) attrNames;
interactiveConfig = ( interactiveConfig = ({ config, ... }: {
{ config, ... }: # so we can run `nix shell nixpkgs#foo` on the machines
{ nix.extraOptions = ''
# so we can run `nix shell nixpkgs#foo` on the machines extra-experimental-features = nix-command flakes
nix.extraOptions = '' '';
extra-experimental-features = nix-command flakes
'';
# so we can ssh in and rebuild them # so we can ssh in and rebuild them
services.openssh = { services.openssh = {
enable = true; enable = true;
settings = { settings = {
PermitRootLogin = "yes"; PermitRootLogin = "yes";
PermitEmptyPasswords = "yes"; PermitEmptyPasswords = "yes";
UsePAM = false; UsePAM = false;
};
}; };
};
virtualisation = mkIf (config.networking.hostName == "jumphost") { virtualisation = mkIf (config.networking.hostName == "jumphost") {
forwardPorts = [ forwardPorts = [{
{ from = "host";
from = "host"; host.port = 2222;
host.port = 2222; guest.port = 22;
guest.port = 22; }];
} };
]; });
};
}
);
sshConfig = pkgs.writeText "ssh-config" '' sshConfig = pkgs.writeText "ssh-config" ''
Host * Host *
@ -60,11 +50,10 @@ let
# create an association array from machine names to the path to their # create an association array from machine names to the path to their
# configuration in the nix store # configuration in the nix store
declare -A configPaths=(${ declare -A configPaths=(${
concatStringsSep " " ( concatStringsSep " "
mapAttrsToList ( (mapAttrsToList
n: v: ''["${n}"]="${v.system.build.toplevel}"'' (n: v: ''["${n}"]="${v.system.build.toplevel}"'')
) rebuildableTest.driverInteractive.nodes rebuildableTest.driverInteractive.nodes)
)
}) })
rebuild_one() { rebuild_one() {
@ -124,40 +113,37 @@ let
# we're at it) # we're at it)
rebuildableTest = rebuildableTest =
let let
preOverride = pkgs.nixosTest ( preOverride = pkgs.nixosTest (test // {
test interactive = (test.interactive or { }) // {
// { # no need to // with test.interactive.nodes here, since we are iterating
interactive = (test.interactive or { }) // { # over all of them, and adding back in the config via `imports`
# no need to // with test.interactive.nodes here, since we are iterating nodes = genAttrs
# over all of them, and adding back in the config via `imports` (
nodes = attrNames test.nodes or { } ++
genAttrs (attrNames test.nodes or { } ++ attrNames test.interactive.nodes or { } ++ [ "jumphost" ]) attrNames test.interactive.nodes or { } ++
(n: { [ "jumphost" ]
imports = [ )
(test.interactive.${n} or { }) (n: {
interactiveConfig imports = [
]; (test.interactive.${n} or { })
}); interactiveConfig
}; ];
# override with test.passthru in case someone wants to overwrite us. });
passthru = { };
inherit rebuildScript sshConfig; # override with test.passthru in case someone wants to overwrite us.
} // (test.passthru or { }); passthru = { inherit rebuildScript sshConfig; } // (test.passthru or { });
} });
);
in in
preOverride preOverride // {
// {
driverInteractive = preOverride.driverInteractive.overrideAttrs (old: { driverInteractive = preOverride.driverInteractive.overrideAttrs (old: {
# this comes from runCommand, not mkDerivation, so this is the only # this comes from runCommand, not mkDerivation, so this is the only
# hook we have to override # hook we have to override
buildCommand = buildCommand = old.buildCommand + ''
old.buildCommand ln -s ${sshConfig} $out/ssh-config
+ '' ln -s ${rebuildScript}/bin/rebuild $out/bin/rebuild
ln -s ${sshConfig} $out/ssh-config '';
ln -s ${rebuildScript}/bin/rebuild $out/bin/rebuild
'';
}); });
}; };
in in
rebuildableTest rebuildableTest

View file

@ -1,44 +0,0 @@
{
lib,
config,
modulesPath,
...
}:
let
inherit (lib) mkVMOverride mapAttrs' filterAttrs;
cfg = config.services.garage;
fedicfg = config.fediversity.internal.garage;
in
{
imports = [ (modulesPath + "/virtualisation/qemu-vm.nix") ];
services.nginx.virtualHosts =
let
value = {
forceSSL = mkVMOverride false;
enableACME = mkVMOverride false;
};
in
mapAttrs' (bucket: _: {
name = fedicfg.web.domainForBucket bucket;
inherit value;
}) (filterAttrs (_: { website, ... }: website) cfg.ensureBuckets);
virtualisation.diskSize = 2048;
virtualisation.forwardPorts = [
{
from = "host";
host.port = fedicfg.rpc.port;
guest.port = fedicfg.rpc.port;
}
{
from = "host";
host.port = fedicfg.web.internalPort;
guest.port = fedicfg.web.internalPort;
}
];
}

View file

@ -1,6 +1,5 @@
# customize nixos-rebuild build-vm to be a bit more convenient # customize nixos-rebuild build-vm to be a bit more convenient
{ pkgs, ... }: { pkgs, ... }: {
{
# let us log in # let us log in
users.mutableUsers = false; users.mutableUsers = false;
users.users.root.hashedPassword = ""; users.users.root.hashedPassword = "";
@ -35,10 +34,7 @@
# no graphics. see nixos-shell # no graphics. see nixos-shell
virtualisation = { virtualisation = {
graphics = false; graphics = false;
qemu.consoles = [ qemu.consoles = [ "tty0" "hvc0" ];
"tty0"
"hvc0"
];
qemu.options = [ qemu.options = [
"-serial null" "-serial null"
"-device virtio-serial" "-device virtio-serial"
@ -48,19 +44,12 @@
]; ];
}; };
# we can't forward port 80 or 443, so let's run nginx on a different port # we can't forward port 80 or 443, so let's run nginx on a different port
networking.firewall.allowedTCPPorts = [ networking.firewall.allowedTCPPorts = [ 8443 8080 ];
8443
8080
];
services.nginx.defaultSSLListenPort = 8443; services.nginx.defaultSSLListenPort = 8443;
services.nginx.defaultHTTPListenPort = 8080; services.nginx.defaultHTTPListenPort = 8080;
virtualisation.forwardPorts = [ virtualisation.forwardPorts = [
{
from = "host";
host.port = 22222;
guest.port = 22;
}
{ {
from = "host"; from = "host";
host.port = 8080; host.port = 8080;

View file

@ -1,12 +1,8 @@
{ { modulesPath, lib, config, ... }: {
modulesPath,
lib,
config,
...
}:
{
imports = [ (modulesPath + "/virtualisation/qemu-vm.nix") ]; imports = [
(modulesPath + "/virtualisation/qemu-vm.nix")
];
config = lib.mkMerge [ config = lib.mkMerge [
{ {
@ -14,17 +10,19 @@
enable = true; enable = true;
domain = "localhost"; domain = "localhost";
mastodon.enable = true; mastodon.enable = true;
temp.cores = config.virtualisation.cores;
}; };
services.mastodon = { services.mastodon = {
extraConfig = { extraConfig = {
EMAIL_DOMAIN_ALLOWLIST = "example.com"; EMAIL_DOMAIN_ALLOWLIST = "example.com";
}; };
# from the documentation: recommended is the amount of your CPU cores
# minus one. but it also must be a positive integer
streamingProcesses = lib.max 1 (config.virtualisation.cores - 1);
}; };
security.acme = lib.mkVMOverride { security.acme = {
defaults = { defaults = {
# invalid server; the systemd service will fail, and we won't get # invalid server; the systemd service will fail, and we won't get
# properly signed certificates. but let's not spam the letsencrypt # properly signed certificates. but let's not spam the letsencrypt

View file

@ -1,8 +1,8 @@
{ modulesPath, ... }: { pkgs, modulesPath, ... }: {
{ imports = [
(modulesPath + "/virtualisation/qemu-vm.nix")
imports = [ (modulesPath + "/virtualisation/qemu-vm.nix") ]; ];
services.peertube = { services.peertube = {
enableWebHttps = false; enableWebHttps = false;
@ -10,6 +10,10 @@
listen.hostname = "0.0.0.0"; listen.hostname = "0.0.0.0";
instance.name = "PeerTube Test VM"; instance.name = "PeerTube Test VM";
}; };
# TODO: use agenix
secrets.secretsFile = pkgs.writeText "secret" ''
574e093907d1157ac0f8e760a6deb1035402003af5763135bae9cbd6abe32b24
'';
}; };
virtualisation.forwardPorts = [ virtualisation.forwardPorts = [

View file

@ -1,16 +1,8 @@
{ { pkgs, modulesPath, ... }: {
lib,
modulesPath,
...
}:
let imports = [
inherit (lib) mkVMOverride; (modulesPath + "/virtualisation/qemu-vm.nix")
];
in
{
imports = [ (modulesPath + "/virtualisation/qemu-vm.nix") ];
fediversity = { fediversity = {
enable = true; enable = true;
@ -18,16 +10,22 @@ in
pixelfed.enable = true; pixelfed.enable = true;
}; };
networking.firewall.allowedTCPPorts = [ 80 ];
services.pixelfed = { services.pixelfed = {
# TODO: secrets management!
secretFile = pkgs.writeText "secrets.env" ''
APP_KEY=adKK9EcY8Hcj3PLU7rzG9rJ6KKTOtYfA
'';
settings = { settings = {
OPEN_REGISTRATION = true;
FORCE_HTTPS_URLS = false; FORCE_HTTPS_URLS = false;
}; };
# I feel like this should have an `enable` option and be configured via `services.nginx` rather than mirroring those options in services.pixelfed.nginx
# TODO: If that indeed makes sense, upstream it.
nginx = { nginx = {
forceSSL = mkVMOverride false; # locations."/public/".proxyPass = "${config.fediversity.internal.garage.web.urlFor "pixelfed"}/public/";
enableACME = mkVMOverride false;
}; };
}; };
virtualisation.memorySize = 2048; virtualisation.memorySize = 2048;
virtualisation.forwardPorts = [ virtualisation.forwardPorts = [
{ {