diff --git a/config/workspace/global.yml b/config/workspace/global.yml index a1d7f55b..3099183f 100644 --- a/config/workspace/global.yml +++ b/config/workspace/global.yml @@ -1,9 +1,9 @@ -command('global service logger (enable|disable)', 'global service logger'): | +command('global service logger (enable|disable|restart)', 'global service logger'): | #!bash|= ws-service logger ={input.command(4)} -command('global service mail (enable|disable)', 'global service mail'): +command('global service mail (enable|disable|restart)', 'global service mail'): description: Actions for mailhog (mail.my127.site) exec: | #!bash|= @@ -15,6 +15,94 @@ command('global service proxy (enable|disable|restart)', 'global service proxy') #!bash|= ws-service proxy ={input.command(4)} +command('global service proxy config domain list', 'global service proxy config domain list'): + description: List configured Global Proxy domains + exec: | + #!php(cwd:/) + use my127\Workspace\GlobalService\Proxy\ProxyDomainConfiguration; + use my127\Workspace\GlobalService\Proxy\ProxyDomainCommand; + + $domains = ProxyDomainConfiguration::assertDomainMap($ws['global.service.proxy.domains'] ?? []); + + ProxyDomainCommand::domainList($domains); + +command('global service proxy config domain add --name= --crt= --key= [--crt-file=] [--key-file=]', 'global service proxy config domain add'): + description: Register a Global Proxy domain + exec: | + #!php(cwd:/) + use my127\Workspace\GlobalService\Proxy\ProxyDomainConfiguration; + use my127\Workspace\GlobalService\Proxy\ProxyDomainCommand; + + $domains = ProxyDomainConfiguration::assertDomainMap($ws['global.service.proxy.domains'] ?? []); + + ProxyDomainCommand::domainAdd($domains, $input, home()); + +command('global service proxy config domain update --name= --crt= --key= [--crt-file=] [--key-file=]', 'global service proxy config domain update'): + description: Replace a registered Global Proxy domain + exec: | + #!php(cwd:/) + use my127\Workspace\GlobalService\Proxy\ProxyDomainConfiguration; + use my127\Workspace\GlobalService\Proxy\ProxyDomainCommand; + + $domains = ProxyDomainConfiguration::assertDomainMap($ws['global.service.proxy.domains'] ?? []); + + ProxyDomainCommand::domainUpdate($domains, $input, home(), $ws); + +command('global service proxy config domain remove ', 'global service proxy config domain remove'): + description: Remove a registered Global Proxy domain + exec: | + #!php(cwd:/) + use my127\Workspace\GlobalService\Proxy\ProxyDomainConfiguration; + use my127\Workspace\GlobalService\Proxy\ProxyDomainCommand; + + $domains = ProxyDomainConfiguration::assertDomainMap($ws['global.service.proxy.domains'] ?? []); + + ProxyDomainCommand::domainRemove($domains, $input, home(), $ws); + +command('global service proxy config domain import ', 'global service proxy config domain import'): + description: Import registered Global Proxy domains + exec: | + #!php(cwd:/) + use my127\Workspace\GlobalService\Proxy\ProxyDomainConfiguration; + use my127\Workspace\GlobalService\Proxy\ProxyDomainCommand; + + $domains = ProxyDomainConfiguration::assertDomainMap($ws['global.service.proxy.domains'] ?? []); + + ProxyDomainCommand::domainImport($domains, $input, home()); + +command('global service proxy config rule []', 'global service proxy config rule'): + description: Print a Traefik Host rule for Global Proxy domains + exec: | + #!php(cwd:/) + use my127\Workspace\GlobalService\Proxy\ProxyDomainConfiguration; + use my127\Workspace\GlobalService\Proxy\ProxyRuntimeCommand; + + $domains = ProxyDomainConfiguration::assertDomainMap($ws['global.service.proxy.domains'] ?? []); + + ProxyRuntimeCommand::rule($domains, $input); + +command('global service proxy config tls [--output=]', 'global service proxy config tls'): + description: Print Traefik TLS configuration + exec: | + #!php(cwd:/) + use my127\Workspace\GlobalService\Proxy\ProxyDomainConfiguration; + use my127\Workspace\GlobalService\Proxy\ProxyRuntimeCommand; + + $domains = ProxyDomainConfiguration::assertDomainMap($ws['global.service.proxy.domains'] ?? []); + + ProxyRuntimeCommand::tls($domains, $input); + +command('global service proxy config certificates download ', 'global service proxy config certificates download'): + description: Download configured Global Proxy certificates + exec: | + #!php(cwd:/) + use my127\Workspace\GlobalService\Proxy\ProxyDomainConfiguration; + use my127\Workspace\GlobalService\Proxy\ProxyRuntimeCommand; + + $domains = ProxyDomainConfiguration::assertDomainMap($ws['global.service.proxy.domains'] ?? []); + + ProxyRuntimeCommand::downloadCertificates($domains, $input, home()); + command('global service tracing (start|stop|restart)', 'global service tracing'): | #!bash|= ws-service tracing ={input.command(4)} diff --git a/docs/cheatsheet.md b/docs/cheatsheet.md index f7061466..2784b6aa 100644 --- a/docs/cheatsheet.md +++ b/docs/cheatsheet.md @@ -10,6 +10,19 @@ You need only to restart the Traefik proxy service: ws global service proxy restart ``` +## Custom proxy domains + +```bash +ws global service proxy config domain add mydomain \ + --name=mydomain.site \ + --crt= \ + --key= +ws global service proxy restart +``` + +See [Custom Global Proxy domains](custom-proxy-domains.md) for import, update, +remove, and project configuration examples. + ## How-to receive email The email service is not running by default. It can be started with: @@ -20,4 +33,4 @@ ws global service mail enable This will allow email to be viewed at `https://mail.my127.site/` -This will collect email sent from any server through native `sendmail`. \ No newline at end of file +This will collect email sent from any server through native `sendmail`. diff --git a/docs/custom-proxy-domains.md b/docs/custom-proxy-domains.md new file mode 100644 index 00000000..0857f13d --- /dev/null +++ b/docs/custom-proxy-domains.md @@ -0,0 +1,146 @@ +# Custom Global Proxy domains + +Workspace uses `my127.site` by default for local HTTPS hostnames. You can also +register extra Global Proxy domains on your machine, so different projects can +use different DNS suffixes while sharing the same Traefik proxy. + +## Contents + +- [Register a domain](#register-a-domain) +- [Host the proxy configuration files](#host-the-proxy-configuration-files) +- [Use a domain in a project](#use-a-domain-in-a-project) + +Registered domains are stored in: + +```text +~/.config/my127/workspace/proxy-domains.yml +``` + +The built-in `my127.site` domain remains available by default. Do not add it to +`proxy-domains.yml`; that file is for extra domains registered on the machine. + +## Register a domain + +A proxy domain must be registered before the Global Proxy can serve it. The +certificate and key must be reachable `http://` or `https://` URLs. + +Certificate URLs may point at private locations that are already reachable from +the machine, such as an internal HTTP(S) endpoint. + +The certificate must cover the bare domain and the subdomains used by projects +or global services. For example, a certificate for `mydomain.site` should also +cover `*.mydomain.site`. + +## Host the proxy configuration files + +Before developers import a custom proxy domain, the organisation needs stable +HTTP(S) URLs for: + +- `domains.yml`: the Workspace proxy domain configuration to import. +- `fullchain.pem`: the certificate served by the Global Proxy. +- `privkey.pem`: the private key used by that certificate. + +The files do not need to live in the same repository, website, or directory. +The certificate and key URLs are read from the attributes in `domains.yml`; the +layout below keeps them together only to make the example easy to follow. + +GitHub raw URLs work only for public repositories because Workspace does not +authenticate to GitHub. Use them for `domains.yml`, or for disposable test +certificates. For private keys, use organisation-only HTTP(S) URLs reachable +from developer machines. + +Suggested structure for one or more domains: + +```text +proxy-config/ +├── domains.yml +└── certs/ + ├── mydomain.site/ + │ ├── fullchain.pem + │ └── privkey.pem + └── otherdomain.site/ + ├── fullchain.pem + └── privkey.pem +``` + +`domains.yml` can reference any number of domains. Each entry should point to +the hosted certificate and key URLs for that domain. When certificates are +renewed, update the hosted certificate files and keep the URLs stable, then +developers only need to restart the Global Proxy. Re-import `domains.yml` only +when registering a new machine or adding a domain; use `domain update` when an +existing domain definition changes. + +The following commands are alternatives for common tasks. Run the one that +matches the change you want to make. + +```bash +# List registered proxy domains. +ws global service proxy config domain list + +# Register one domain manually. +ws global service proxy config domain add mydomain \ + --name=mydomain.site \ + --crt=https://proxy-config.example.internal/certs/mydomain.site/fullchain.pem \ + --key=https://proxy-config.example.internal/certs/mydomain.site/privkey.pem + +# Import domains from a local file. +ws global service proxy config domain import domains.yml + +# Import domains from a public GitHub repository. +ws global service proxy config domain import https://raw.githubusercontent.com/my-org/public-proxy-config/main/domains.yml + +# Import domains from an internal website. +ws global service proxy config domain import https://proxy-config.example.internal/domains.yml + +# Replace one registered domain. +ws global service proxy config domain update mydomain \ + --name=mydomain.site \ + --crt=https://proxy-config.example.internal/certs/mydomain.site/fullchain.pem \ + --key=https://proxy-config.example.internal/certs/mydomain.site/privkey.pem + +# Remove one registered domain. +ws global service proxy config domain remove mydomain +``` + +Example `domains.yml`: + +```yaml +attributes: + global: + service: + proxy: + domains: + mydomain: + name: mydomain.site + https: + crt: https://proxy-config.example.internal/certs/mydomain.site/fullchain.pem + key: https://proxy-config.example.internal/certs/mydomain.site/privkey.pem + crt_file: mydomain.site.crt + key_file: mydomain.site.key + otherdomain: + name: otherdomain.site + https: + crt: https://proxy-config.example.internal/certs/otherdomain.site/fullchain.pem + key: https://proxy-config.example.internal/certs/otherdomain.site/privkey.pem + crt_file: otherdomain.site.crt + key_file: otherdomain.site.key +``` + +After changing registered domains, restart the proxy: + +```bash +ws global service proxy restart +``` + +Enabled global services such as mail, logger, or tracing may also need a +restart before they pick up the new host rules. + +## Use a domain in a project + +A project uses the normal `domain` attribute. Register the proxy domain first, +then set the project domain: + +```yaml +attributes: + domain: mydomain.site +``` diff --git a/home/lib/sidekick.sh b/home/lib/sidekick.sh index b5aa56a9..ae782d4f 100755 --- a/home/lib/sidekick.sh +++ b/home/lib/sidekick.sh @@ -11,9 +11,13 @@ INDICATOR_PASSTHRU="37m" prompt() { - if [ "${RUN_CWD}" != "$(pwd)" ]; then - RUN_CWD="$(pwd)" - echo -e "\\033[1m[\\033[0m$(pwd)\\033[1m]:\\033[0m" >&2 + local CWD + + CWD="$(pwd)" + + if [ "${RUN_CWD}" != "$CWD" ]; then + RUN_CWD="$CWD" + echo -e "\\033[1m[\\033[0m$CWD\\033[1m]:\\033[0m" >&2 fi } @@ -82,9 +86,32 @@ passthru() setCommandIndicator() { - echo -ne "\\033[1A" >&2 + echo -ne "\\033[1A" >&2 echo -ne "\\033[$1" >&2 echo -n "■" >&2 echo -ne "\\033[0m" >&2 echo -ne "\\033[1E" >&2 } + +updateEnvGeneratedKey() +{ + local -r FILE="$1" + local -r KEY="$2" + local -r VALUE="$3" + local TEMP_FILE + local LINE + + TEMP_FILE="$(mktemp "$FILE.XXXXXX")" + + if [ -f "$FILE" ]; then + while IFS= read -r LINE || [ -n "$LINE" ]; do + case "$LINE" in + "$KEY="*) ;; + *) printf '%s\n' "$LINE" >> "$TEMP_FILE" ;; + esac + done < "$FILE" + fi + + printf '%s=%s\n' "$KEY" "$VALUE" >> "$TEMP_FILE" + mv "$TEMP_FILE" "$FILE" +} diff --git a/home/service/logger/.env b/home/service/logger/.env index b2cce8c1..c93a14a6 100644 --- a/home/service/logger/.env +++ b/home/service/logger/.env @@ -1 +1,2 @@ TRAEFIK_NETWORK=my127ws +TRAEFIK_KIBANA_RULE=Host(`kibana.my127.site`) diff --git a/home/service/logger/docker-compose.yml b/home/service/logger/docker-compose.yml index 798c6d2d..5adb1614 100644 --- a/home/service/logger/docker-compose.yml +++ b/home/service/logger/docker-compose.yml @@ -6,7 +6,7 @@ services: - elasticsearch labels: - traefik.enable=true - - traefik.http.routers.kibana.rule=Host(`kibana.my127.site`) + - traefik.http.routers.kibana.rule=${TRAEFIK_KIBANA_RULE} - traefik.http.services.kibana.loadbalancer.server.port=5601 - traefik.docker.network=${TRAEFIK_NETWORK} networks: diff --git a/home/service/logger/init.sh b/home/service/logger/init.sh index c1b91aec..9acffc28 100755 --- a/home/service/logger/init.sh +++ b/home/service/logger/init.sh @@ -17,15 +17,25 @@ main() disable exit fi + + if [ "$1" = "restart" ]; then + restart + exit + fi } enable() { + local TRAEFIK_KIBANA_RULE + + TRAEFIK_KIBANA_RULE="$(ws global service proxy config rule kibana)" + updateEnvGeneratedKey ".env" "TRAEFIK_KIBANA_RULE" "$TRAEFIK_KIBANA_RULE" + if [ ! -f .flag-built ]; then run docker-compose -p my127ws-logger up -d --build touch .flag-built else - run docker-compose -p my127ws-logger start + run docker-compose -p my127ws-logger up -d fi } @@ -34,6 +44,12 @@ disable() run docker-compose -p my127ws-logger stop } +restart() +{ + disable + enable +} + bootstrap() { DIR="$(cd "$(dirname "$0")" && pwd)" diff --git a/home/service/mail/.env b/home/service/mail/.env index b2cce8c1..c1caf75d 100644 --- a/home/service/mail/.env +++ b/home/service/mail/.env @@ -1 +1,2 @@ TRAEFIK_NETWORK=my127ws +TRAEFIK_MAIL_RULE=Host(`mail.my127.site`) diff --git a/home/service/mail/docker-compose.yml b/home/service/mail/docker-compose.yml index 2d6edef9..6c07412a 100644 --- a/home/service/mail/docker-compose.yml +++ b/home/service/mail/docker-compose.yml @@ -4,7 +4,7 @@ services: restart: unless-stopped labels: - traefik.enable=true - - traefik.http.routers.mail.rule=Host(`mail.my127.site`) + - traefik.http.routers.mail.rule=${TRAEFIK_MAIL_RULE} - traefik.http.services.mail.loadbalancer.server.port=8025 networks: - private diff --git a/home/service/mail/init.sh b/home/service/mail/init.sh index 1e10f2f2..95423101 100755 --- a/home/service/mail/init.sh +++ b/home/service/mail/init.sh @@ -17,15 +17,25 @@ main() disable exit fi + + if [ "$1" = "restart" ]; then + restart + exit + fi } enable() { + local TRAEFIK_MAIL_RULE + + TRAEFIK_MAIL_RULE="$(ws global service proxy config rule mail)" + updateEnvGeneratedKey ".env" "TRAEFIK_MAIL_RULE" "$TRAEFIK_MAIL_RULE" + if [ ! -f .flag-built ]; then run docker-compose -p my127ws-mail up -d --build touch .flag-built else - run docker-compose -p my127ws-mail start + run docker-compose -p my127ws-mail up -d fi } @@ -34,6 +44,12 @@ disable() run docker-compose -p my127ws-mail stop } +restart() +{ + disable + enable +} + bootstrap() { DIR="$(cd "$(dirname "$0")" && pwd)" diff --git a/home/service/proxy/.env b/home/service/proxy/.env index b2cce8c1..83f69845 100644 --- a/home/service/proxy/.env +++ b/home/service/proxy/.env @@ -1 +1,2 @@ TRAEFIK_NETWORK=my127ws +TRAEFIK_PROXY_RULE=Host(`my127.site`) diff --git a/home/service/proxy/docker-compose.yml b/home/service/proxy/docker-compose.yml index 7cf24807..b7b54f89 100644 --- a/home/service/proxy/docker-compose.yml +++ b/home/service/proxy/docker-compose.yml @@ -9,7 +9,7 @@ services: - 443:443/udp labels: - traefik.enable=true - - traefik.http.routers.traefik.rule=Host(`my127.site`) + - traefik.http.routers.traefik.rule=${TRAEFIK_PROXY_RULE} - traefik.http.services.traefik.loadbalancer.server.port=8080 - co.elastic.logs/module=traefik volumes: diff --git a/home/service/proxy/init.sh b/home/service/proxy/init.sh index 53b80c89..044eb0e8 100755 --- a/home/service/proxy/init.sh +++ b/home/service/proxy/init.sh @@ -26,6 +26,8 @@ main() enable() ( + local TRAEFIK_PROXY_RULE + cd "$DIR" if ! docker ps | grep my127ws-proxy > /dev/null; then @@ -34,8 +36,10 @@ enable() run mkdir -p traefik/root/tls fi - run curl --fail --location --output traefik/root/tls/my127.site.crt "$(ws global config get global.service.proxy.https.crt)" - run curl --fail --location --output traefik/root/tls/my127.site.key "$(ws global config get global.service.proxy.https.key)" + TRAEFIK_PROXY_RULE="$(ws global service proxy config rule)" + updateEnvGeneratedKey ".env" "TRAEFIK_PROXY_RULE" "$TRAEFIK_PROXY_RULE" + run ws global service proxy config certificates download traefik/root/tls + run ws global service proxy config tls --output=traefik/root/config/tls.yaml run docker-compose -p my127ws-proxy up --force-recreate --build -d traefik fi ) diff --git a/home/service/tracing/.env b/home/service/tracing/.env index b2cce8c1..7d902967 100644 --- a/home/service/tracing/.env +++ b/home/service/tracing/.env @@ -1 +1,2 @@ TRAEFIK_NETWORK=my127ws +TRAEFIK_TRACING_RULE=Host(`tracing.my127.site`) diff --git a/home/service/tracing/docker-compose.yml b/home/service/tracing/docker-compose.yml index 75785e1f..14d6b2b8 100644 --- a/home/service/tracing/docker-compose.yml +++ b/home/service/tracing/docker-compose.yml @@ -9,7 +9,7 @@ services: JAEGER_AGENT_HOST: 127.0.0.1 labels: - traefik.enable=true - - traefik.http.routers.my127ws-tracing-jaeger.rule=Host(`tracing.my127.site`) + - traefik.http.routers.my127ws-tracing-jaeger.rule=${TRAEFIK_TRACING_RULE} - traefik.http.services.my127ws-tracing-jaeger.loadbalancer.server.port=16686 - traefik.docker.network=${TRAEFIK_NETWORK} restart: unless-stopped diff --git a/home/service/tracing/init.sh b/home/service/tracing/init.sh index 0b6ac962..f210993f 100755 --- a/home/service/tracing/init.sh +++ b/home/service/tracing/init.sh @@ -26,8 +26,12 @@ main() start() ( + local TRAEFIK_TRACING_RULE + cd "$DIR" + TRAEFIK_TRACING_RULE="$(ws global service proxy config rule tracing)" + updateEnvGeneratedKey ".env" "TRAEFIK_TRACING_RULE" "$TRAEFIK_TRACING_RULE" run docker-compose -p my127ws-tracing pull run docker-compose -p my127ws-tracing up -d diff --git a/home/workspace.yml b/home/workspace.yml index 1a53940b..e49d7417 100644 --- a/home/workspace.yml +++ b/home/workspace.yml @@ -21,3 +21,11 @@ attributes.default: https: crt: https://my127.io/workspace/my127.site.crt key: https://my127.io/workspace/my127.site.key + domains: + default: + name: my127.site + https: + crt: = @('global.service.proxy.https.crt') + key: = @('global.service.proxy.https.key') + crt_file: my127.site.crt + key_file: my127.site.key diff --git a/src/GlobalService/Proxy/CertificateDownloader.php b/src/GlobalService/Proxy/CertificateDownloader.php new file mode 100644 index 00000000..e9c61d95 --- /dev/null +++ b/src/GlobalService/Proxy/CertificateDownloader.php @@ -0,0 +1,74 @@ +resolveDirectory($directory); + + if (!is_dir($directory)) { + mkdir($directory, 0755, true); + } + + foreach ($domains as $id => $domain) { + $this->downloadDomain($id, $domain, $directory, $registryPath); + } + } + + private function downloadDomain(string $id, array $domain, string $directory, string $registryPath): void + { + $temporaryFiles = []; + + try { + $temporaryFiles['crt'] = $this->downloadToTemporaryFile($domain['https']['crt'], $directory); + $temporaryFiles['key'] = $this->downloadToTemporaryFile($domain['https']['key'], $directory); + + rename($temporaryFiles['crt'], $directory . '/' . $domain['crt_file']); + rename($temporaryFiles['key'], $directory . '/' . $domain['key_file']); + } catch (\Throwable $e) { + foreach ($temporaryFiles as $temporaryFile) { + if (file_exists($temporaryFile)) { + unlink($temporaryFile); + } + } + + throw new \RuntimeException(sprintf('Could not download certificate sources for Proxy Domain "%s". Check %s and verify the configured URLs are reachable. %s', $id, $registryPath, $e->getMessage()), 0, $e); + } + } + + private function downloadToTemporaryFile(string $url, string $directory): string + { + $contents = @file_get_contents($url); + if ($contents === false) { + throw new \RuntimeException(sprintf('Failed source URL: %s.', $url)); + } + + $temporaryFile = tempnam($directory, 'proxy-domain-'); + if ($temporaryFile === false) { + throw new \RuntimeException(sprintf('Could not create temporary file in %s.', $directory)); + } + + $written = file_put_contents($temporaryFile, $contents); + if ($written !== strlen($contents)) { + if (file_exists($temporaryFile)) { + unlink($temporaryFile); + } + + throw new \RuntimeException(sprintf('Could not write temporary certificate file in %s.', $directory)); + } + + return $temporaryFile; + } + + private function resolveDirectory(string $directory): string + { + if (str_starts_with($directory, '/')) { + return $directory; + } + + return getcwd() . '/' . $directory; + } +} diff --git a/src/GlobalService/Proxy/ProxyDomainCommand.php b/src/GlobalService/Proxy/ProxyDomainCommand.php new file mode 100644 index 00000000..2171b063 --- /dev/null +++ b/src/GlobalService/Proxy/ProxyDomainCommand.php @@ -0,0 +1,249 @@ +argument('id'); + $domain = ProxyDomainConfiguration::createDomain( + $id, + self::requiredOption($input, 'name'), + self::requiredOption($input, 'crt'), + self::requiredOption($input, 'key'), + self::optionalOption($input, 'crt-file'), + self::optionalOption($input, 'key-file') + ); + + if (isset($domains[$id])) { + throw new \InvalidArgumentException(sprintf('Proxy Domain "%s" already exists. Use update %s to replace it.', $id, $id)); + } + + $registry = new ProxyDomainRegistry($home); + $registeredDomains = $registry->read(); + if (isset($registeredDomains[$id])) { + throw new \InvalidArgumentException(sprintf('Proxy Domain "%s" already exists. Use update %s to replace it.', $id, $id)); + } + + ProxyDomainConfiguration::assertNoConflicts($domains, $id, $domain); + ProxyDomainConfiguration::assertNoConflicts($registeredDomains, $id, $domain); + + $registeredDomains[$id] = $domain; + $registry->write($registeredDomains); + + echo sprintf("Registered Proxy Domain \"%s\" in %s.\n", $id, $registry->displayPath()); + echo "Run ws global service proxy restart to apply certificate and TLS changes.\n"; + } + + public static function domainUpdate(array $domains, Input $input, string $home, Workspace $workspace): void + { + $id = $input->argument('id'); + $domain = ProxyDomainConfiguration::createDomain( + $id, + self::requiredOption($input, 'name'), + self::requiredOption($input, 'crt'), + self::requiredOption($input, 'key'), + self::optionalOption($input, 'crt-file'), + self::optionalOption($input, 'key-file') + ); + + $registry = new ProxyDomainRegistry($home); + $registeredDomains = $registry->read(); + + if (!isset($registeredDomains[$id])) { + throw new \InvalidArgumentException(sprintf('Proxy Domain "%s" is not registered. Use add %s to register it.', $id, $id)); + } + + self::assertRegistryDomainIsEffective($domains, $id, $registeredDomains[$id], $workspace, $registry->path()); + ProxyDomainConfiguration::assertNoConflicts($domains, $id, $domain); + ProxyDomainConfiguration::assertNoConflicts($registeredDomains, $id, $domain); + + $registeredDomains[$id] = $domain; + $registry->write($registeredDomains); + + echo sprintf("Updated Proxy Domain \"%s\" in %s.\n", $id, $registry->displayPath()); + echo "Run ws global service proxy restart to apply certificate and TLS changes.\n"; + } + + public static function domainRemove(array $domains, Input $input, string $home, Workspace $workspace): void + { + $id = $input->argument('id'); + ProxyDomainConfiguration::assertValidId($id); + + $registry = new ProxyDomainRegistry($home); + $registeredDomains = $registry->read(); + + if (!isset($registeredDomains[$id])) { + throw new \InvalidArgumentException(sprintf('Proxy Domain "%s" is not registered.', $id)); + } + + self::assertRegistryDomainIsEffective($domains, $id, $registeredDomains[$id], $workspace, $registry->path()); + + unset($registeredDomains[$id]); + $registry->write($registeredDomains); + + echo sprintf("Removed Proxy Domain \"%s\" from %s.\n", $id, $registry->displayPath()); + echo "Projects using this domain will stop resolving through the Global Proxy until it is registered again.\n"; + echo "Run ws global service proxy restart to apply certificate and TLS changes.\n"; + } + + public static function domainImport(array $domains, Input $input, string $home): void + { + $source = $input->argument('source'); + $importDomains = ProxyDomainConfiguration::extractDomains(Yaml::parse(self::readSource($source)) ?? []); + + if ($importDomains === []) { + throw new \InvalidArgumentException(sprintf('No global.service.proxy.domains map found in "%s".', $source)); + } + + $registry = new ProxyDomainRegistry($home); + $registeredDomains = $registry->read(); + $imported = 0; + $unchanged = 0; + $skipped = 0; + + foreach ($importDomains as $id => $importDomain) { + if ($id === 'default') { + fwrite(STDERR, "Skipping reserved Proxy Domain ID \"default\".\n"); + ++$skipped; + continue; + } + + try { + $domain = ProxyDomainConfiguration::normalizeDomain($id, is_array($importDomain) ? $importDomain : []); + + if (isset($domains[$id]) && !isset($registeredDomains[$id])) { + if (ProxyDomainConfiguration::normalizeDomain($id, $domains[$id]) == $domain) { + fwrite(STDERR, sprintf("Proxy Domain \"%s\" is already configured.\n", $id)); + ++$unchanged; + continue; + } + + fwrite(STDERR, sprintf("Skipping Proxy Domain \"%s\" because it is already configured outside the registry.\n", $id)); + ++$skipped; + continue; + } + + if (isset($registeredDomains[$id])) { + if (ProxyDomainConfiguration::normalizeDomain($id, $registeredDomains[$id]) == $domain) { + fwrite(STDERR, sprintf("Proxy Domain \"%s\" is already registered.\n", $id)); + ++$unchanged; + continue; + } + + fwrite(STDERR, sprintf("Skipping Proxy Domain \"%s\" because it is already registered with different values.\n", $id)); + ++$skipped; + continue; + } + + ProxyDomainConfiguration::assertNoConflicts($domains, $id, $domain); + ProxyDomainConfiguration::assertNoConflicts($registeredDomains, $id, $domain); + + $registeredDomains[$id] = $domain; + ++$imported; + } catch (\Throwable $e) { + fwrite(STDERR, sprintf("Skipping Proxy Domain \"%s\": %s\n", $id, $e->getMessage())); + ++$skipped; + } + } + + if ($imported === 0 && $unchanged === 0) { + throw new \InvalidArgumentException(sprintf('No Proxy Domains could be imported from "%s".', $source)); + } + + if ($imported > 0) { + $registry->write($registeredDomains); + } + + echo sprintf("Imported %d Proxy Domain(s) into %s.\n", $imported, $registry->displayPath()); + if ($skipped > 0) { + echo sprintf("Skipped %d Proxy Domain(s); see stderr for details.\n", $skipped); + } + echo "Run ws global service proxy restart to apply certificate and TLS changes.\n"; + } + + private static function requiredOption(Input $input, string $name): string + { + $value = self::optionalOption($input, $name); + + if ($value === null) { + throw new \InvalidArgumentException(sprintf('Missing required option --%s.', $name)); + } + + return $value; + } + + private static function optionalOption(Input $input, string $name): ?string + { + $value = $input->option($name); + + if ($value === '') { + return null; + } + + return $value; + } + + private static function assertRegistryDomainIsEffective(array $domains, string $id, array $registeredDomain, Workspace $workspace, string $registryPath): void + { + if ( + !isset($domains[$id]) + || ProxyDomainConfiguration::normalizeDomain($id, $domains[$id]) != ProxyDomainConfiguration::normalizeDomain($id, $registeredDomain) + ) { + throw new \InvalidArgumentException(sprintf('Proxy Domain "%s" is configured outside the registry. Remove that global config override before using this registry command.', $id)); + } + + $registryPath = realpath($registryPath) ?: $registryPath; + foreach (['name', 'https.crt', 'https.key', 'crt_file', 'key_file'] as $key) { + $metadata = $workspace->attributeMetadata(sprintf('global.service.proxy.domains.%s.%s', $id, $key)); + $sources = is_array($metadata) ? ($metadata['source'] ?? []) : []; + $effectiveSource = self::effectiveAttributeSource($sources); + $effectiveSource = $effectiveSource === null ? null : (realpath($effectiveSource) ?: $effectiveSource); + + if ($effectiveSource !== $registryPath) { + throw new \InvalidArgumentException(sprintf('Proxy Domain "%s" is configured outside the registry. Remove that global config override before using this registry command.', $id)); + } + } + } + + private static function effectiveAttributeSource(array $sources): ?string + { + $effectivePrecedence = null; + $effectiveSource = null; + + foreach ($sources as $precedence => $source) { + $precedence = (int) substr($precedence, 1); + if ($effectivePrecedence === null || $precedence > $effectivePrecedence) { + $effectivePrecedence = $precedence; + $effectiveSource = $source; + } + } + + return $effectiveSource; + } + + private static function readSource(string $source): string + { + $path = $source; + if (!preg_match('#^https?://#', $source) && !str_starts_with($source, '/')) { + $path = getcwd() . '/' . $source; + } + + $contents = @file_get_contents($path); + if ($contents === false) { + throw new \InvalidArgumentException(sprintf('Could not read Proxy Domain import source "%s".', $source)); + } + + return $contents; + } +} diff --git a/src/GlobalService/Proxy/ProxyDomainConfiguration.php b/src/GlobalService/Proxy/ProxyDomainConfiguration.php new file mode 100644 index 00000000..bef5db5d --- /dev/null +++ b/src/GlobalService/Proxy/ProxyDomainConfiguration.php @@ -0,0 +1,219 @@ + $name, + 'https' => [ + 'crt' => $crt, + 'key' => $key, + ], + 'crt_file' => $crtFile ?: self::deriveCertificateFilename($name, 'crt'), + 'key_file' => $keyFile ?: self::deriveCertificateFilename($name, 'key'), + ]; + + return self::normalizeDomain($id, $domain); + } + + public static function dumpDomainList(array $domains): string + { + return Yaml::dump(['domains' => self::normalizeDomains($domains)], 10, 2); + } + + public static function extractDomains(array $data): array + { + if (isset($data['attributes']['global']['service']['proxy']['domains'])) { + $domains = $data['attributes']['global']['service']['proxy']['domains']; + + return is_array($domains) ? $domains : []; + } + + if (isset($data["attribute('global.service.proxy.domains')"])) { + $domains = $data["attribute('global.service.proxy.domains')"]; + + return is_array($domains) ? $domains : []; + } + + if (isset($data["attributes('global.service.proxy.domains')"])) { + throw new \InvalidArgumentException("Use attribute('global.service.proxy.domains'), not attributes('global.service.proxy.domains')."); + } + + return []; + } + + public static function normalizeDomains(array $domains): array + { + if (!isset($domains['default'])) { + $domains = array_replace_recursive([ + 'default' => [ + 'name' => 'my127.site', + 'https' => [ + 'crt' => 'https://my127.io/workspace/my127.site.crt', + 'key' => 'https://my127.io/workspace/my127.site.key', + ], + 'crt_file' => 'my127.site.crt', + 'key_file' => 'my127.site.key', + ], + ], $domains); + } + + foreach ($domains as $id => $domain) { + if (!is_array($domain)) { + throw new \InvalidArgumentException(sprintf('Proxy Domain "%s" must be a map.', $id)); + } + + $domains[$id] = self::normalizeDomain($id, $domain, $id === 'default'); + } + + self::assertNoDomainCollectionConflicts($domains); + + return $domains; + } + + public static function normalizeDomain(string $id, array $domain, bool $allowDefault = false): array + { + self::assertValidId($id, $allowDefault); + + if (isset($domain['name'])) { + $domain['crt_file'] = $domain['crt_file'] ?? self::deriveCertificateFilename($domain['name'], 'crt'); + $domain['key_file'] = $domain['key_file'] ?? self::deriveCertificateFilename($domain['name'], 'key'); + } + + self::assertValidDomain($id, $domain); + + return $domain; + } + + public static function assertNoConflicts(array $domains, string $id, array $domain): void + { + foreach (self::normalizeDomains($domains) as $existingId => $existingDomain) { + if ($existingId === $id) { + continue; + } + + if (strcasecmp($existingDomain['name'], $domain['name']) === 0) { + throw new \InvalidArgumentException(sprintf('Proxy Domain "%s" already uses name "%s".', $existingId, $domain['name'])); + } + + foreach (['crt_file', 'key_file'] as $existingKey) { + foreach (['crt_file', 'key_file'] as $newKey) { + if (strcasecmp($existingDomain[$existingKey], $domain[$newKey]) === 0) { + throw new \InvalidArgumentException(sprintf('Proxy Domain "%s" already uses local TLS filename "%s".', $existingId, $domain[$newKey])); + } + } + } + } + } + + public static function assertValidId(string $id, bool $allowDefault = false): void + { + if ($id === 'default' && !$allowDefault) { + throw new \InvalidArgumentException('Proxy Domain ID "default" is reserved.'); + } + + if (!preg_match('/^[a-zA-Z0-9_-]+$/', $id)) { + throw new \InvalidArgumentException('Proxy Domain ID must match ^[a-zA-Z0-9_-]+$.'); + } + } + + public static function assertValidDomain(string $id, array $domain): void + { + foreach (['name', 'https', 'crt_file', 'key_file'] as $key) { + if (!isset($domain[$key])) { + throw new \InvalidArgumentException(sprintf('Proxy Domain "%s" is missing "%s".', $id, $key)); + } + } + + if (!is_array($domain['https'])) { + throw new \InvalidArgumentException(sprintf('Proxy Domain "%s" is missing certificate sources.', $id)); + } + + foreach (['crt', 'key'] as $key) { + if (empty($domain['https'][$key])) { + throw new \InvalidArgumentException(sprintf('Proxy Domain "%s" is missing "https.%s".', $id, $key)); + } + if (!preg_match('#^https?://#', $domain['https'][$key])) { + throw new \InvalidArgumentException(sprintf('Proxy Domain "%s" https.%s must be an http:// or https:// URL.', $id, $key)); + } + } + + if (!self::isBareDnsSuffix($domain['name'])) { + throw new \InvalidArgumentException(sprintf('Proxy Domain "%s" name must be a bare DNS suffix.', $id)); + } + + foreach (['crt_file', 'key_file'] as $key) { + if (!self::isSimpleFilename($domain[$key])) { + throw new \InvalidArgumentException(sprintf('Proxy Domain "%s" %s must be a simple basename.', $id, $key)); + } + } + + if (strcasecmp($domain['crt_file'], $domain['key_file']) === 0) { + throw new \InvalidArgumentException(sprintf('Proxy Domain "%s" certificate and key filenames must be different.', $id)); + } + } + + private static function assertNoDomainCollectionConflicts(array $domains): void + { + $names = []; + $files = []; + + foreach ($domains as $id => $domain) { + $name = strtolower($domain['name']); + if (isset($names[$name])) { + throw new \InvalidArgumentException(sprintf('Proxy Domain "%s" already uses name "%s".', $names[$name], $domain['name'])); + } + $names[$name] = $id; + + foreach (['crt_file', 'key_file'] as $key) { + $file = strtolower($domain[$key]); + if (isset($files[$file])) { + throw new \InvalidArgumentException(sprintf('Proxy Domain "%s" already uses local TLS filename "%s".', $files[$file], $domain[$key])); + } + $files[$file] = $id; + } + } + } + + private static function deriveCertificateFilename(string $domain, string $extension): string + { + return $domain . '.' . $extension; + } + + private static function isBareDnsSuffix(string $domain): bool + { + return (bool) preg_match('/^(?=.{1,253}$)([a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,63}$/', $domain); + } + + private static function isSimpleFilename(string $filename): bool + { + return $filename !== '' + && $filename !== '.' + && $filename !== '..' + && basename($filename) === $filename + && !str_contains($filename, '/') + && !str_contains($filename, '\\'); + } +} diff --git a/src/GlobalService/Proxy/ProxyDomainRegistry.php b/src/GlobalService/Proxy/ProxyDomainRegistry.php new file mode 100644 index 00000000..c8751262 --- /dev/null +++ b/src/GlobalService/Proxy/ProxyDomainRegistry.php @@ -0,0 +1,55 @@ +home . '/' . self::RELATIVE_PATH; + } + + public function displayPath(): string + { + return '~/' . self::RELATIVE_PATH; + } + + public function read(): array + { + if (!file_exists($this->path())) { + return []; + } + + return ProxyDomainConfiguration::extractDomains(Yaml::parseFile($this->path()) ?? []); + } + + public function write(array $domains): void + { + unset($domains['default']); + + $directory = dirname($this->path()); + if (!is_dir($directory)) { + mkdir($directory, 0755, true); + } + + file_put_contents($this->path(), Yaml::dump([ + 'attributes' => [ + 'global' => [ + 'service' => [ + 'proxy' => [ + 'domains' => $domains, + ], + ], + ], + ], + ], 10, 2)); + } +} diff --git a/src/GlobalService/Proxy/ProxyRuntimeCommand.php b/src/GlobalService/Proxy/ProxyRuntimeCommand.php new file mode 100644 index 00000000..2f574199 --- /dev/null +++ b/src/GlobalService/Proxy/ProxyRuntimeCommand.php @@ -0,0 +1,35 @@ +argument('host-prefix')) . "\n"; + } + + public static function tls(array $domains, Input $input): void + { + $yaml = ProxyRuntimeConfiguration::tlsYaml($domains); + $output = $input->option('output'); + + if ($output === '') { + echo $yaml; + + return; + } + + if (file_put_contents($output, $yaml) === false) { + throw new \RuntimeException(sprintf('Could not write Traefik TLS configuration to "%s".', $output)); + } + } + + public static function downloadCertificates(array $domains, Input $input, string $home): void + { + $registry = new ProxyDomainRegistry($home); + (new CertificateDownloader())->download($domains, $input->argument('dir'), $registry->displayPath()); + } +} diff --git a/src/GlobalService/Proxy/ProxyRuntimeConfiguration.php b/src/GlobalService/Proxy/ProxyRuntimeConfiguration.php new file mode 100644 index 00000000..564237b0 --- /dev/null +++ b/src/GlobalService/Proxy/ProxyRuntimeConfiguration.php @@ -0,0 +1,78 @@ + 253) { + throw new \InvalidArgumentException(sprintf('Global Proxy host "%s" exceeds the DNS hostname length limit.', $host)); + } + + $hosts[] = sprintf('Host(`%s`)', $host); + } + + return implode(' || ', $hosts); + } + + public static function tlsYaml(array $domains): string + { + $domains = ProxyDomainConfiguration::normalizeDomains($domains); + $default = $domains['default']; + + $tls = [ + 'tls' => [ + 'stores' => [ + 'default' => [ + 'defaultCertificate' => self::certificateFiles($default), + ], + ], + ], + ]; + + foreach ($domains as $id => $domain) { + if ($id === 'default') { + continue; + } + + $tls['tls']['certificates'][] = self::certificateFiles($domain); + } + + return Yaml::dump($tls, 10, 2); + } + + private static function certificateFiles(array $domain): array + { + return [ + 'certFile' => '/tls/' . $domain['crt_file'], + 'keyFile' => '/tls/' . $domain['key_file'], + ]; + } + + private static function normalizeHostPrefix(?string $hostPrefix): string + { + if ($hostPrefix === null || $hostPrefix === '') { + return ''; + } + + $hostPrefix = rtrim($hostPrefix, '.'); + if ($hostPrefix === '' || !preg_match('/^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/', $hostPrefix)) { + throw new \InvalidArgumentException('Global Proxy host prefix must be DNS labels without the domain suffix.'); + } + + return $hostPrefix . '.'; + } +} diff --git a/src/Types/Attribute/Collection.php b/src/Types/Attribute/Collection.php index d62b049f..1ac4d367 100644 --- a/src/Types/Attribute/Collection.php +++ b/src/Types/Attribute/Collection.php @@ -47,10 +47,12 @@ function (&$value) { $value['source'], function (&$source) { // de-dupe when attribute defined twice in same file with attribute('...') and yaml - $source = is_array($source) ? $source[0] : $source; + $source = is_array($source) ? end($source) : $source; } ); - ksort($value['source']); + uksort($value['source'], function ($a, $b) { + return (int) substr($a, 1) <=> (int) substr($b, 1); + }); } ); } diff --git a/src/Types/Workspace/Workspace.php b/src/Types/Workspace/Workspace.php index 090de90d..456efb86 100644 --- a/src/Types/Workspace/Workspace.php +++ b/src/Types/Workspace/Workspace.php @@ -141,11 +141,16 @@ public function offsetExists($offset): bool return isset($this->attributes[$offset]); } - public function offsetGet($offset): ?string + public function offsetGet($offset): mixed { return $this->attributes->get($offset); } + public function attributeMetadata(string $key): mixed + { + return $this->attributes->getAttributeMetadata($key); + } + public function offsetSet($offset, $value): void { $this->attributes->set($offset, $value); diff --git a/tests/Test/GlobalService/Proxy/CertificateDownloaderTest.php b/tests/Test/GlobalService/Proxy/CertificateDownloaderTest.php new file mode 100644 index 00000000..fb3dcf10 --- /dev/null +++ b/tests/Test/GlobalService/Proxy/CertificateDownloaderTest.php @@ -0,0 +1,97 @@ +server !== null) { + $this->server->stop(); + } + } + + public function testShortCertificateWritesDoNotReplaceExistingFiles(): void + { + $this->startCertificateServer(); + $this->workspace()->put('tls/my127.site.crt', 'existing certificate'); + $this->workspace()->put('tls/my127.site.key', 'existing key'); + + CertificateDownloaderWriteFailure::$enabled = true; + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Could not download certificate sources for Proxy Domain "default"'); + + try { + (new CertificateDownloader())->download([ + 'default' => [ + 'name' => 'my127.site', + 'https' => [ + 'crt' => $this->serverUrl . '/my127.site.crt', + 'key' => $this->serverUrl . '/my127.site.key', + ], + 'crt_file' => 'my127.site.crt', + 'key_file' => 'my127.site.key', + ], + ], $this->workspace()->path('tls'), 'proxy-domains.yml'); + } finally { + self::assertSame('existing certificate', $this->workspace()->getContents('tls/my127.site.crt')); + self::assertSame('existing key', $this->workspace()->getContents('tls/my127.site.key')); + self::assertSame([], glob($this->workspace()->path('tls/proxy-domain-*'))); + } + } + + private function startCertificateServer(): void + { + $this->workspace()->put('certs/my127.site.crt', str_repeat('new certificate', 100)); + $this->workspace()->put('certs/my127.site.key', str_repeat('new key', 100)); + + $socket = stream_socket_server('tcp://127.0.0.1:0'); + if ($socket === false) { + throw new \RuntimeException('Could not reserve local HTTP port.'); + } + $address = stream_socket_get_name($socket, false); + fclose($socket); + + $this->serverUrl = 'http://' . $address; + $this->server = new Process([PHP_BINARY, '-S', $address, '-t', $this->workspace()->path('certs')]); + $this->server->start(); + + for ($i = 0; $i < 30; ++$i) { + if (@file_get_contents($this->serverUrl . '/my127.site.crt') !== false) { + return; + } + usleep(100000); + } + + throw new \RuntimeException('Certificate fixture server did not start.'); + } + } +} diff --git a/tests/Test/GlobalService/Proxy/ProxyDomainCommandTest.php b/tests/Test/GlobalService/Proxy/ProxyDomainCommandTest.php new file mode 100644 index 00000000..8cae46e0 --- /dev/null +++ b/tests/Test/GlobalService/Proxy/ProxyDomainCommandTest.php @@ -0,0 +1,622 @@ +cleanInstalledHome(); + $this->cleanProxyDomainRegistry(); + } + + public function testListsTheBuiltInDefaultProxyDomain(): void + { + $process = $this->workspaceCommand('global service proxy config domain list'); + $data = Yaml::parse($process->getOutput()); + + self::assertSame([ + 'domains' => [ + 'default' => [ + 'name' => 'my127.site', + 'https' => [ + 'crt' => 'https://my127.io/workspace/my127.site.crt', + 'key' => 'https://my127.io/workspace/my127.site.key', + ], + 'crt_file' => 'my127.site.crt', + 'key_file' => 'my127.site.key', + ], + ], + ], $data); + } + + public function testAddsAProxyDomainToTheRegistry(): void + { + $this->workspaceCommand( + 'global service proxy config domain add acme ' . + '--name=domain.site ' . + '--crt=https://certs.domain.site/fullchain.pem ' . + '--key=https://certs.domain.site/privkey.pem' + ); + + $registry = Yaml::parseFile($this->registryPath()); + self::assertSame([ + 'attributes' => [ + 'global' => [ + 'service' => [ + 'proxy' => [ + 'domains' => [ + 'acme' => [ + 'name' => 'domain.site', + 'https' => [ + 'crt' => 'https://certs.domain.site/fullchain.pem', + 'key' => 'https://certs.domain.site/privkey.pem', + ], + 'crt_file' => 'domain.site.crt', + 'key_file' => 'domain.site.key', + ], + ], + ], + ], + ], + ], + ], $registry); + + $list = Yaml::parse($this->workspaceCommand('global service proxy config domain list')->getOutput()); + self::assertSame('my127.site', $list['domains']['default']['name']); + self::assertSame('domain.site', $list['domains']['acme']['name']); + } + + public function testUpdatesAProxyDomainWithAFullReplacement(): void + { + $this->workspaceCommand( + 'global service proxy config domain add acme ' . + '--name=domain.site ' . + '--crt=https://certs.domain.site/fullchain.pem ' . + '--key=https://certs.domain.site/privkey.pem' + ); + + $this->workspaceCommand( + 'global service proxy config domain update acme ' . + '--name=dev.domain.site ' . + '--crt=https://certs.domain.site/dev-fullchain.pem ' . + '--key=https://certs.domain.site/dev-privkey.pem ' . + '--crt-file=dev-domain.crt ' . + '--key-file=dev-domain.key' + ); + + $registry = Yaml::parseFile($this->registryPath()); + self::assertSame([ + 'name' => 'dev.domain.site', + 'https' => [ + 'crt' => 'https://certs.domain.site/dev-fullchain.pem', + 'key' => 'https://certs.domain.site/dev-privkey.pem', + ], + 'crt_file' => 'dev-domain.crt', + 'key_file' => 'dev-domain.key', + ], $registry['attributes']['global']['service']['proxy']['domains']['acme']); + } + + public function testRemovesAProxyDomainFromTheRegistry(): void + { + $this->workspaceCommand( + 'global service proxy config domain add acme ' . + '--name=domain.site ' . + '--crt=https://certs.domain.site/fullchain.pem ' . + '--key=https://certs.domain.site/privkey.pem' + ); + + $this->workspaceCommand('global service proxy config domain remove acme'); + + $registry = Yaml::parseFile($this->registryPath()); + self::assertArrayNotHasKey('acme', $registry['attributes']['global']['service']['proxy']['domains']); + + $list = Yaml::parse($this->workspaceCommand('global service proxy config domain list')->getOutput()); + self::assertSame(['default'], array_keys($list['domains'])); + } + + public function testImportsProxyDomainsNonDestructively(): void + { + $this->workspaceCommand( + 'global service proxy config domain add acme ' . + '--name=domain.site ' . + '--crt=https://certs.domain.site/fullchain.pem ' . + '--key=https://certs.domain.site/privkey.pem' + ); + $this->workspace()->put('imports/domains.yml', <<<'YAML' +attributes: + global: + service: + proxy: + domains: + team: + name: team.site + https: + crt: https://certs.team.site/fullchain.pem + key: https://certs.team.site/privkey.pem + default: + name: ignored.site + https: + crt: https://certs.ignored.site/fullchain.pem + key: https://certs.ignored.site/privkey.pem + incomplete: + name: incomplete.site + https: + crt: https://certs.incomplete.site/fullchain.pem +YAML); + + $this->workspaceCommand('global service proxy config domain import imports/domains.yml'); + + $domains = Yaml::parseFile($this->registryPath())['attributes']['global']['service']['proxy']['domains']; + self::assertSame(['acme', 'team'], array_keys($domains)); + self::assertSame('domain.site', $domains['acme']['name']); + self::assertSame([ + 'name' => 'team.site', + 'https' => [ + 'crt' => 'https://certs.team.site/fullchain.pem', + 'key' => 'https://certs.team.site/privkey.pem', + ], + 'crt_file' => 'team.site.crt', + 'key_file' => 'team.site.key', + ], $domains['team']); + } + + public function testAddRejectsMatchingCertificateAndKeyFilenames(): void + { + $process = $this->workspaceProcess( + 'global service proxy config domain add acme ' . + '--name=domain.site ' . + '--crt=https://certs.domain.site/fullchain.pem ' . + '--key=https://certs.domain.site/privkey.pem ' . + '--crt-file=shared.pem ' . + '--key-file=shared.pem' + ); + + $process->run(); + + self::assertNotSame(0, $process->getExitCode()); + self::assertStringContainsString( + 'certificate and key filenames must be different', + $process->getOutput() . $process->getErrorOutput() + ); + self::assertFalse(file_exists($this->registryPath())); + } + + public function testAddRejectsCaseInsensitiveNameAndFilenameConflicts(): void + { + $this->workspaceCommand( + 'global service proxy config domain add acme ' . + '--name=domain.site ' . + '--crt=https://certs.domain.site/fullchain.pem ' . + '--key=https://certs.domain.site/privkey.pem ' . + '--crt-file=domain.crt ' . + '--key-file=domain.key' + ); + + $nameConflict = $this->workspaceProcess( + 'global service proxy config domain add other ' . + '--name=Domain.site ' . + '--crt=https://certs.other.site/fullchain.pem ' . + '--key=https://certs.other.site/privkey.pem' + ); + $nameConflict->run(); + + self::assertNotSame(0, $nameConflict->getExitCode()); + self::assertStringContainsString( + 'already uses name "Domain.site"', + $nameConflict->getOutput() . $nameConflict->getErrorOutput() + ); + + $filenameConflict = $this->workspaceProcess( + 'global service proxy config domain add other ' . + '--name=other.site ' . + '--crt=https://certs.other.site/fullchain.pem ' . + '--key=https://certs.other.site/privkey.pem ' . + '--crt-file=DOMAIN.CRT ' . + '--key-file=other.key' + ); + $filenameConflict->run(); + + self::assertNotSame(0, $filenameConflict->getExitCode()); + self::assertStringContainsString( + 'already uses local TLS filename "DOMAIN.CRT"', + $filenameConflict->getOutput() . $filenameConflict->getErrorOutput() + ); + } + + public function testAddRejectsConflictsWithShadowedRegistryDomains(): void + { + $this->addRegistryDomainShadowedByGlobalOverride(); + + $process = $this->workspaceProcess( + 'global service proxy config domain add team ' . + '--name=domain.site ' . + '--crt=https://certs.team.site/fullchain.pem ' . + '--key=https://certs.team.site/privkey.pem' + ); + $process->run(); + + self::assertNotSame(0, $process->getExitCode()); + self::assertStringContainsString( + 'already uses name "domain.site"', + $process->getOutput() . $process->getErrorOutput() + ); + + $domains = Yaml::parseFile($this->registryPath())['attributes']['global']['service']['proxy']['domains']; + self::assertSame(['acme'], array_keys($domains)); + } + + public function testImportSkipsDomainsAlreadyConfiguredOutsideTheRegistry(): void + { + $this->writeGlobalConfig(<<<'YAML' +attributes: + global: + service: + proxy: + domains: + external: + name: external.site + https: + crt: https://certs.external.site/fullchain.pem + key: https://certs.external.site/privkey.pem +YAML); + $this->workspace()->put('imports/domains.yml', <<<'YAML' +attributes: + global: + service: + proxy: + domains: + external: + name: changed.site + https: + crt: https://certs.changed.site/fullchain.pem + key: https://certs.changed.site/privkey.pem + team: + name: team.site + https: + crt: https://certs.team.site/fullchain.pem + key: https://certs.team.site/privkey.pem +YAML); + + $process = $this->workspaceCommand('global service proxy config domain import imports/domains.yml'); + + $domains = Yaml::parseFile($this->registryPath())['attributes']['global']['service']['proxy']['domains']; + self::assertSame(['team'], array_keys($domains)); + self::assertStringContainsString('Skipped 1 Proxy Domain(s)', $process->getOutput()); + + $list = Yaml::parse($this->workspaceCommand('global service proxy config domain list')->getOutput()); + self::assertSame('external.site', $list['domains']['external']['name']); + self::assertSame('team.site', $list['domains']['team']['name']); + } + + public function testImportRejectsConflictsWithEffectiveGlobalConfig(): void + { + $this->addRegistryDomainShadowedByGlobalOverride(); + $this->workspace()->put('imports/domains.yml', <<<'YAML' +attributes: + global: + service: + proxy: + domains: + team: + name: override.site + https: + crt: https://certs.override.site/fullchain.pem + key: https://certs.override.site/privkey.pem +YAML); + + $process = $this->workspaceProcess('global service proxy config domain import imports/domains.yml'); + $process->run(); + + self::assertNotSame(0, $process->getExitCode()); + self::assertStringContainsString( + 'already uses name "override.site"', + $process->getOutput() . $process->getErrorOutput() + ); + + $domains = Yaml::parseFile($this->registryPath())['attributes']['global']['service']['proxy']['domains']; + self::assertSame(['acme'], array_keys($domains)); + } + + public function testImportRejectsConflictsWithShadowedRegistryDomains(): void + { + $this->addRegistryDomainShadowedByGlobalOverride(); + $this->workspace()->put('imports/domains.yml', <<<'YAML' +attributes: + global: + service: + proxy: + domains: + team: + name: domain.site + https: + crt: https://certs.team.site/fullchain.pem + key: https://certs.team.site/privkey.pem +YAML); + + $process = $this->workspaceProcess('global service proxy config domain import imports/domains.yml'); + $process->run(); + + self::assertNotSame(0, $process->getExitCode()); + self::assertStringContainsString( + 'already uses name "domain.site"', + $process->getOutput() . $process->getErrorOutput() + ); + + $domains = Yaml::parseFile($this->registryPath())['attributes']['global']['service']['proxy']['domains']; + self::assertSame(['acme'], array_keys($domains)); + } + + public function testUpdateFailsWhenARegisteredDomainIsShadowedByAnotherGlobalConfig(): void + { + $this->addRegistryDomainShadowedByGlobalOverride(); + + $process = $this->workspaceProcess( + 'global service proxy config domain update acme ' . + '--name=updated.site ' . + '--crt=https://certs.updated.site/fullchain.pem ' . + '--key=https://certs.updated.site/privkey.pem' + ); + $process->run(); + + self::assertNotSame(0, $process->getExitCode()); + self::assertStringContainsString( + 'configured outside the registry', + $process->getOutput() . $process->getErrorOutput() + ); + + $list = Yaml::parse($this->workspaceCommand('global service proxy config domain list')->getOutput()); + self::assertSame('override.site', $list['domains']['acme']['name']); + } + + public function testUpdateFailsWhenARegisteredDomainIsShadowedByIdenticalGlobalConfig(): void + { + $this->addRegistryDomainShadowedByIdenticalGlobalOverride(); + + $process = $this->workspaceProcess( + 'global service proxy config domain update acme ' . + '--name=updated.site ' . + '--crt=https://certs.updated.site/fullchain.pem ' . + '--key=https://certs.updated.site/privkey.pem' + ); + $process->run(); + + self::assertNotSame(0, $process->getExitCode()); + self::assertStringContainsString( + 'configured outside the registry', + $process->getOutput() . $process->getErrorOutput() + ); + + $list = Yaml::parse($this->workspaceCommand('global service proxy config domain list')->getOutput()); + self::assertSame('domain.site', $list['domains']['acme']['name']); + } + + public function testUpdateRejectsConflictsWithShadowedRegistryDomains(): void + { + $this->workspaceCommand( + 'global service proxy config domain add acme ' . + '--name=domain.site ' . + '--crt=https://certs.domain.site/fullchain.pem ' . + '--key=https://certs.domain.site/privkey.pem' + ); + $this->workspaceCommand( + 'global service proxy config domain add team ' . + '--name=team.site ' . + '--crt=https://certs.team.site/fullchain.pem ' . + '--key=https://certs.team.site/privkey.pem' + ); + $this->writeGlobalConfig(<<<'YAML' +attributes: + global: + service: + proxy: + domains: + acme: + name: override.site + https: + crt: https://certs.override.site/fullchain.pem + key: https://certs.override.site/privkey.pem + crt_file: override.site.crt + key_file: override.site.key +YAML, 'zz-override.yml'); + + $process = $this->workspaceProcess( + 'global service proxy config domain update team ' . + '--name=domain.site ' . + '--crt=https://certs.updated.site/fullchain.pem ' . + '--key=https://certs.updated.site/privkey.pem' + ); + $process->run(); + + self::assertNotSame(0, $process->getExitCode()); + self::assertStringContainsString( + 'already uses name "domain.site"', + $process->getOutput() . $process->getErrorOutput() + ); + + $domains = Yaml::parseFile($this->registryPath())['attributes']['global']['service']['proxy']['domains']; + self::assertSame('team.site', $domains['team']['name']); + } + + public function testRemoveFailsWhenARegisteredDomainIsShadowedByHigherPrecedenceConfig(): void + { + $this->workspaceCommand( + 'global service proxy config domain add acme ' . + '--name=domain.site ' . + '--crt=https://certs.domain.site/fullchain.pem ' . + '--key=https://certs.domain.site/privkey.pem' + ); + $process = $this->workspaceProcess( + 'global service proxy config domain remove acme', + null, + [ + 'MY127WS_ATTR_PROXY_DOMAIN_OVERRIDE' => <<<'YAML' +global: + service: + proxy: + domains: + acme: + name: domain.site + https: + crt: https://certs.domain.site/fullchain.pem + key: https://certs.domain.site/privkey.pem + crt_file: domain.site.crt + key_file: domain.site.key +YAML, + ] + ); + $process->run(); + + self::assertNotSame(0, $process->getExitCode()); + self::assertStringContainsString( + 'configured outside the registry', + $process->getOutput() . $process->getErrorOutput() + ); + + $domains = Yaml::parseFile($this->registryPath())['attributes']['global']['service']['proxy']['domains']; + self::assertArrayHasKey('acme', $domains); + } + + public function testRemoveFailsWhenARegisteredDomainIsShadowedByAnotherGlobalConfig(): void + { + $this->addRegistryDomainShadowedByGlobalOverride(); + + $process = $this->workspaceProcess('global service proxy config domain remove acme'); + $process->run(); + + self::assertNotSame(0, $process->getExitCode()); + self::assertStringContainsString( + 'configured outside the registry', + $process->getOutput() . $process->getErrorOutput() + ); + + $domains = Yaml::parseFile($this->registryPath())['attributes']['global']['service']['proxy']['domains']; + self::assertArrayHasKey('acme', $domains); + } + + public function testRemoveFailsWhenARegisteredDomainIsShadowedByIdenticalGlobalConfig(): void + { + $this->addRegistryDomainShadowedByIdenticalGlobalOverride(); + + $process = $this->workspaceProcess('global service proxy config domain remove acme'); + $process->run(); + + self::assertNotSame(0, $process->getExitCode()); + self::assertStringContainsString( + 'configured outside the registry', + $process->getOutput() . $process->getErrorOutput() + ); + + $domains = Yaml::parseFile($this->registryPath())['attributes']['global']['service']['proxy']['domains']; + self::assertArrayHasKey('acme', $domains); + } + + public function testListRejectsScalarEffectiveProxyDomainsConfig(): void + { + $this->writeGlobalConfig(<<<'YAML' +attribute('global.service.proxy.domains'): disabled +YAML); + + $process = $this->workspaceProcess('global service proxy config domain list'); + $process->run(); + + self::assertNotSame(0, $process->getExitCode()); + self::assertStringContainsString( + 'global.service.proxy.domains must be a map', + $process->getOutput() . $process->getErrorOutput() + ); + } + + private function cleanInstalledHome(): void + { + $homeDir = $_SERVER['MY127WS_HOME'] . '/.my127/workspace'; + + if (!is_dir($homeDir)) { + return; + } + + Filesystem::rrmdir($homeDir); + } + + private function cleanProxyDomainRegistry(): void + { + $configDir = dirname($this->registryPath()); + + if (!is_dir($configDir)) { + return; + } + + foreach (glob($configDir . '/*.yml') as $file) { + unlink($file); + } + } + + private function registryPath(): string + { + return $_SERVER['MY127WS_HOME'] . '/.config/my127/workspace/proxy-domains.yml'; + } + + private function writeGlobalConfig(string $contents, string $filename = 'external.yml'): void + { + $configDir = $_SERVER['MY127WS_HOME'] . '/.config/my127/workspace'; + if (!is_dir($configDir)) { + mkdir($configDir, 0755, true); + } + + file_put_contents($configDir . '/' . $filename, $contents); + } + + private function addRegistryDomainShadowedByGlobalOverride(): void + { + $this->workspaceCommand( + 'global service proxy config domain add acme ' . + '--name=domain.site ' . + '--crt=https://certs.domain.site/fullchain.pem ' . + '--key=https://certs.domain.site/privkey.pem' + ); + $this->writeGlobalConfig(<<<'YAML' +attributes: + global: + service: + proxy: + domains: + acme: + name: override.site + https: + crt: https://certs.override.site/fullchain.pem + key: https://certs.override.site/privkey.pem + crt_file: override.site.crt + key_file: override.site.key +YAML, 'zz-override.yml'); + } + + private function addRegistryDomainShadowedByIdenticalGlobalOverride(): void + { + $this->workspaceCommand( + 'global service proxy config domain add acme ' . + '--name=domain.site ' . + '--crt=https://certs.domain.site/fullchain.pem ' . + '--key=https://certs.domain.site/privkey.pem' + ); + $this->writeGlobalConfig(<<<'YAML' +attributes: + global: + service: + proxy: + domains: + acme: + name: domain.site + https: + crt: https://certs.domain.site/fullchain.pem + key: https://certs.domain.site/privkey.pem + crt_file: domain.site.crt + key_file: domain.site.key +YAML, 'zz-override.yml'); + } +} diff --git a/tests/Test/GlobalService/Proxy/ProxyRuntimeCommandTest.php b/tests/Test/GlobalService/Proxy/ProxyRuntimeCommandTest.php new file mode 100644 index 00000000..1d062bcd --- /dev/null +++ b/tests/Test/GlobalService/Proxy/ProxyRuntimeCommandTest.php @@ -0,0 +1,258 @@ +server !== null) { + $this->server->stop(); + } + } + + protected function setUp(): void + { + parent::setUp(); + + $this->cleanInstalledHome(); + $this->cleanProxyDomainRegistry(); + } + + public function testPrintsTraefikHostRuleForHostPrefix(): void + { + $this->addCustomDomain(); + + self::assertSame( + "Host(`mail.my127.site`) || Host(`mail.domain.site`)\n", + $this->workspaceCommand('global service proxy config rule mail')->getOutput() + ); + self::assertSame( + "Host(`my127.site`) || Host(`domain.site`)\n", + $this->workspaceCommand('global service proxy config rule')->getOutput() + ); + self::assertSame( + "Host(`kibana.my127.site`) || Host(`kibana.domain.site`)\n", + $this->workspaceCommand('global service proxy config rule kibana')->getOutput() + ); + } + + public function testRejectsInvalidTraefikHostRulePrefix(): void + { + $process = $this->workspaceProcess('global service proxy config rule bad_prefix'); + $process->run(); + + self::assertNotSame(0, $process->getExitCode()); + self::assertStringContainsString( + 'host prefix must be DNS labels', + $process->getOutput() . $process->getErrorOutput() + ); + } + + public function testRejectsTooLongTraefikHostRulePrefix(): void + { + $prefix = implode('.', array_fill(0, 4, str_repeat('a', 63))); + $process = $this->workspaceProcess('global service proxy config rule ' . $prefix); + $process->run(); + + self::assertNotSame(0, $process->getExitCode()); + self::assertStringContainsString( + 'hostname length limit', + $process->getOutput() . $process->getErrorOutput() + ); + } + + public function testPrintsTraefikTlsConfiguration(): void + { + $this->addCustomDomain(); + + $tls = Yaml::parse($this->workspaceCommand('global service proxy config tls')->getOutput()); + + self::assertSame([ + 'certFile' => '/tls/my127.site.crt', + 'keyFile' => '/tls/my127.site.key', + ], $tls['tls']['stores']['default']['defaultCertificate']); + self::assertSame([ + [ + 'certFile' => '/tls/domain.site.crt', + 'keyFile' => '/tls/domain.site.key', + ], + ], $tls['tls']['certificates']); + } + + public function testWritesTraefikTlsConfigurationToOutputFile(): void + { + $this->addCustomDomain(); + + $this->workspaceCommand('global service proxy config tls --output=tls.yaml'); + + $tls = Yaml::parse($this->workspace()->getContents('tls.yaml')); + + self::assertSame([ + 'certFile' => '/tls/my127.site.crt', + 'keyFile' => '/tls/my127.site.key', + ], $tls['tls']['stores']['default']['defaultCertificate']); + self::assertSame([ + [ + 'certFile' => '/tls/domain.site.crt', + 'keyFile' => '/tls/domain.site.key', + ], + ], $tls['tls']['certificates']); + } + + public function testRejectsRuntimeTlsFilenameCollisions(): void + { + $this->writeGlobalConfig(<<<'YAML' +attributes: + global: + service: + proxy: + domains: + acme: + name: domain.site + https: + crt: https://certs.domain.site/fullchain.pem + key: https://certs.domain.site/privkey.pem + crt_file: shared.pem + key_file: domain.key + other: + name: other.site + https: + crt: https://certs.other.site/fullchain.pem + key: https://certs.other.site/privkey.pem + crt_file: other.crt + key_file: SHARED.PEM +YAML); + + $process = $this->workspaceProcess('global service proxy config tls'); + $process->run(); + + self::assertNotSame(0, $process->getExitCode()); + self::assertStringContainsString( + 'already uses local TLS filename "SHARED.PEM"', + $process->getOutput() . $process->getErrorOutput() + ); + } + + public function testRejectsScalarProxyDomainEntries(): void + { + $this->writeGlobalConfig(<<<'YAML' +attributes: + global: + service: + proxy: + domains: + acme: disabled +YAML); + + $process = $this->workspaceProcess('global service proxy config rule mail'); + $process->run(); + + self::assertNotSame(0, $process->getExitCode()); + $output = $process->getOutput() . $process->getErrorOutput(); + self::assertStringContainsString('Proxy Domain', $output); + self::assertStringContainsString('"acme" must be a map', $output); + } + + public function testDownloadsConfiguredCertificates(): void + { + $this->startCertificateServer(); + $this->writeGlobalConfig(<<serverUrl}/my127.site.crt +attribute('global.service.proxy.https.key'): {$this->serverUrl}/my127.site.key +YAML); + $this->workspaceCommand( + 'global service proxy config domain add acme ' . + '--name=domain.site ' . + "--crt={$this->serverUrl}/domain.site.crt " . + "--key={$this->serverUrl}/domain.site.key" + ); + + $this->workspaceCommand('global service proxy config certificates download tls'); + + self::assertSame('default certificate', $this->workspace()->getContents('tls/my127.site.crt')); + self::assertSame('default key', $this->workspace()->getContents('tls/my127.site.key')); + self::assertSame('custom certificate', $this->workspace()->getContents('tls/domain.site.crt')); + self::assertSame('custom key', $this->workspace()->getContents('tls/domain.site.key')); + } + + private function startCertificateServer(): void + { + $this->workspace()->put('certs/my127.site.crt', 'default certificate'); + $this->workspace()->put('certs/my127.site.key', 'default key'); + $this->workspace()->put('certs/domain.site.crt', 'custom certificate'); + $this->workspace()->put('certs/domain.site.key', 'custom key'); + + $socket = stream_socket_server('tcp://127.0.0.1:0'); + if ($socket === false) { + throw new \RuntimeException('Could not reserve local HTTP port.'); + } + $address = stream_socket_get_name($socket, false); + fclose($socket); + + $this->serverUrl = 'http://' . $address; + $this->server = new Process([PHP_BINARY, '-S', $address, '-t', $this->workspace()->path('certs')]); + $this->server->start(); + + for ($i = 0; $i < 30; ++$i) { + if (@file_get_contents($this->serverUrl . '/my127.site.crt') === 'default certificate') { + return; + } + usleep(100000); + } + + throw new \RuntimeException('Certificate fixture server did not start.'); + } + + private function writeGlobalConfig(string $contents): void + { + $configDir = $_SERVER['MY127WS_HOME'] . '/.config/my127/workspace'; + if (!is_dir($configDir)) { + mkdir($configDir, 0755, true); + } + + file_put_contents($configDir . '/test.yml', $contents); + } + + private function addCustomDomain(): void + { + $this->workspaceCommand( + 'global service proxy config domain add acme ' . + '--name=domain.site ' . + '--crt=https://certs.domain.site/fullchain.pem ' . + '--key=https://certs.domain.site/privkey.pem' + ); + } + + private function cleanInstalledHome(): void + { + $homeDir = $_SERVER['MY127WS_HOME'] . '/.my127/workspace'; + + if (!is_dir($homeDir)) { + return; + } + + Filesystem::rrmdir($homeDir); + } + + private function cleanProxyDomainRegistry(): void + { + $configDir = $_SERVER['MY127WS_HOME'] . '/.config/my127/workspace'; + + if (!is_dir($configDir)) { + return; + } + + foreach (glob($configDir . '/*.yml') as $file) { + unlink($file); + } + } +} diff --git a/tests/Test/Types/AttributeTest.php b/tests/Test/Types/AttributeTest.php index 10dbb89f..f81ee732 100644 --- a/tests/Test/Types/AttributeTest.php +++ b/tests/Test/Types/AttributeTest.php @@ -214,4 +214,36 @@ public function duplicateAttributeDefinitionDoesNotCauseErrorInConfigDump() $this->assertFalse(strpos($this->workspaceCommand('config dump --key=message')->getOutput(), 'World')); } + + /** @test */ + public function attributeMetadataSourceUsesLatestSourceAtTheSamePrecedence() + { + $attributes = new AttributeCollection(new Expression(new CWD())); + + $attributes->add(['message' => 'first'], 'first.yml', 1); + $attributes->add(['message' => 'second'], 'second.yml', 1); + + $metadata = $attributes->getAttributeMetadata('message'); + $this->assertNotNull($metadata); + + $sources = $metadata['source']; + $this->assertEquals('second.yml', array_pop($sources)); + $this->assertEquals('second', $attributes->get('message')); + } + + /** @test */ + public function attributeMetadataSourceUsesHighestNumericPrecedence() + { + $attributes = new AttributeCollection(new Expression(new CWD())); + + $attributes->add(['message' => 'normal'], 'normal.yml', 6); + $attributes->add(['message' => 'environment'], 'environment.yml', 10); + + $metadata = $attributes->getAttributeMetadata('message'); + $this->assertNotNull($metadata); + + $sources = $metadata['source']; + $this->assertEquals('environment.yml', array_pop($sources)); + $this->assertEquals('environment', $attributes->get('message')); + } }