diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix
index cbd57fad6094..c4ee28a95930 100644
--- a/nixos/modules/module-list.nix
+++ b/nixos/modules/module-list.nix
@@ -779,6 +779,7 @@
./services/web-apps/tt-rss.nix
./services/web-apps/selfoss.nix
./services/web-apps/virtlyst.nix
+ ./services/web-apps/wordpress.nix
./services/web-apps/youtrack.nix
./services/web-servers/apache-httpd/default.nix
./services/web-servers/caddy.nix
diff --git a/nixos/modules/services/web-apps/wordpress.nix b/nixos/modules/services/web-apps/wordpress.nix
new file mode 100644
index 000000000000..624b0089a037
--- /dev/null
+++ b/nixos/modules/services/web-apps/wordpress.nix
@@ -0,0 +1,371 @@
+{ config, pkgs, lib, ... }:
+
+let
+ inherit (lib) mkDefault mkEnableOption mkForce mkIf mkMerge mkOption types;
+ inherit (lib) any attrValues concatMapStringsSep flatten literalExample;
+ inherit (lib) mapAttrs' mapAttrsToList nameValuePair optional optionalAttrs optionalString;
+
+ eachSite = config.services.wordpress;
+ user = "wordpress";
+ group = config.services.httpd.group;
+ stateDir = hostName: "/var/lib/wordpress/${hostName}";
+
+ pkg = hostName: cfg: pkgs.stdenv.mkDerivation rec {
+ pname = "wordpress-${hostName}";
+ version = src.version;
+ src = cfg.package;
+
+ installPhase = ''
+ mkdir -p $out
+ cp -r * $out/
+
+ # symlink the wordpress config
+ ln -s ${wpConfig hostName cfg} $out/share/wordpress/wp-config.php
+ # symlink uploads directory
+ ln -s ${cfg.uploadsDir} $out/share/wordpress/wp-content/uploads
+
+ # https://github.com/NixOS/nixpkgs/pull/53399
+ #
+ # Symlinking works for most plugins and themes, but Avada, for instance, fails to
+ # understand the symlink, causing its file path stripping to fail. This results in
+ # requests that look like: https://example.com/wp-content//nix/store/...plugin/path/some-file.js
+ # Since hard linking directories is not allowed, copying is the next best thing.
+
+ # copy additional plugin(s) and theme(s)
+ ${concatMapStringsSep "\n" (theme: "cp -r ${theme} $out/share/wordpress/wp-content/themes/${theme.name}") cfg.themes}
+ ${concatMapStringsSep "\n" (plugin: "cp -r ${plugin} $out/share/wordpress/wp-content/plugins/${plugin.name}") cfg.plugins}
+ '';
+ };
+
+ wpConfig = hostName: cfg: pkgs.writeText "wp-config-${hostName}.php" ''
+
+ '';
+
+ siteOpts = { lib, name, ... }:
+ {
+ options = {
+ package = mkOption {
+ type = types.package;
+ default = pkgs.wordpress;
+ description = "Which WordPress package to use.";
+ };
+
+ uploadsDir = mkOption {
+ type = types.path;
+ default = "/var/lib/wordpress/${name}/uploads";
+ description = ''
+ This directory is used for uploads of pictures. The directory passed here is automatically
+ created and permissions adjusted as required.
+ '';
+ };
+
+ plugins = mkOption {
+ type = types.listOf types.path;
+ default = [];
+ description = ''
+ List of path(s) to respective plugin(s) which are copied from the 'plugins' directory.
+ These plugins need to be packaged before use, see example.
+ '';
+ example = ''
+ # Wordpress plugin 'embed-pdf-viewer' installation example
+ embedPdfViewerPlugin = pkgs.stdenv.mkDerivation {
+ name = "embed-pdf-viewer-plugin";
+ # Download the theme from the wordpress site
+ src = pkgs.fetchurl {
+ url = https://downloads.wordpress.org/plugin/embed-pdf-viewer.2.0.3.zip;
+ sha256 = "1rhba5h5fjlhy8p05zf0p14c9iagfh96y91r36ni0rmk6y891lyd";
+ };
+ # We need unzip to build this package
+ buildInputs = [ pkgs.unzip ];
+ # Installing simply means copying all files to the output directory
+ installPhase = "mkdir -p $out; cp -R * $out/";
+ };
+
+ And then pass this theme to the themes list like this:
+ plugins = [ embedPdfViewerPlugin ];
+ '';
+ };
+
+ themes = mkOption {
+ type = types.listOf types.path;
+ default = [];
+ description = ''
+ List of path(s) to respective theme(s) which are copied from the 'theme' directory.
+ These themes need to be packaged before use, see example.
+ '';
+ example = ''
+ # For shits and giggles, let's package the responsive theme
+ responsiveTheme = pkgs.stdenv.mkDerivation {
+ name = "responsive-theme";
+ # Download the theme from the wordpress site
+ src = pkgs.fetchurl {
+ url = https://downloads.wordpress.org/theme/responsive.3.14.zip;
+ sha256 = "0rjwm811f4aa4q43r77zxlpklyb85q08f9c8ns2akcarrvj5ydx3";
+ };
+ # We need unzip to build this package
+ buildInputs = [ pkgs.unzip ];
+ # Installing simply means copying all files to the output directory
+ installPhase = "mkdir -p $out; cp -R * $out/";
+ };
+
+ And then pass this theme to the themes list like this:
+ themes = [ responsiveTheme ];
+ '';
+ };
+
+ database = rec {
+ host = mkOption {
+ type = types.str;
+ default = "localhost";
+ description = "Database host address.";
+ };
+
+ port = mkOption {
+ type = types.port;
+ default = 3306;
+ description = "Database host port.";
+ };
+
+ name = mkOption {
+ type = types.str;
+ default = "wordpress";
+ description = "Database name.";
+ };
+
+ user = mkOption {
+ type = types.str;
+ default = "wordpress";
+ description = "Database user.";
+ };
+
+ passwordFile = mkOption {
+ type = types.nullOr types.path;
+ default = null;
+ example = "/run/keys/wordpress-dbpassword";
+ description = ''
+ A file containing the password corresponding to
+ .
+ '';
+ };
+
+ tablePrefix = mkOption {
+ type = types.str;
+ default = "wp_";
+ description = ''
+ The $table_prefix is the value placed in the front of your database tables.
+ Change the value if you want to use something other than wp_ for your database
+ prefix. Typically this is changed if you are installing multiple WordPress blogs
+ in the same database.
+
+ See .
+ '';
+ };
+
+ socket = mkOption {
+ type = types.nullOr types.path;
+ default = null;
+ defaultText = "/run/mysqld/mysqld.sock";
+ description = "Path to the unix socket file to use for authentication.";
+ };
+
+ createLocally = mkOption {
+ type = types.bool;
+ default = true;
+ description = "Create the database and database user locally.";
+ };
+ };
+
+ virtualHost = mkOption {
+ type = types.submodule ({
+ options = import ../web-servers/apache-httpd/per-server-options.nix {
+ inherit lib;
+ forMainServer = false;
+ };
+ });
+ example = literalExample ''
+ {
+ enableSSL = true;
+ adminAddr = "webmaster@example.org";
+ sslServerCert = "/var/lib/acme/wordpress.example.org/full.pem";
+ sslServerKey = "/var/lib/acme/wordpress.example.org/key.pem";
+ }
+ '';
+ description = ''
+ Apache configuration can be done by adapting .
+ '';
+ };
+
+ poolConfig = mkOption {
+ type = types.lines;
+ default = ''
+ pm = dynamic
+ pm.max_children = 32
+ pm.start_servers = 2
+ pm.min_spare_servers = 2
+ pm.max_spare_servers = 4
+ pm.max_requests = 500
+ '';
+ description = ''
+ Options for the WordPress PHP pool. See the documentation on php-fpm.conf
+ for details on configuration directives.
+ '';
+ };
+
+ extraConfig = mkOption {
+ type = types.lines;
+ default = "";
+ description = ''
+ Any additional text to be appended to the wp-config.php
+ configuration file. This is a PHP script. For configuration
+ settings, see .
+ '';
+ example = ''
+ define( 'AUTOSAVE_INTERVAL', 60 ); // Seconds
+ '';
+ };
+ };
+
+ config.virtualHost.hostName = mkDefault name;
+ };
+in
+{
+ # interface
+ options = {
+ services.wordpress = mkOption {
+ type = types.attrsOf (types.submodule siteOpts);
+ default = {};
+ description = "Specification of one or more WordPress sites to serve via Apache.";
+ };
+ };
+
+ # implementation
+ config = mkIf (eachSite != {}) {
+
+ assertions = mapAttrsToList (hostName: cfg:
+ { assertion = cfg.database.createLocally -> cfg.database.user == user;
+ message = "services.wordpress.${hostName}.database.user must be ${user} if the database is to be automatically provisioned";
+ }
+ ) eachSite;
+
+ services.mysql = mkIf (any (v: v.database.createLocally) (attrValues eachSite)) {
+ enable = true;
+ package = mkDefault pkgs.mariadb;
+ ensureDatabases = mapAttrsToList (hostName: cfg: cfg.database.name) eachSite;
+ ensureUsers = mapAttrsToList (hostName: cfg:
+ { name = cfg.database.user;
+ ensurePermissions = { "${cfg.database.name}.*" = "ALL PRIVILEGES"; };
+ }
+ ) eachSite;
+ };
+
+ services.phpfpm.pools = mapAttrs' (hostName: cfg: (
+ nameValuePair "wordpress-${hostName}" {
+ listen = "/run/phpfpm/wordpress-${hostName}.sock";
+ extraConfig = ''
+ listen.owner = ${config.services.httpd.user}
+ listen.group = ${config.services.httpd.group}
+ user = ${user}
+ group = ${group}
+
+ ${cfg.poolConfig}
+ '';
+ }
+ )) eachSite;
+
+ services.httpd = {
+ enable = true;
+ extraModules = [ "proxy_fcgi" ];
+ virtualHosts = mapAttrsToList (hostName: cfg:
+ (mkMerge [
+ cfg.virtualHost {
+ documentRoot = mkForce "${pkg hostName cfg}/share/wordpress";
+ extraConfig = ''
+
+
+
+ SetHandler "proxy:unix:/run/phpfpm/wordpress-${hostName}.sock|fcgi://localhost/"
+
+
+
+ # standard wordpress .htaccess contents
+
+ RewriteEngine On
+ RewriteBase /
+ RewriteRule ^index\.php$ - [L]
+ RewriteCond %{REQUEST_FILENAME} !-f
+ RewriteCond %{REQUEST_FILENAME} !-d
+ RewriteRule . /index.php [L]
+
+
+ DirectoryIndex index.php
+ Require all granted
+ Options +FollowSymLinks
+
+
+ # https://wordpress.org/support/article/hardening-wordpress/#securing-wp-config-php
+
+ Require all denied
+
+ '';
+ }
+ ])
+ ) eachSite;
+ };
+
+ systemd.tmpfiles.rules = flatten (mapAttrsToList (hostName: cfg: [
+ "d '${stateDir hostName}' 0750 ${user} ${group} - -"
+ "d '${cfg.uploadsDir}' 0750 ${user} ${group} - -"
+ "Z '${cfg.uploadsDir}' 0750 ${user} ${group} - -"
+ ]) eachSite);
+
+ systemd.services = mkMerge [
+ (mapAttrs' (hostName: cfg: (
+ nameValuePair "wordpress-init-${hostName}" {
+ wantedBy = [ "multi-user.target" ];
+ before = [ "phpfpm-wordpress-${hostName}.service" ];
+ after = optional cfg.database.createLocally "mysql.service";
+ script = ''
+ if ! test -e "${stateDir hostName}/secret-keys.php"; then
+ echo "> "${stateDir hostName}/secret-keys.php"
+ ${pkgs.curl}/bin/curl -s https://api.wordpress.org/secret-key/1.1/salt/ >> "${stateDir hostName}/secret-keys.php"
+ echo "?>" >> "${stateDir hostName}/secret-keys.php"
+ chmod 440 "${stateDir hostName}/secret-keys.php"
+ fi
+ '';
+
+ serviceConfig = {
+ Type = "oneshot";
+ User = user;
+ Group = group;
+ };
+ })) eachSite)
+
+ (optionalAttrs (any (v: v.database.createLocally) (attrValues eachSite)) {
+ httpd.after = [ "mysql.service" ];
+ })
+ ];
+
+ users.users.${user}.group = group;
+
+ };
+}
diff --git a/nixos/modules/services/web-servers/apache-httpd/wordpress.nix b/nixos/modules/services/web-servers/apache-httpd/wordpress.nix
deleted file mode 100644
index 3dddda138fed..000000000000
--- a/nixos/modules/services/web-servers/apache-httpd/wordpress.nix
+++ /dev/null
@@ -1,285 +0,0 @@
-{ config, lib, pkgs, serverInfo, ... }:
-# http://codex.wordpress.org/Hardening_WordPress
-
-with lib;
-
-let
- # Our bare-bones wp-config.php file using the above settings
- wordpressConfig = pkgs.writeText "wp-config.php" ''
-
- RewriteEngine On
- RewriteBase /
- RewriteRule ^index\.php$ - [L]
-
- # add a trailing slash to /wp-admin
- RewriteRule ^wp-admin$ wp-admin/ [R=301,L]
-
- RewriteCond %{REQUEST_FILENAME} -f [OR]
- RewriteCond %{REQUEST_FILENAME} -d
- RewriteRule ^ - [L]
- RewriteRule ^(wp-(content|admin|includes).*) $1 [L]
- RewriteRule ^(.*\.php)$ $1 [L]
- RewriteRule . index.php [L]
-
-
- ${config.extraHtaccess}
- '';
-
- # WP translation can be found here:
- # https://github.com/nixcloud/wordpress-translations
- supportedLanguages = {
- en_GB = { revision="d6c005372a5318fd758b710b77a800c86518be13"; sha256="0qbbsi87k47q4rgczxx541xz4z4f4fr49hw4lnaxkdsf5maz8p9p"; };
- de_DE = { revision="3c62955c27baaae98fd99feb35593d46562f4736"; sha256="1shndgd11dk836dakrjlg2arwv08vqx6j4xjh4jshvwmjab6ng6p"; };
- zh_ZN = { revision="12b9f811e8cae4b6ee41de343d35deb0a8fdda6d"; sha256="1339ggsxh0g6lab37jmfxicsax4h702rc3fsvv5azs7mcznvwh47"; };
- fr_FR = { revision="688c8b1543e3d38d9e8f57e0a6f2a2c3c8b588bd"; sha256="1j41iak0i6k7a4wzyav0yrllkdjjskvs45w53db8vfm8phq1n014"; };
- };
-
- downloadLanguagePack = language: revision: sha256s:
- pkgs.stdenv.mkDerivation rec {
- name = "wp_${language}";
- src = pkgs.fetchFromGitHub {
- owner = "nixcloud";
- repo = "wordpress-translations";
- rev = revision;
- sha256 = sha256s;
- };
- installPhase = "mkdir -p $out; cp -R * $out/";
- };
-
- selectedLanguages = map (lang: downloadLanguagePack lang supportedLanguages.${lang}.revision supportedLanguages.${lang}.sha256) (config.languages);
-
- # The wordpress package itself
- wordpressRoot = pkgs.stdenv.mkDerivation rec {
- name = "wordpress";
- src = config.package;
- installPhase = ''
- mkdir -p $out
- # copy all the wordpress files we downloaded
- cp -R * $out/
-
- # symlink the wordpress config
- ln -s ${wordpressConfig} $out/wp-config.php
- # symlink custom .htaccess
- ln -s ${htaccess} $out/.htaccess
- # symlink uploads directory
- ln -s ${config.wordpressUploads} $out/wp-content/uploads
-
- # remove bundled plugins(s) coming with wordpress
- rm -Rf $out/wp-content/plugins/*
- # remove bundled themes(s) coming with wordpress
- rm -Rf $out/wp-content/themes/*
-
- # copy additional theme(s)
- ${concatMapStrings (theme: "cp -r ${theme} $out/wp-content/themes/${theme.name}\n") config.themes}
- # copy additional plugin(s)
- ${concatMapStrings (plugin: "cp -r ${plugin} $out/wp-content/plugins/${plugin.name}\n") (config.plugins) }
-
- # symlink additional translation(s)
- mkdir -p $out/wp-content/languages
- ${concatMapStrings (language: "ln -s ${language}/*.mo ${language}/*.po $out/wp-content/languages/\n") (selectedLanguages) }
- '';
- };
-
-in
-
-{
-
- # And some httpd extraConfig to make things work nicely
- extraConfig = ''
-
- DirectoryIndex index.php
- Allow from *
- Options FollowSymLinks
- AllowOverride All
-
- '';
-
- enablePHP = true;
-
- options = {
- package = mkOption {
- type = types.path;
- default = pkgs.wordpress;
- description = ''
- Path to the wordpress sources.
- Upgrading? We have a test! nix-build ./nixos/tests/wordpress.nix
- '';
- };
- dbHost = mkOption {
- default = "localhost";
- description = "The location of the database server.";
- example = "localhost";
- };
- dbName = mkOption {
- default = "wordpress";
- description = "Name of the database that holds the Wordpress data.";
- example = "localhost";
- };
- dbUser = mkOption {
- default = "wordpress";
- description = "The dbUser, read: the username, for the database.";
- example = "wordpress";
- };
- dbPassword = mkOption {
- default = "wordpress";
- description = ''
- The mysql password to the respective dbUser.
-
- Warning: this password is stored in the world-readable Nix store. It's
- recommended to use the $dbPasswordFile option since that gives you control over
- the security of the password. $dbPasswordFile also takes precedence over $dbPassword.
- '';
- example = "wordpress";
- };
- dbPasswordFile = mkOption {
- type = types.str;
- default = toString (pkgs.writeTextFile {
- name = "wordpress-dbpassword";
- text = config.dbPassword;
- });
- example = "/run/keys/wordpress-dbpassword";
- description = ''
- Path to a file that contains the mysql password to the respective dbUser.
- The file should be readable by the user: config.services.httpd.user.
-
- $dbPasswordFile takes precedence over the $dbPassword option.
-
- This defaults to a file in the world-readable Nix store that contains the value
- of the $dbPassword option. It's recommended to override this with a path not in
- the Nix store. Tip: use nixops key management:
-
- '';
- };
- tablePrefix = mkOption {
- default = "wp_";
- description = ''
- The $table_prefix is the value placed in the front of your database tables. Change the value if you want to use something other than wp_ for your database prefix. Typically this is changed if you are installing multiple WordPress blogs in the same database. See .
- '';
- };
- wordpressUploads = mkOption {
- default = "/data/uploads";
- description = ''
- This directory is used for uploads of pictures and must be accessible (read: owned) by the httpd running user. The directory passed here is automatically created and permissions are given to the httpd running user.
- '';
- };
- plugins = mkOption {
- default = [];
- type = types.listOf types.path;
- description =
- ''
- List of path(s) to respective plugin(s) which are symlinked from the 'plugins' directory. Note: These plugins need to be packaged before use, see example.
- '';
- example = ''
- # Wordpress plugin 'akismet' installation example
- akismetPlugin = pkgs.stdenv.mkDerivation {
- name = "akismet-plugin";
- # Download the theme from the wordpress site
- src = pkgs.fetchurl {
- url = https://downloads.wordpress.org/plugin/akismet.3.1.zip;
- sha256 = "1i4k7qyzna08822ncaz5l00wwxkwcdg4j9h3z2g0ay23q640pclg";
- };
- # We need unzip to build this package
- buildInputs = [ pkgs.unzip ];
- # Installing simply means copying all files to the output directory
- installPhase = "mkdir -p $out; cp -R * $out/";
- };
-
- And then pass this theme to the themes list like this:
- plugins = [ akismetPlugin ];
- '';
- };
- themes = mkOption {
- default = [];
- type = types.listOf types.path;
- description =
- ''
- List of path(s) to respective theme(s) which are symlinked from the 'theme' directory. Note: These themes need to be packaged before use, see example.
- '';
- example = ''
- # For shits and giggles, let's package the responsive theme
- responsiveTheme = pkgs.stdenv.mkDerivation {
- name = "responsive-theme";
- # Download the theme from the wordpress site
- src = pkgs.fetchurl {
- url = http://wordpress.org/themes/download/responsive.1.9.7.6.zip;
- sha256 = "06i26xlc5kdnx903b1gfvnysx49fb4kh4pixn89qii3a30fgd8r8";
- };
- # We need unzip to build this package
- buildInputs = [ pkgs.unzip ];
- # Installing simply means copying all files to the output directory
- installPhase = "mkdir -p $out; cp -R * $out/";
- };
-
- And then pass this theme to the themes list like this:
- themes = [ responsiveTheme ];
- '';
- };
- languages = mkOption {
- default = [];
- description = "Installs wordpress language packs based on the list, see wordpress.nix for possible translations.";
- example = "[ \"en_GB\" \"de_DE\" ];";
- };
- extraConfig = mkOption {
- type = types.lines;
- default = "";
- example =
- ''
- define( 'AUTOSAVE_INTERVAL', 60 ); // Seconds
- '';
- description = ''
- Any additional text to be appended to Wordpress's wp-config.php
- configuration file. This is a PHP script. For configuration
- settings, see .
- '';
- };
- extraHtaccess = mkOption {
- default = "";
- example =
- ''
- php_value upload_max_filesize 20M
- php_value post_max_size 20M
- '';
- description = ''
- Any additional text to be appended to Wordpress's .htaccess file.
- '';
- };
- };
-
- documentRoot = wordpressRoot;
-
- # FIXME adding the user has to be done manually for the time being
- startupScript = pkgs.writeScript "init-wordpress.sh" ''
- #!/bin/sh
- mkdir -p ${config.wordpressUploads}
- chown ${serverInfo.serverConfig.user} ${config.wordpressUploads}
-
- # we should use systemd dependencies here
- if [ ! -d ${serverInfo.fullConfig.services.mysql.dataDir}/${config.dbName} ]; then
- echo "Need to create the database '${config.dbName}' and grant permissions to user named '${config.dbUser}'."
- # Wait until MySQL is up
- while [ ! -S /run/mysqld/mysqld.sock ]; do
- sleep 1
- done
- ${pkgs.mysql}/bin/mysql -e 'CREATE DATABASE ${config.dbName};'
- ${pkgs.mysql}/bin/mysql -e "GRANT ALL ON ${config.dbName}.* TO ${config.dbUser}@localhost IDENTIFIED BY \"$(cat ${config.dbPasswordFile})\";"
- else
- echo "Good, no need to do anything database related."
- fi
- '';
-}
diff --git a/nixos/tests/wordpress.nix b/nixos/tests/wordpress.nix
index 5003e25a7d5b..774ef6293b51 100644
--- a/nixos/tests/wordpress.nix
+++ b/nixos/tests/wordpress.nix
@@ -6,48 +6,37 @@ import ./make-test.nix ({ pkgs, ... }:
maintainers = [ grahamc ]; # under duress!
};
- nodes =
- { web =
- { pkgs, ... }:
- {
- services.mysql = {
- enable = true;
- package = pkgs.mysql;
- };
- services.httpd = {
- enable = true;
- logPerVirtualHost = true;
- adminAddr="js@lastlog.de";
+ machine =
+ { ... }:
+ { services.httpd.adminAddr = "webmaster@site.local";
+ services.httpd.logPerVirtualHost = true;
- virtualHosts = [
- {
- hostName = "wordpress";
- extraSubservices =
- [
- {
- serviceType = "wordpress";
- dbPassword = "wordpress";
- dbHost = "127.0.0.1";
- languages = [ "de_DE" "en_GB" ];
- }
- ];
- }
- ];
- };
- };
+ services.wordpress."site1.local" = {
+ database.tablePrefix = "site1_";
+ };
+
+ services.wordpress."site2.local" = {
+ database.tablePrefix = "site2_";
+ };
+
+ networking.hosts."127.0.0.1" = [ "site1.local" "site2.local" ];
+
+ # required for wordpress-init.service to succeed
+ systemd.tmpfiles.rules = [
+ "F /var/lib/wordpress/site1.local/secret-keys.php 0440 wordpress wwwrun - -"
+ "F /var/lib/wordpress/site2.local/secret-keys.php 0440 wordpress wwwrun - -"
+ ];
};
- testScript =
- { ... }:
- ''
- startAll;
+ testScript = ''
+ startAll;
- $web->waitForUnit("mysql");
- $web->waitForUnit("httpd");
+ $machine->waitForUnit("httpd");
+ $machine->waitForUnit("phpfpm-wordpress-site1.local");
+ $machine->waitForUnit("phpfpm-wordpress-site2.local");
- $web->succeed("curl -L 127.0.0.1:80 | grep 'Welcome to the famous'");
-
-
- '';
+ $machine->succeed("curl -L site1.local | grep 'Welcome to the famous'");
+ $machine->succeed("curl -L site2.local | grep 'Welcome to the famous'");
+ '';
})