From b5f7ec22f95d129b26b4cb2e42212bc2268cb12b Mon Sep 17 00:00:00 2001 From: Grant Emsley Date: Mon, 5 Oct 2026 12:40:49 -0400 Subject: [PATCH 01/17] feat(git): let a git run take settings of its own GitCommand::run takes a list of extra settings, passed with -c after the forced ones. Private repositories need this: a stack's credential helper and, for ssh, allowing that transport, apply to that stack's runs only. The forced settings still come first, so the empty credential.helper resets the helper list before a stack's own helper is added. The extra settings are built by the plugin, never taken from user input, and never hold a secret, since a command line is visible to anyone on the server. Co-Authored-By: Claude Opus 5.5 --- source/compose.manager/include/GitCommand.php | 11 ++++++- tests/unit/GitCommandTest.php | 31 +++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/source/compose.manager/include/GitCommand.php b/source/compose.manager/include/GitCommand.php index 5ea5bddb..ea734bfc 100644 --- a/source/compose.manager/include/GitCommand.php +++ b/source/compose.manager/include/GitCommand.php @@ -54,13 +54,18 @@ final class GitCommand * @param string|null $workingDirectory Folder to run in (the clone), or null for a neutral folder * @param array $extraEnvironment Extra environment variables for this run * @param string[] $trustedRepositories Repositories git may use even when another user owns them + * @param string[] $extraConfig Settings for this run only, passed with -c after the forced ones, such as + * a credential helper for one stack. Built by the plugin, never from user + * input, and never holding a secret: anyone on the server can read a + * command line. */ public static function run( array $args, ?string $workingDirectory = null, int $timeoutSeconds = self::DEFAULT_TIMEOUT_SECONDS, array $extraEnvironment = [], - array $trustedRepositories = [] + array $trustedRepositories = [], + array $extraConfig = [] ): ProcessResult { $homeDirectory = self::prepareHomeDirectory(); @@ -69,6 +74,10 @@ public static function run( $command[] = '-c'; $command[] = $setting; } + foreach ($extraConfig as $setting) { + $command[] = '-c'; + $command[] = $setting; + } foreach ($args as $arg) { $command[] = $arg; } diff --git a/tests/unit/GitCommandTest.php b/tests/unit/GitCommandTest.php index a70af62d..0f8d1219 100644 --- a/tests/unit/GitCommandTest.php +++ b/tests/unit/GitCommandTest.php @@ -132,6 +132,37 @@ public function testLocalPathTransportIsAllowed(): void $this->assertStringContainsString('refs/heads/main', $result->stdout); } + public function testExtraConfigComesAfterTheForcedSettings(): void + { + $result = GitCommand::run( + ['config', '--get-all', 'credential.helper'], + null, + GitCommand::DEFAULT_TIMEOUT_SECONDS, + [], + [], + ['credential.helper=store --file=/var/tmp/example'] + ); + + // The forced empty helper resets the list first, so the extra helper is the only one used. + $this->assertTrue($result->succeeded(), $result->stderr); + $this->assertSame("\nstore --file=/var/tmp/example\n", $result->stdout); + } + + public function testExtraConfigCanAllowAnotherTransport(): void + { + $result = GitCommand::run( + ['config', '--get', 'protocol.ssh.allow'], + null, + GitCommand::DEFAULT_TIMEOUT_SECONDS, + [], + [], + ['protocol.ssh.allow=always'] + ); + + $this->assertTrue($result->succeeded(), $result->stderr); + $this->assertSame("always\n", $result->stdout); + } + public function testArgumentsArePassedLiterallyWithoutAShell(): void { $repo = $this->makeRepo(); From 5cb39ab81f6ff2ddc6abf0afcb963dee580bbd8c Mon Sep 17 00:00:00 2001 From: Grant Emsley Date: Mon, 5 Oct 2026 12:54:06 -0400 Subject: [PATCH 02/17] feat(git): store which credential a git stack uses git.json gets its first key added after format 1: credentialId, the credential vault entry used to reach the repository. Missing means none, which is what format 1 did, so every existing file keeps its meaning. save() writes it only when one is set. A stack without a credential then still loads in a version from before this change, and one with a credential is refused there by name, which is right: that version could not reach the repository anyway. It is a key of its own rather than the stack's credential_id file, which holds the registry credential for pulling images: a stack can need both. Co-Authored-By: Claude Opus 5.5 --- .../include/GitStackSettings.php | 86 ++++++++++++++++--- tests/unit/GitStackSettingsTest.php | 55 ++++++++++++ 2 files changed, 129 insertions(+), 12 deletions(-) diff --git a/source/compose.manager/include/GitStackSettings.php b/source/compose.manager/include/GitStackSettings.php index 2c79bf92..c80aa5ae 100644 --- a/source/compose.manager/include/GitStackSettings.php +++ b/source/compose.manager/include/GitStackSettings.php @@ -16,9 +16,11 @@ * Changing the format later. Every git.json any released version wrote must * keep loading, and must keep meaning what it meant: * - The keys of format 1 (KEYS) are always required. - * - A key added later may be missing from a file. It then takes a default - * that does exactly what the plugin did before the key existed, or less, - * never more: a stack written by an older version must behave as it did. + * - A key added later (OPTIONAL_KEYS) may be missing from a file. It then + * takes a default that does exactly what the plugin did before the key + * existed, or less, never more: a stack written by an older version must + * behave as it did. save() writes it only when it differs from that + * default, so a stack that never uses it still loads in an older version. * - A key this code does not know is refused, naming the key: it was * written by a newer version (after a downgrade), and ignoring a setting * could make the stack do something its owner turned off. @@ -33,13 +35,18 @@ final class GitStackSettings public const FILE_NAME = 'git.json'; public const FORMAT_VERSION = 1; - /** @var string[] The keys git.json must contain, no more and no fewer. */ + /** @var string[] The keys git.json must contain (format 1). */ private const KEYS = ['version', 'url', 'branch', 'composePath', 'cloneId', 'cloneDir', 'recreateOnFolderChange']; + /** @var string[] Keys added later, which a file may leave out. */ + private const OPTIONAL_KEYS = ['credentialId']; + /** * @param bool $recreateOnFolderChange Recreate the stack's containers when a deploy changes * anything in the stack's folder, not only when the compose definition changes. A commit * that only edits a bind-mounted config file then takes effect. + * @param string|null $credentialId The credential vault entry used to reach the repository, or + * null for a repository that needs none (the default: format 1 had no credentials). */ private function __construct( public readonly string $url, @@ -47,7 +54,8 @@ private function __construct( public readonly string $composePath, public readonly string $cloneId, public readonly string $cloneDir, - public readonly bool $recreateOnFolderChange + public readonly bool $recreateOnFolderChange, + public readonly ?string $credentialId = null ) { } @@ -75,7 +83,33 @@ public static function createNew( */ public function withRecreateOnFolderChange(bool $recreateOnFolderChange): self { - return new self($this->url, $this->branch, $this->composePath, $this->cloneId, $this->cloneDir, $recreateOnFolderChange); + return new self( + $this->url, + $this->branch, + $this->composePath, + $this->cloneId, + $this->cloneDir, + $recreateOnFolderChange, + $this->credentialId + ); + } + + /** + * The same settings with another credential, or none. + * + * @throws InvalidArgumentException if the id is not a credential vault id + */ + public function withCredentialId(?string $credentialId): self + { + return self::fromValues( + $this->url, + $this->branch, + $this->composePath, + $this->cloneId, + $this->cloneDir, + $this->recreateOnFolderChange, + $credentialId + ); } /** @@ -107,7 +141,7 @@ public static function load(string $stackDir): ?self throw new RuntimeException("$file is not a valid git settings file (expected a JSON object)."); } - $unknown = array_values(array_diff(array_keys($data), self::KEYS)); + $unknown = array_values(array_diff(array_keys($data), self::KEYS, self::OPTIONAL_KEYS)); if ($unknown !== []) { throw new RuntimeException( "$file has settings this version of the plugin does not know: " . implode(', ', $unknown) @@ -132,6 +166,10 @@ public static function load(string $stackDir): ?self if (!is_bool($data['recreateOnFolderChange'])) { throw new RuntimeException("$file: 'recreateOnFolderChange' must be true or false."); } + $credentialId = $data['credentialId'] ?? null; + if ($credentialId !== null && !is_string($credentialId)) { + throw new RuntimeException("$file: 'credentialId' must be a string."); + } try { return self::fromValues( @@ -140,7 +178,8 @@ public static function load(string $stackDir): ?self $data['composePath'], $data['cloneId'], $data['cloneDir'], - $data['recreateOnFolderChange'] + $data['recreateOnFolderChange'], + $credentialId ); } catch (InvalidArgumentException $error) { throw new RuntimeException("$file: " . $error->getMessage(), 0, $error); @@ -163,7 +202,7 @@ public function save(string $stackDir): void throw new RuntimeException("Stack folder $stackDir does not exist."); } - $json = json_encode([ + $data = [ 'version' => self::FORMAT_VERSION, 'url' => $this->url, 'branch' => $this->branch, @@ -171,7 +210,12 @@ public function save(string $stackDir): void 'cloneId' => $this->cloneId, 'cloneDir' => $this->cloneDir, 'recreateOnFolderChange' => $this->recreateOnFolderChange, - ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + ]; + // Keys added later are written only when they differ from their default (see the class comment). + if ($this->credentialId !== null) { + $data['credentialId'] = $this->credentialId; + } + $json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); if ($json === false) { throw new RuntimeException('Could not encode the git settings.'); } @@ -343,6 +387,18 @@ public static function validateComposePath(string $composePath): void } } + /** + * Check a credential vault id: null for none, or the 32 hex characters the vault gives every entry. + * + * @throws InvalidArgumentException naming the problem + */ + public static function validateCredentialId(?string $credentialId): void + { + if ($credentialId !== null && preg_match('/^[0-9a-f]{32}$/', $credentialId) !== 1) { + throw new InvalidArgumentException("The credential id is not valid: $credentialId"); + } + } + /** * Validate every value and build the settings. * @@ -354,7 +410,8 @@ private static function fromValues( string $composePath, string $cloneId, string $cloneDir, - bool $recreateOnFolderChange + bool $recreateOnFolderChange, + ?string $credentialId = null ): self { self::validateUrl($url); self::validateBranch($branch); @@ -371,6 +428,11 @@ private static function fromValues( throw new InvalidArgumentException("The clone folder $cloneDir does not belong to clone id $cloneId."); } - return new self($url, $branch, $composePath, $cloneId, $cloneDir, $recreateOnFolderChange); + self::validateCredentialId($credentialId); + if ($credentialId !== null && str_starts_with($url, '/')) { + throw new InvalidArgumentException('A repository on this server needs no credential.'); + } + + return new self($url, $branch, $composePath, $cloneId, $cloneDir, $recreateOnFolderChange, $credentialId); } } diff --git a/tests/unit/GitStackSettingsTest.php b/tests/unit/GitStackSettingsTest.php index 5f7c13c1..6a8522bc 100644 --- a/tests/unit/GitStackSettingsTest.php +++ b/tests/unit/GitStackSettingsTest.php @@ -235,6 +235,52 @@ public function testRecreateOnFolderChangeIsOnByDefaultAndCanBeTurnedOff(): void $this->assertSame($settings->cloneDir, $loaded->cloneDir); } + public function testNoCredentialByDefaultAndNoneWrittenToTheFile(): void + { + $settings = $this->makeSettings(); + $this->assertNull($settings->credentialId); + + $settings->save($this->stackDir); + + // Left out, so a version from before credentials existed still loads the file. + $data = json_decode((string) file_get_contents($this->stackDir . '/git.json'), true); + $this->assertArrayNotHasKey('credentialId', $data); + $this->assertNull(GitStackSettings::load($this->stackDir)?->credentialId); + } + + public function testCredentialIsSavedAndLoaded(): void + { + $credentialId = str_repeat('ab', 16); + $this->makeSettings()->withCredentialId($credentialId)->save($this->stackDir); + + $loaded = GitStackSettings::load($this->stackDir); + + $this->assertNotNull($loaded); + $this->assertSame($credentialId, $loaded->credentialId); + $this->assertNull($loaded->withCredentialId(null)->credentialId); + } + + public function testRepositoryOnThisServerTakesNoCredential(): void + { + $settings = GitStackSettings::createNew( + $this->mnt . '/user/repos/stacks.git', + 'main', + 'whoami/compose.yaml', + $this->mnt . '/user/appdata/git', + 'whoami' + ); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('needs no credential'); + $settings->withCredentialId(str_repeat('ab', 16)); + } + + public function testCredentialIdThatIsNotAVaultIdIsRefused(): void + { + $this->expectException(InvalidArgumentException::class); + $this->makeSettings()->withCredentialId('../../vault'); + } + public function testStackWithoutGitJsonIsNotAGitStack(): void { $this->assertNull(GitStackSettings::load($this->stackDir)); @@ -270,6 +316,14 @@ public static function brokenFiles(): array $data['branch'] = 5; return (string) json_encode($data); }], + 'credential id not a vault id' => [static function (array $data): string { + $data['credentialId'] = 'not-an-id'; + return (string) json_encode($data); + }], + 'credential id not a string' => [static function (array $data): string { + $data['credentialId'] = 7; + return (string) json_encode($data); + }], 'string instead of true or false' => [static function (array $data): string { $data['recreateOnFolderChange'] = 'yes'; return (string) json_encode($data); @@ -344,6 +398,7 @@ public function testFormatOneLoadsWithTheBehaviourItWasWrittenWith(): void $this->assertSame('https://github.com/owner/repo.git', $settings->url); $this->assertSame('whoami/compose.yaml', $settings->composePath); $this->assertTrue($settings->recreateOnFolderChange); + $this->assertNull($settings->credentialId); } public function testSettingFromANewerVersionIsRefusedByName(): void From 961ff1820032208a2ca200203140c6bb885d25fe Mon Sep 17 00:00:00 2001 From: Grant Emsley Date: Mon, 5 Oct 2026 13:01:40 -0400 Subject: [PATCH 03/17] feat(credentials): add a git repository kind of credential The vault gets a "git" provider: an HTTPS token for reaching a git repository, such as a GitHub fine-grained token or a Forgejo access token. Its registry field holds the repository host only (github.com, or host:port), and saving anything else is refused. useCredential() hands one credential, secret included, to a callback. Git stacks need it to pass the token to git; the secret is never returned to the caller. Co-Authored-By: Claude Opus 5.5 --- .../include/CredentialVault.php | 28 +++++++++- tests/unit/CredentialVaultTest.php | 56 +++++++++++++++++++ 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/source/compose.manager/include/CredentialVault.php b/source/compose.manager/include/CredentialVault.php index d0335014..ee4ecf25 100644 --- a/source/compose.manager/include/CredentialVault.php +++ b/source/compose.manager/include/CredentialVault.php @@ -51,9 +51,13 @@ public function saveCredential(array $input): array if ($name === '' || $registry === '' || $username === '' || $secret === '') { throw new InvalidArgumentException('Name, registry, username, and token are required.'); } - if (!in_array($provider, ['github', 'docker', 'gitlab', 'quay', 'aws', 'azure', 'gcr', 'generic'], true)) { + if (!in_array($provider, ['github', 'docker', 'gitlab', 'quay', 'aws', 'azure', 'gcr', 'generic', 'git'], true)) { throw new InvalidArgumentException('Unsupported credential provider.'); } + // A git repository credential (an HTTPS token for git stacks) is for one host, such as github.com. + if ($provider === 'git' && preg_match('/^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?(?::[0-9]{1,5})?$/', $registry) !== 1) { + throw new InvalidArgumentException('A git credential needs the repository host only, such as github.com.'); + } if (!in_array($authMethod, ['manual', 'oauth_device'], true)) { throw new InvalidArgumentException('Unsupported credential authentication method.'); } @@ -165,9 +169,31 @@ public function testCredential(string $id): array ]; } + /** + * Hand one credential, secret included, to a callback, and return what it returns. + * + * The one place code reads a secret out of the vault, for code that has to pass it on + * to another program: git stacks write it to a short-lived file and remove it after + * the run. Whatever the callback returns, the secret included, goes back to the caller. + * + * @template T + * @param callable(array): T $callback + * @return T + */ + public function useCredential(string $id, callable $callback) + { + $credential = $this->withLock(LOCK_SH, fn(): array => $this->findCredential($id)); + return $callback($credential); + } + public function materializeDockerConfig(string $id): string { $credential = $this->withLock(LOCK_SH, fn(): array => $this->findCredential($id)); + // A git token is never a registry login. Stack settings refuse to assign one, but a hand-edited + // or restored credential_id could still name one: never write it out as a Docker login. + if (($credential['provider'] ?? '') === 'git') { + throw new RuntimeException("The credential '{$credential['name']}' is a git repository credential, not a registry login."); + } // Confirms which named credential a compose operation is using, without ever logging the secret. composeLogger( "Using registry credential '{$credential['name']}' ({$credential['registry']}) for this operation", diff --git a/tests/unit/CredentialVaultTest.php b/tests/unit/CredentialVaultTest.php index 1dd3d152..300b6fb0 100644 --- a/tests/unit/CredentialVaultTest.php +++ b/tests/unit/CredentialVaultTest.php @@ -194,6 +194,62 @@ public function testRejectsUnsupportedProvider(): void ]); } + public function testGitCredentialIsForOneHost(): void + { + $vault = new CredentialVault(); + $saved = $vault->saveCredential([ + 'name' => 'Git stacks', + 'provider' => 'git', + 'registry' => 'https://GitHub.com/', + 'username' => 'bot', + 'secret' => 'token123', + ]); + $this->assertSame('git', $saved['provider']); + $this->assertSame('github.com', $saved['registry']); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('repository host only'); + $vault->saveCredential([ + 'name' => 'Too much', + 'provider' => 'git', + 'registry' => 'github.com/owner/repo.git', + 'username' => 'bot', + 'secret' => 'token123', + ]); + } + + public function testUseCredentialHandsTheSecretToTheCallbackOnly(): void + { + $vault = new CredentialVault(); + $saved = $vault->saveCredential([ + 'name' => 'Git stacks', + 'provider' => 'git', + 'registry' => 'github.com', + 'username' => 'bot', + 'secret' => 'token123', + ]); + + $seen = $vault->useCredential($saved['id'], static fn(array $credential): string => $credential['secret']); + + $this->assertSame('token123', $seen); + } + + public function testGitCredentialIsNeverWrittenOutAsADockerLogin(): void + { + $vault = new CredentialVault(); + $saved = $vault->saveCredential([ + 'name' => 'Git stacks', + 'provider' => 'git', + 'registry' => 'github.com', + 'username' => 'bot', + 'secret' => 'token123', + ]); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('is a git repository credential, not a registry login'); + $vault->materializeDockerConfig($saved['id']); + } + public function testGetCredentialSummaryOmitsSecret(): void { $vault = new CredentialVault(); From 618b467491fd98985fcaa40ad4981ed4c0b3f638 Mon Sep 17 00:00:00 2001 From: Grant Emsley Date: Mon, 5 Oct 2026 13:01:40 -0400 Subject: [PATCH 04/17] feat(git): reach a private repository with an HTTPS token A git stack with a credential gets it on every git run: the token is written to a file only root can read under /var/tmp (in RAM), git is pointed at it with credential.helper=store, and the file is removed when the run ends. A file left by a run that was killed is removed by a later run once it is an hour old: every git run is time-limited to far less. The token is never on a command line, in the environment, in the clone's .git/config or in a log. The file holds the stack's own repository address, and git matches the path as well as the host (credential.useHttpPath), so the token is offered to that repository only. A credential for another host, or one that is not a git credential, is refused by name. Every run gets it, not only clone and fetch, because the clone is partial (--filter=blob:none): a checkout can fetch file contents too. Two git messages that point the wrong way get a line of their own: a server that redirects to another address (the token is not offered there), and a repository that asks for a password when the stack has no credential. Co-Authored-By: Claude Opus 5.5 --- source/compose.manager/include/Defines.php | 2 + source/compose.manager/include/GitClone.php | 50 ++++- .../include/GitCredentials.php | 132 ++++++++++++ tests/bootstrap.php | 1 + tests/unit/GitCredentialsTest.php | 196 ++++++++++++++++++ 5 files changed, 380 insertions(+), 1 deletion(-) create mode 100644 source/compose.manager/include/GitCredentials.php create mode 100644 tests/unit/GitCredentialsTest.php diff --git a/source/compose.manager/include/Defines.php b/source/compose.manager/include/Defines.php index 4a2ca601..90afe554 100644 --- a/source/compose.manager/include/Defines.php +++ b/source/compose.manager/include/Defines.php @@ -42,6 +42,8 @@ function locate_compose_root($name) defined('COMPOSE_MOUNTS_FILE') || define('COMPOSE_MOUNTS_FILE', '/proc/self/mounts'); defined('COMPOSE_GIT_MNT_DIR') || define('COMPOSE_GIT_MNT_DIR', '/mnt'); defined('COMPOSE_GIT_HOME_DIR') || define('COMPOSE_GIT_HOME_DIR', '/var/tmp/compose-manager-git-home'); +// Short-lived files that hand a git stack's credential to git for one run (in RAM, emptied at reboot). +defined('COMPOSE_GIT_CREDENTIAL_DIR') || define('COMPOSE_GIT_CREDENTIAL_DIR', '/var/tmp/compose-manager-git-credentials'); defined('COMPOSE_GIT_DEFAULT_CLONES_ROOT') || define('COMPOSE_GIT_DEFAULT_CLONES_ROOT', '/mnt/user/appdata/compose.manager/git'); // The docker CLI (the git deploy checks run it; tests point this at a scripted stand-in). defined('COMPOSE_DOCKER_BIN') || define('COMPOSE_DOCKER_BIN', 'docker'); diff --git a/source/compose.manager/include/GitClone.php b/source/compose.manager/include/GitClone.php index d0c74771..78909443 100644 --- a/source/compose.manager/include/GitClone.php +++ b/source/compose.manager/include/GitClone.php @@ -6,6 +6,7 @@ require_once '/usr/local/emhttp/plugins/compose.manager/include/GitPathGuard.php'; require_once '/usr/local/emhttp/plugins/compose.manager/include/GitCommand.php'; require_once '/usr/local/emhttp/plugins/compose.manager/include/GitStackSettings.php'; +require_once '/usr/local/emhttp/plugins/compose.manager/include/GitCredentials.php'; /** * Thrown when a clone folder is not one this plugin can prove it made for this stack. @@ -668,12 +669,59 @@ private function isLocalRepository(): bool * it ("dubious ownership"). The stack's own repository path is trusted * for the run, and nothing else (see GitCommand). * + * A stack with a credential gets it for every run, not only clone and + * fetch: the clone is partial (--filter=blob:none), so a checkout or diff + * can fetch file contents from the repository too. + * * @param string[] $args */ private function git(array $args, ?string $workingDirectory, int $timeoutSeconds = GitCommand::DEFAULT_TIMEOUT_SECONDS): ProcessResult { $trusted = $this->isLocalRepository() ? [$this->settings->url] : []; - return GitCommand::run($args, $workingDirectory, $timeoutSeconds, [], $trusted); + if ($this->settings->credentialId === null) { + $result = GitCommand::run($args, $workingDirectory, $timeoutSeconds, [], $trusted); + } else { + $credentialFile = GitCredentials::writeFile($this->settings->credentialId, $this->settings->url); + try { + $result = GitCommand::run( + $args, + $workingDirectory, + $timeoutSeconds, + [], + $trusted, + GitCredentials::settingsFor($credentialFile) + ); + } finally { + GitCredentials::remove($credentialFile); + } + } + if (!$result->succeeded()) { + $hint = self::httpsFailureHint($result->stderr, $this->settings->credentialId !== null); + if ($hint !== null) { + throw new RuntimeException($result->errorSummary() . "\n" . $hint); + } + } + return $result; + } + + /** + * What to tell the person when git's own message for a failed https run + * points the wrong way, or null when it is clear enough. + */ + public static function httpsFailureHint(string $stderr, bool $hasCredential): ?string + { + // The token is offered only for the exact address the stack uses (credential.useHttpPath), + // so after a redirect git asks for the new address and gets nothing. + if ($hasCredential && preg_match('/^warning: redirecting to (\S+)/m', $stderr, $match) === 1) { + return "The server redirected to {$match[1]}, and the stack's credential is offered only for the address " + . 'the stack uses. Use the address the server redirects to (usually the one ending in .git).'; + } + if (!$hasCredential && str_contains($stderr, 'terminal prompts disabled')) { + return 'The repository asks for a user name and password, so it needs a credential: add a git token on the ' + . 'Credentials tab, then name it with --credential when adding the stack, or with compose-git credential ' + . ' for a stack that exists.'; + } + return null; } /** diff --git a/source/compose.manager/include/GitCredentials.php b/source/compose.manager/include/GitCredentials.php new file mode 100644 index 00000000..126bc9f4 --- /dev/null +++ b/source/compose.manager/include/GitCredentials.php @@ -0,0 +1,132 @@ +useCredential( + $credentialId, + static function (array $credential) use ($host, $repository): string { + if (($credential['provider'] ?? '') !== 'git') { + throw new RuntimeException("The credential '{$credential['name']}' is not a git repository credential."); + } + if (($credential['registry'] ?? '') !== $host) { + throw new RuntimeException( + "The credential '{$credential['name']}' is for {$credential['registry']}, but the repository is on $host." + ); + } + return 'https://' . rawurlencode($credential['username']) . ':' . rawurlencode($credential['secret']) + . '@' . $host . $repository['path'] . "\n"; + } + ); + + $directory = self::prepareDirectory(); + self::removeStaleFiles($directory); + + $file = $directory . '/' . bin2hex(random_bytes(16)); + // Created empty with root-only permissions first, so the token is never in a file others can read. + $handle = @fopen($file, 'x'); + if ($handle === false) { + throw new RuntimeException('Could not create a credential file for git.'); + } + chmod($file, 0600); + $written = fwrite($handle, $line); + fclose($handle); + if ($written !== strlen($line)) { + @unlink($file); + throw new RuntimeException('Could not write the credential file for git.'); + } + return $file; + } + + /** + * The settings that make git use a credential file (for GitCommand::run's $extraConfig). + * + * @return string[] + */ + public static function settingsFor(string $file): array + { + return [ + 'credential.helper=store --file=' . $file, + 'credential.useHttpPath=true', + ]; + } + + /** + * Remove a credential file written by writeFile(). + */ + public static function remove(string $file): void + { + $directory = realpath(COMPOSE_GIT_CREDENTIAL_DIR); + $target = realpath($file); + if ($directory === false || $target === false || dirname($target) !== $directory) { + return; + } + @unlink($target); + } + + private static function prepareDirectory(): string + { + $directory = rtrim(COMPOSE_GIT_CREDENTIAL_DIR, '/'); + if (!is_dir($directory) && !@mkdir($directory, 0700, true) && !is_dir($directory)) { + throw new RuntimeException('Could not create the folder for git credential files.'); + } + chmod($directory, 0700); + return $directory; + } + + /** + * Remove files left behind by runs that were killed before they could clean up. + * + * Every run that uses one of these files is time-limited to well under an + * hour (GitCommand's and GitClone's timeouts), so a file older than that + * belongs to no run that is still going. + */ + private static function removeStaleFiles(string $directory): void + { + foreach (glob($directory . '/*') ?: [] as $file) { + $modified = @filemtime($file); + if ($modified === false || time() - $modified <= self::STALE_AFTER_SECONDS) { + continue; + } + self::remove($file); + } + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 7615a76c..b64731e3 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -52,6 +52,7 @@ function composeLogger($message, $data = null, $type = 'daemon', $level = 'info' define('COMPOSE_UNRAID_VAR_INI', sys_get_temp_dir() . '/compose_git_var.ini'); define('COMPOSE_MOUNTS_FILE', sys_get_temp_dir() . '/compose_git_mounts'); define('COMPOSE_GIT_HOME_DIR', sys_get_temp_dir() . '/compose_git_home'); +define('COMPOSE_GIT_CREDENTIAL_DIR', sys_get_temp_dir() . '/compose_git_credentials'); // A scripted stand-in for the docker CLI (see FakeDocker in the git deploy tests). define('COMPOSE_DOCKER_BIN', sys_get_temp_dir() . '/compose_fake_docker/docker'); define('COMPOSE_LOCK_DIR', sys_get_temp_dir() . '/compose_manager_locks'); diff --git a/tests/unit/GitCredentialsTest.php b/tests/unit/GitCredentialsTest.php new file mode 100644 index 00000000..8f683be6 --- /dev/null +++ b/tests/unit/GitCredentialsTest.php @@ -0,0 +1,196 @@ +gitCredential('github.com'), 'https://github.com/owner/repo.git'); + + $this->assertSame("https://bot:" . self::TOKEN . "@github.com/owner/repo.git\n", file_get_contents($file)); + $this->assertSame(0600, fileperms($file) & 0777); + $this->assertSame(0700, fileperms(COMPOSE_GIT_CREDENTIAL_DIR) & 0777); + GitCredentials::remove($file); + $this->assertFileDoesNotExist($file); + } + + public function testGitGetsTheTokenForTheStacksRepositoryOnly(): void + { + $file = GitCredentials::writeFile($this->gitCredential('github.com'), 'https://github.com/owner/repo.git'); + + $this->assertStringContainsString('password=' . self::TOKEN, $this->credentialFill($file, 'owner/repo.git')); + // Another repository on the same host gets nothing. + $this->assertStringNotContainsString(self::TOKEN, $this->credentialFill($file, 'owner/other.git')); + GitCredentials::remove($file); + } + + public function testUserNameAndTokenWithSpecialCharactersArePassedExactly(): void + { + $id = (new CredentialVault())->saveCredential([ + 'name' => 'Odd', + 'provider' => 'git', + 'registry' => 'git.example.com:3000', + 'username' => 'me@example.com', + 'secret' => 'p@ss:w/rd%', + ])['id']; + $file = GitCredentials::writeFile($id, 'https://git.example.com:3000/team/stacks.git'); + + $answer = $this->credentialFill($file, 'team/stacks.git', 'git.example.com:3000'); + + $this->assertStringContainsString("username=me@example.com\n", $answer); + $this->assertStringContainsString("password=p@ss:w/rd%\n", $answer); + GitCredentials::remove($file); + } + + public function testCredentialForAnotherHostIsRefused(): void + { + $id = $this->gitCredential('gitlab.com'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('is for gitlab.com, but the repository is on github.com'); + GitCredentials::writeFile($id, 'https://github.com/owner/repo.git'); + } + + public function testRegistryCredentialIsRefused(): void + { + $id = (new CredentialVault())->saveCredential([ + 'name' => 'Docker Hub', + 'provider' => 'docker', + 'registry' => 'docker.io', + 'username' => 'bot', + 'secret' => self::TOKEN, + ])['id']; + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('is not a git repository credential'); + GitCredentials::writeFile($id, 'https://github.com/owner/repo.git'); + } + + public function testMissingCredentialIsRefused(): void + { + $this->expectException(RuntimeException::class); + GitCredentials::writeFile(str_repeat('0', 32), 'https://github.com/owner/repo.git'); + } + + public function testLocalRepositoryPathIsRefused(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('https://'); + GitCredentials::writeFile($this->gitCredential('github.com'), '/mnt/user/repos/stacks.git'); + } + + public function testRemoveTouchesOnlyFilesInTheCredentialFolder(): void + { + $outside = tempnam(sys_get_temp_dir(), 'not-a-credential'); + GitCredentials::remove($outside); + $this->assertFileExists($outside); + unlink($outside); + } + + public function testFileLeftByAKilledRunIsRemovedByTheNextOne(): void + { + $id = $this->gitCredential('github.com'); + $old = GitCredentials::writeFile($id, 'https://github.com/owner/repo.git'); + touch($old, time() - 7200); + $recent = GitCredentials::writeFile($id, 'https://github.com/owner/repo.git'); + touch($recent, time() - 60); + + $new = GitCredentials::writeFile($id, 'https://github.com/owner/repo.git'); + + $this->assertFileDoesNotExist($old); + $this->assertFileExists($recent); + GitCredentials::remove($recent); + GitCredentials::remove($new); + } + + private function gitCredential(string $host): string + { + return (new CredentialVault())->saveCredential([ + 'name' => 'Git ' . $host, + 'provider' => 'git', + 'registry' => $host, + 'username' => 'bot', + 'secret' => self::TOKEN, + ])['id']; + } + + /** + * Ask git, the way it asks before a fetch, for the credential of one repository. + * + * Run directly rather than through GitCommand, which gives git no input: the + * request goes to git on stdin. The settings are the ones a stack's run gets. + */ + private function credentialFill(string $file, string $path, string $host = 'github.com'): string + { + $command = ['git', '-c', 'credential.helper=']; + foreach (GitCredentials::settingsFor($file) as $setting) { + $command[] = '-c'; + $command[] = $setting; + } + $command[] = 'credential'; + $command[] = 'fill'; + $environment = [ + 'PATH' => '/usr/local/bin:/usr/bin:/bin', + 'HOME' => sys_get_temp_dir(), + 'GIT_CONFIG_NOSYSTEM' => '1', + 'GIT_CONFIG_GLOBAL' => '/dev/null', + 'GIT_TERMINAL_PROMPT' => '0', + 'GIT_ASKPASS' => '/bin/false', + ]; + $process = proc_open($command, [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, null, $environment); + $this->assertIsResource($process); + fwrite($pipes[0], "protocol=https\nhost=$host\npath=$path\n\n"); + fclose($pipes[0]); + $output = (string) stream_get_contents($pipes[1]); + fclose($pipes[1]); + fclose($pipes[2]); + proc_close($process); + return $output; + } + + public function testARedirectIsNamedWhenTheTokenWasNotOfferedForTheNewAddress(): void + { + $stderr = "warning: redirecting to https://gitlab.example.com/group/project.git/\n" + . "fatal: Authentication failed for 'https://gitlab.example.com/group/project.git/'\n"; + + $hint = \GitClone::httpsFailureHint($stderr, true); + + $this->assertNotNull($hint); + $this->assertStringContainsString('redirected to https://gitlab.example.com/group/project.git/', $hint); + // Without a credential a redirect is followed and needs no hint. + $this->assertNull(\GitClone::httpsFailureHint($stderr, false)); + } + + public function testAPrivateRepositoryWithoutACredentialSaysItNeedsOne(): void + { + $stderr = "fatal: could not read Username for 'https://github.com': terminal prompts disabled\n"; + + $this->assertStringContainsString('needs a credential', (string) \GitClone::httpsFailureHint($stderr, false)); + $this->assertNull(\GitClone::httpsFailureHint("fatal: repository 'https://github.com/x/y.git/' not found\n", false)); + } +} From 080646be15c7408061dc8017cc6d4ffa27bca5f8 Mon Sep 17 00:00:00 2001 From: Grant Emsley Date: Mon, 5 Oct 2026 13:05:01 -0400 Subject: [PATCH 05/17] feat(credentials): count a git stack's repository credential as in use The credential list's "Used by" and the refusal to delete a credential that a stack uses looked only at each stack's credential_id file (its registry credential). They now also read the credentialId in a git stack's git.json. git.json is read as plain JSON, not through its full validation, so a file that has since become invalid still keeps its credential from being deleted. Co-Authored-By: Claude Opus 5.5 --- source/compose.manager/include/Exec.php | 13 +++++++++++++ tests/unit/ExecActionsTest.php | 18 ++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/source/compose.manager/include/Exec.php b/source/compose.manager/include/Exec.php index 13a3c646..b8ef5d44 100644 --- a/source/compose.manager/include/Exec.php +++ b/source/compose.manager/include/Exec.php @@ -184,6 +184,7 @@ function composePurgeDeletedStackCaches(string $stackName): array /** * Single-pass scan of every stack's `credential_id` file, grouped by credential id. * Avoids re-globbing/re-reading the compose root once per credential. + * A git stack's repository credential (`credentialId` in its git.json) counts too. * * @return array Credential id => assigned stack names */ @@ -197,6 +198,18 @@ function composeBuildCredentialStackMap(string $composeRoot): array } $map[$credentialId][] = basename(dirname($credentialFile)); } + foreach (glob(rtrim($composeRoot, '/') . '/*/git.json') ?: [] as $gitSettingsFile) { + // Read as plain JSON: a git.json that fails validation still keeps its credential in use. + $gitSettings = json_decode((string) @file_get_contents($gitSettingsFile), true); + $credentialId = is_array($gitSettings) ? ($gitSettings['credentialId'] ?? null) : null; + if (!is_string($credentialId) || $credentialId === '') { + continue; + } + $stack = basename(dirname($gitSettingsFile)); + if (!in_array($stack, $map[$credentialId] ?? [], true)) { + $map[$credentialId][] = $stack; + } + } return $map; } } diff --git a/tests/unit/ExecActionsTest.php b/tests/unit/ExecActionsTest.php index f968d67f..30060cac 100644 --- a/tests/unit/ExecActionsTest.php +++ b/tests/unit/ExecActionsTest.php @@ -840,6 +840,24 @@ public function testCredentialCrudAndStackAssignmentRoundTrip(): void $this->assertSame(['test-stack'], $deleteResult['stacks']); } + public function testCredentialUsedByAGitStackCannotBeDeleted(): void + { + $stackPath = $this->createTestStack('git-stack'); + $saved = json_decode($this->executeAction('saveCredential', [ + 'name' => 'Git stacks', 'provider' => 'git', 'registry' => 'github.com', + 'username' => 'bot', 'secret' => 'repository-token', + ]), true); + $credentialId = $saved['credential']['id']; + file_put_contents($stackPath . '/git.json', (string) json_encode(['credentialId' => $credentialId])); + + $listed = json_decode($this->executeAction('listCredentials'), true); + $this->assertSame(['git-stack'], $listed['credentials'][0]['stacks']); + + $deleteResult = json_decode($this->executeAction('deleteCredential', ['id' => $credentialId]), true); + $this->assertSame('error', $deleteResult['result']); + $this->assertSame(['git-stack'], $deleteResult['stacks']); + } + public function testGetStackSettingsReturnsExternalComposeFileForFileMode(): void { $stackPath = $this->createTestStack('test-stack'); From 567cc2763e8015a2ce9d5143f9701be6f1f51731 Mon Sep 17 00:00:00 2001 From: Grant Emsley Date: Mon, 5 Oct 2026 13:10:19 -0400 Subject: [PATCH 06/17] feat(git): choose a git stack's credential from compose-git add and convert take --credential , a git credential from the plugin's Credentials tab, for a private repository. The new credential command changes or removes it on an existing stack, and saves the change only after the repository has been reached with it. status shows which credential a stack uses. A credential is found by its exact name or its id, and only among git credentials; two with the same name are not guessed between. Co-Authored-By: Claude Opus 5.5 --- .../include/GitStackManager.php | 115 ++++++++++++++++-- .../compose.manager/scripts/compose_git.php | 47 ++++++- tests/unit/GitStackManagerTest.php | 85 ++++++++++++- 3 files changed, 232 insertions(+), 15 deletions(-) diff --git a/source/compose.manager/include/GitStackManager.php b/source/compose.manager/include/GitStackManager.php index 847ff566..5504391f 100644 --- a/source/compose.manager/include/GitStackManager.php +++ b/source/compose.manager/include/GitStackManager.php @@ -8,6 +8,7 @@ require_once '/usr/local/emhttp/plugins/compose.manager/include/GitStackSettings.php'; require_once '/usr/local/emhttp/plugins/compose.manager/include/GitStackState.php'; require_once '/usr/local/emhttp/plugins/compose.manager/include/GitClone.php'; +require_once '/usr/local/emhttp/plugins/compose.manager/include/CredentialVault.php'; /** * Creates and looks after git stacks: what the compose-git command does, @@ -46,7 +47,8 @@ public function add( string $branch, string $composePath, ?string $clonesRoot, - string $description = '' + string $description = '', + ?string $credentialId = null ): string { $this->assertArrayStarted(); $folder = StackInfo::sanitizeProjectString($stackName); @@ -58,7 +60,8 @@ public function add( throw new RuntimeException("A stack folder named '$folder' already exists. Choose another name, or use convert for that stack."); } - $settings = GitStackSettings::createNew($url, $branch, $composePath, $clonesRoot ?? COMPOSE_GIT_DEFAULT_CLONES_ROOT, $folder); + $settings = GitStackSettings::createNew($url, $branch, $composePath, $clonesRoot ?? COMPOSE_GIT_DEFAULT_CLONES_ROOT, $folder) + ->withCredentialId($credentialId); ($this->say)("Cloning $url ($branch) into {$settings->cloneDir}..."); (new GitClone($settings))->create(); @@ -80,6 +83,9 @@ public function add( throw new RuntimeException($error->getMessage() . "\n" . $leftBehind, 0, $error); } ($this->say)("Created stack '{$stack->projectFolder}'. It is not deployed yet."); + if ($settings->credentialId !== null) { + $this->logCredentialChange($stack->projectFolder, $settings->credentialId); + } return $stack->projectFolder; } @@ -95,8 +101,14 @@ public function add( * @return string The backup folder * @throws RuntimeException|InvalidArgumentException naming why nothing was changed */ - public function convert(string $folder, string $url, string $branch, string $composePath, ?string $clonesRoot): string - { + public function convert( + string $folder, + string $url, + string $branch, + string $composePath, + ?string $clonesRoot, + ?string $credentialId = null + ): string { $this->assertArrayStarted(); $stack = $this->stack($folder); if ($stack->isGitStack()) { @@ -104,14 +116,15 @@ public function convert(string $folder, string $url, string $branch, string $com } $stackDir = $stack->path; - return $this->withStackLock($stack, function () use ($stack, $stackDir, $folder, $url, $branch, $composePath, $clonesRoot): string { + return $this->withStackLock($stack, function () use ($stack, $stackDir, $folder, $url, $branch, $composePath, $clonesRoot, $credentialId): string { $oldOverride = $stack->getOverridePath(); $oldEnv = $stack->getEffectiveEnvFilePath(); $hasExplicitEnvPath = trim((string) @file_get_contents($stackDir . '/envpath')) !== ''; $oldComposeFile = $stack->isIndirect ? null : $stack->composeFilePath; $foldersNextToOldComposeFile = $this->foldersIn($stack->composeSource); - $settings = GitStackSettings::createNew($url, $branch, $composePath, $clonesRoot ?? COMPOSE_GIT_DEFAULT_CLONES_ROOT, $folder); + $settings = GitStackSettings::createNew($url, $branch, $composePath, $clonesRoot ?? COMPOSE_GIT_DEFAULT_CLONES_ROOT, $folder) + ->withCredentialId($credentialId); ($this->say)("Cloning $url ($branch) into {$settings->cloneDir}..."); (new GitClone($settings))->create(); @@ -185,6 +198,9 @@ public function convert(string $folder, string $url, string $branch, string $com } ($this->say)("'$folder' is now a git stack. Its containers change at the next deploy."); + if ($settings->credentialId !== null) { + $this->logCredentialChange($folder, $settings->credentialId); + } if ($foldersNextToOldComposeFile !== []) { // The compose file is not parsed here; this is only a reminder. ($this->say)( @@ -242,7 +258,7 @@ public function check(string $folder): array /** * What is known about a git stack locally, without asking the remote. * - * @return array{stack: string, url: string, branch: string, composePath: string, cloneDir: string, recreateOnFolderChange: bool, deployedCommit: ?string, failedCommit: ?string, checkedOutCommit: ?string, localChanges: list, problem: ?string} + * @return array{stack: string, url: string, branch: string, composePath: string, cloneDir: string, recreateOnFolderChange: bool, credential: ?string, deployedCommit: ?string, failedCommit: ?string, checkedOutCommit: ?string, localChanges: list, problem: ?string} */ public function status(string $folder): array { @@ -266,6 +282,7 @@ public function status(string $folder): array 'composePath' => $settings->composePath, 'cloneDir' => $settings->cloneDir, 'recreateOnFolderChange' => $settings->recreateOnFolderChange, + 'credential' => $settings->credentialId === null ? null : $this->credentialName($settings->credentialId), 'deployedCommit' => $state->deployedCommit, 'failedCommit' => $state->failedCommit, 'checkedOutCommit' => $checkedOut, @@ -274,6 +291,90 @@ public function status(string $folder): array ]; } + /** + * Change the credential a git stack uses to reach its repository, or remove it. + * + * The repository is reached with the new setting first, and nothing is + * saved unless that works. + * + * @throws RuntimeException|InvalidArgumentException naming why nothing was changed + */ + public function setCredential(string $folder, ?string $credentialId): void + { + $this->assertArrayStarted(); + $stack = $this->stack($folder); + $settings = $this->settings($stack); + + $this->withStackLock($stack, function () use ($stack, $settings, $credentialId): void { + $changed = $settings->withCredentialId($credentialId); + ($this->say)("Checking that {$changed->url} can be reached with the new setting..."); + (new GitClone($changed))->remoteBranchCommit(); + $changed->save($stack->path); + }); + $this->logCredentialChange($folder, $credentialId); + ($this->say)($credentialId === null + ? "'$folder' now reaches its repository without a credential." + : "'$folder' now uses the credential '" . $this->credentialName($credentialId) . "'."); + } + + /** + * The id of a git repository credential, given its exact name or its id. + * + * @throws RuntimeException if there is none, or several with that name + */ + public function findGitCredential(string $nameOrId): string + { + $gitCredentials = array_values(array_filter( + (new CredentialVault())->listCredentials(), + static fn(array $credential): bool => ($credential['provider'] ?? '') === 'git' + )); + $matches = array_values(array_filter( + $gitCredentials, + static fn(array $credential): bool => $credential['id'] === $nameOrId || $credential['name'] === $nameOrId + )); + if (count($matches) === 1) { + return $matches[0]['id']; + } + if (count($matches) > 1) { + throw new RuntimeException("Several git credentials are named '$nameOrId'. Use the credential's id instead."); + } + $names = array_map(static fn(array $credential): string => $credential['name'], $gitCredentials); + throw new RuntimeException( + "There is no git credential named '$nameOrId'. " + . ($names === [] ? 'Add one on the Credentials tab of the plugin settings first.' : 'Git credentials: ' . implode(', ', $names) . '.') + ); + } + + /** + * A credential's name for messages, or a note that it is gone. + */ + private function credentialName(string $credentialId): string + { + try { + return (new CredentialVault())->getCredentialSummary($credentialId)['name']; + } catch (Throwable) { + return "$credentialId (no longer in the credential vault)"; + } + } + + /** + * Record in the syslog which credential a git stack now uses, as the web UI's credential actions do. + */ + private function logCredentialChange(string $folder, ?string $credentialId): void + { + if ($credentialId === null) { + composeLogger("Git stack '$folder' no longer uses a credential", null, 'user', 'info', 'credentials'); + return; + } + composeLogger( + "Git stack '$folder' now uses the credential '" . $this->credentialName($credentialId) . "'", + ['id' => $credentialId], + 'user', + 'info', + 'credentials' + ); + } + /** * Replace a stack's clone with a fresh one at the deployed commit. * diff --git a/source/compose.manager/scripts/compose_git.php b/source/compose.manager/scripts/compose_git.php index 7d732395..ab4580fb 100644 --- a/source/compose.manager/scripts/compose_git.php +++ b/source/compose.manager/scripts/compose_git.php @@ -29,8 +29,9 @@ final class ComposeGitUsageError extends InvalidArgumentException const COMPOSE_GIT_USAGE = <<<'TEXT' Usage: - compose-git add --url --path [--branch ] [--clones-root ] [--description ] - compose-git convert --url --path [--branch ] [--clones-root ] + compose-git add --url --path [--branch ] [--clones-root ] [--description ] [--credential ] + compose-git convert --url --path [--branch ] [--clones-root ] [--credential ] + compose-git credential |--none compose-git check |--all compose-git deploy [--commit ] [--save-local-changes] [--wait|--no-wait] [--wait-timeout ] [--profile ]... compose-git reclone @@ -39,6 +40,8 @@ final class ComposeGitUsageError extends InvalidArgumentException is an https address (no user name or password in it) or the path of a repository under /mnt. is the compose file's path inside the repository, such as stacks/whoami/compose.yaml. --branch defaults to main. is the stack's exact folder name. +--credential names a git credential (an HTTPS token) from the plugin's Credentials tab, for a +private repository; credential changes or removes it on an existing stack. deploy waits for healthy containers when the stack's wait-for-healthy setting says so; --wait and --no-wait override it. @@ -77,6 +80,8 @@ function compose_git_command_help(string $command): ?string share, disk or pool, and the share must exist. Default: /mnt/user/appdata/compose.manager/git. --description The description shown for the stack on the Compose page. + --credential A git credential (an HTTPS token) from the Credentials tab, by + its name or id, for a private repository. Example: compose-git add whoami --url https://github.com/you/stacks.git --path whoami/compose.yaml @@ -106,8 +111,22 @@ function compose_git_command_help(string $command): ?string --clones-root The folder the clone goes in, as -. It must be on a share, disk or pool, and the share must exist. Default: /mnt/user/appdata/compose.manager/git. + --credential A git credential (an HTTPS token) from the Credentials tab, by + its name or id, for a private repository. Then deploy it: compose-git deploy +TEXT, + 'credential' => <<<'TEXT' +Usage: compose-git credential + compose-git credential --none + +Change the git credential a stack uses to reach its repository, or remove it. is a +git credential (an HTTPS token) from the plugin's Credentials tab, by its name or id. The +repository is reached with the new setting first, and nothing is saved unless that works. +No container is touched. + +Options: + --none Reach the repository without a credential, as for a public one. TEXT, 'check' => <<<'TEXT' Usage: compose-git check @@ -412,6 +431,9 @@ function compose_git_print_status(array $status): void echo ' repository: ' . $status['url'] . ' (' . $status['branch'] . ")\n"; echo ' compose file: ' . $status['composePath'] . "\n"; echo ' clone: ' . $status['cloneDir'] . "\n"; + if ($status['credential'] !== null) { + echo ' credential: ' . $status['credential'] . "\n"; + } echo ' deployed: ' . $short($status['deployedCommit']) . "\n"; if ($status['failedCommit'] !== null) { echo ' failed: ' . $short($status['failedCommit']) . " (fix it and deploy again)\n"; @@ -449,31 +471,44 @@ function compose_git_main(array $argv, string $composeRoot, $errors = null): int try { switch ($command) { case 'add': - [$positional, $options] = compose_git_parse($args, ['url', 'path', 'branch', 'clones-root', 'description'], []); + [$positional, $options] = compose_git_parse($args, ['url', 'path', 'branch', 'clones-root', 'description', 'credential'], []); $folder = $manager->add( compose_git_one_stack($positional), compose_git_required($options, 'url'), (string) ($options['branch'] ?? GitStackManager::DEFAULT_BRANCH), compose_git_required($options, 'path'), isset($options['clones-root']) ? (string) $options['clones-root'] : null, - (string) ($options['description'] ?? '') + (string) ($options['description'] ?? ''), + isset($options['credential']) ? $manager->findGitCredential((string) $options['credential']) : null ); echo "Deploy it with: compose-git deploy $folder\n"; return COMPOSE_GIT_EXIT_OK; case 'convert': - [$positional, $options] = compose_git_parse($args, ['url', 'path', 'branch', 'clones-root'], []); + [$positional, $options] = compose_git_parse($args, ['url', 'path', 'branch', 'clones-root', 'credential'], []); $folder = compose_git_one_stack($positional); $backup = $manager->convert( $folder, compose_git_required($options, 'url'), (string) ($options['branch'] ?? GitStackManager::DEFAULT_BRANCH), compose_git_required($options, 'path'), - isset($options['clones-root']) ? (string) $options['clones-root'] : null + isset($options['clones-root']) ? (string) $options['clones-root'] : null, + isset($options['credential']) ? $manager->findGitCredential((string) $options['credential']) : null ); echo "The replaced files are in $backup.\nDeploy it with: compose-git deploy $folder\n"; return COMPOSE_GIT_EXIT_OK; + case 'credential': + [$positional, $options] = compose_git_parse($args, [], ['none']); + if (count($positional) === 2 && !isset($options['none'])) { + $manager->setCredential($positional[0], $manager->findGitCredential($positional[1])); + } elseif (count($positional) === 1 && isset($options['none'])) { + $manager->setCredential($positional[0], null); + } else { + throw new ComposeGitUsageError('credential needs a stack and either a credential name or --none.'); + } + return COMPOSE_GIT_EXIT_OK; + case 'check': [$positional, $options] = compose_git_parse($args, [], ['all']); $folders = isset($options['all']) ? $manager->listGitStacks() : [compose_git_one_stack($positional)]; diff --git a/tests/unit/GitStackManagerTest.php b/tests/unit/GitStackManagerTest.php index ec913a65..15a2d628 100644 --- a/tests/unit/GitStackManagerTest.php +++ b/tests/unit/GitStackManagerTest.php @@ -298,6 +298,79 @@ public function testStatusShowsLocalChangesWithoutAskingTheRemote(): void $this->assertSame([$folder], $this->manager->listGitStacks()); } + // ----- credentials ----- + + public function testGitCredentialIsFoundByNameOrIdAndRegistryCredentialsAreIgnored(): void + { + $vault = $this->emptyVault(); + $git = $vault->saveCredential(['name' => 'Forgejo', 'provider' => 'git', 'registry' => 'git.example.com', 'username' => 'bot', 'secret' => 't1']); + $vault->saveCredential(['name' => 'Docker', 'provider' => 'docker', 'registry' => 'docker.io', 'username' => 'bot', 'secret' => 't2']); + + $this->assertSame($git['id'], $this->manager->findGitCredential('Forgejo')); + $this->assertSame($git['id'], $this->manager->findGitCredential($git['id'])); + try { + $this->manager->findGitCredential('Docker'); + $this->fail('Found a registry credential as a git credential'); + } catch (RuntimeException $error) { + $this->assertStringContainsString('Git credentials: Forgejo', $error->getMessage()); + } + } + + public function testTwoGitCredentialsWithTheSameNameAreNotGuessedBetween(): void + { + $vault = $this->emptyVault(); + $vault->saveCredential(['name' => 'Bot', 'provider' => 'git', 'registry' => 'github.com', 'username' => 'a', 'secret' => 't1']); + $vault->saveCredential(['name' => 'Bot', 'provider' => 'git', 'registry' => 'gitlab.com', 'username' => 'b', 'secret' => 't2']); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage("Several git credentials are named 'Bot'"); + $this->manager->findGitCredential('Bot'); + } + + public function testAddThatCannotReachAPrivateRepositoryLeavesNoCloneAndNoCredentialFile(): void + { + $credential = $this->emptyVault()->saveCredential( + ['name' => 'Local', 'provider' => 'git', 'registry' => '127.0.0.1:9', 'username' => 'bot', 'secret' => 'token'] + ); + + try { + $this->manager->add('private', 'https://127.0.0.1:9/team/stacks.git', 'main', 'whoami/compose.yaml', $this->clonesRoot, '', $credential['id']); + $this->fail('Added a stack whose repository could not be reached'); + } catch (RuntimeException $error) { + $this->assertStringContainsString('Could not clone', $error->getMessage()); + } + $this->assertDirectoryDoesNotExist($this->composeRoot . '/private'); + $this->assertSame([], glob($this->clonesRoot . '/*') ?: []); + $this->assertSame([], glob(COMPOSE_GIT_CREDENTIAL_DIR . '/*') ?: []); + } + + public function testCredentialCanBeRemovedAndIsShownInStatus(): void + { + $folder = $this->manager->add('whoami', $this->upstream, 'main', 'whoami/compose.yaml', $this->clonesRoot); + $this->assertNull($this->manager->status($folder)['credential']); + + // A repository on this server never takes one. + $credential = $this->emptyVault()->saveCredential( + ['name' => 'Forgejo', 'provider' => 'git', 'registry' => 'git.example.com', 'username' => 'bot', 'secret' => 't1'] + ); + try { + $this->manager->setCredential($folder, $credential['id']); + $this->fail('Gave a repository on this server a credential'); + } catch (\InvalidArgumentException $error) { + $this->assertStringContainsString('needs no credential', $error->getMessage()); + } + + $this->manager->setCredential($folder, null); + $this->assertNull(GitStackSettings::load($this->composeRoot . '/' . $folder)?->credentialId); + } + + public function testCommandLineCredentialNeedsAStackAndANameOrNone(): void + { + $this->assertSame(2, $this->runCli(['compose-git', 'credential', 'whoami'])); + $this->assertSame(2, $this->runCli(['compose-git', 'credential', 'whoami', 'Bot', '--none'])); + $this->assertSame(1, $this->runCli(['compose-git', 'credential', 'whoami', 'No such credential'])); + } + // ----- reclone ----- public function testRecloneMovesTheOldCloneAsideAndReturnsToTheDeployedCommit(): void @@ -536,8 +609,9 @@ public function testCommandLineStatusOfAllShowsAStackWhoseSettingsCannotBeRead() public static function commandsAndTheirOptions(): array { return [ - 'add' => ['add', ['--url', '--path', '--branch', '--clones-root', '--description']], - 'convert' => ['convert', ['--url', '--path', '--branch', '--clones-root']], + 'add' => ['add', ['--url', '--path', '--branch', '--clones-root', '--description', '--credential']], + 'convert' => ['convert', ['--url', '--path', '--branch', '--clones-root', '--credential']], + 'credential' => ['credential', ['--none']], 'check' => ['check', ['--all']], 'deploy' => ['deploy', ['--commit', '--save-local-changes', '--wait', '--no-wait', '--wait-timeout', '--profile']], 'reclone' => ['reclone', []], @@ -610,6 +684,13 @@ private function runCliCapturingOutput(array $argv): array return [$exit, $output, $errorText]; } + private function emptyVault(): \CredentialVault + { + @unlink(COMPOSE_CREDENTIAL_VAULT_FILE); + @unlink(COMPOSE_CREDENTIAL_KEY_FILE); + return new \CredentialVault(); + } + /** @param string[] $argv */ private function runCli(array $argv): int { From 656e0380946a62e893a4667c61b97260921bc620 Mon Sep 17 00:00:00 2001 From: Grant Emsley Date: Mon, 5 Oct 2026 13:15:10 -0400 Subject: [PATCH 07/17] feat(credentials): add and test git repository tokens on the Credentials tab The provider list gets "Git repository (HTTPS token, for git stacks)", with help on which host to enter and what token to use. Test on a git credential reaches the repository of a git stack that uses it, instead of the Docker registry handshake, which a git server does not answer. With no stack using it yet there is no repository to try, and it says so. A git token is left out of a stack's registry credential picker, and assigning one there is refused: it cannot be used to pull images. Co-Authored-By: Claude Opus 5.5 --- source/compose.manager/include/Exec.php | 18 ++++++++++-- .../include/GitStackManager.php | 29 +++++++++++++++++++ .../javascript/composeManagerMain.js | 2 +- .../javascript/credentialManager.js | 28 ++++++++++++++++-- tests/unit/ExecActionsTest.php | 26 +++++++++++++++++ tests/unit/GitStackManagerTest.php | 22 ++++++++++++++ 6 files changed, 119 insertions(+), 6 deletions(-) diff --git a/source/compose.manager/include/Exec.php b/source/compose.manager/include/Exec.php index b8ef5d44..2cc7ce54 100644 --- a/source/compose.manager/include/Exec.php +++ b/source/compose.manager/include/Exec.php @@ -1081,9 +1081,20 @@ function composeResolveContainerIcon(string $containerName, string $service, arr break; } try { - $test = (new CredentialVault())->testCredential($credentialId); + if (((new CredentialVault())->getCredentialSummary($credentialId)['provider'] ?? '') === 'git') { + // A git credential is tested by reaching a repository that uses it, not with a registry handshake. + require_once("/usr/local/emhttp/plugins/compose.manager/include/GitStackManager.php"); + $test = (new GitStackManager($compose_root))->testCredential($credentialId); + if ($test === null) { + echo json_encode(['result' => 'error', 'message' => 'No git stack uses this credential yet. ' + . 'It is tested by reaching a repository: give it to a stack with compose-git, then test it again.']); + break; + } + } else { + $test = (new CredentialVault())->testCredential($credentialId); + } composeLogger( - "Tested registry credential '{$test['name']}': " . ($test['valid'] ? 'valid' : 'invalid') . ' - ' . $test['message'], + "Tested credential '{$test['name']}': " . ($test['valid'] ? 'valid' : 'invalid') . ' - ' . $test['message'], null, 'user', $test['valid'] ? 'info' : 'warning', @@ -1444,6 +1455,9 @@ function composeResolveContainerIcon(string $containerName, string $service, arr if ($credentialId !== '' && !(new CredentialVault())->hasCredential($credentialId)) { throw new RuntimeException('Selected credential no longer exists.'); } + if ($credentialId !== '' && ((new CredentialVault())->getCredentialSummary($credentialId)['provider'] ?? '') === 'git') { + throw new RuntimeException('A git repository credential cannot be used to pull images.'); + } if ($credentialId === '') { if (is_file($credentialIdFile)) { @unlink($credentialIdFile); diff --git a/source/compose.manager/include/GitStackManager.php b/source/compose.manager/include/GitStackManager.php index 5504391f..e8f50fc7 100644 --- a/source/compose.manager/include/GitStackManager.php +++ b/source/compose.manager/include/GitStackManager.php @@ -345,6 +345,35 @@ public function findGitCredential(string $nameOrId): string ); } + /** + * Test a git credential by reaching the repository of a git stack that uses it. + * + * @return array{id: string, name: string, registry: string, valid: bool, message: string}|null + * null when no git stack uses the credential, so there is no repository to try + */ + public function testCredential(string $credentialId): ?array + { + foreach ($this->listGitStacks() as $folder) { + try { + $settings = $this->settings($this->stack($folder)); + } catch (Throwable) { + continue; + } + if ($settings->credentialId !== $credentialId) { + continue; + } + $summary = (new CredentialVault())->getCredentialSummary($credentialId); + $result = ['id' => $credentialId, 'name' => $summary['name'], 'registry' => $summary['registry']]; + try { + (new GitClone($settings))->remoteBranchCommit(); + return $result + ['valid' => true, 'message' => "Reached {$settings->url} (used by '$folder')."]; + } catch (Throwable $error) { + return $result + ['valid' => false, 'message' => "Could not reach {$settings->url} (used by '$folder'): " . $error->getMessage()]; + } + } + return null; + } + /** * A credential's name for messages, or a note that it is gone. */ diff --git a/source/compose.manager/javascript/composeManagerMain.js b/source/compose.manager/javascript/composeManagerMain.js index 9ec83c6b..bc9aa7d3 100644 --- a/source/compose.manager/javascript/composeManagerMain.js +++ b/source/compose.manager/javascript/composeManagerMain.js @@ -1303,7 +1303,7 @@ function initEditorModal() { ComposeCredentialManager.open(null, function(credential) { ComposeCredentialManager.populateSelect($('#settings-credential-id'), credential.id); $('#settings-credential-id').trigger('change'); - }); + }, { registryOnly: true }); }); // Additional compose files: combined change tracking for the candidate diff --git a/source/compose.manager/javascript/credentialManager.js b/source/compose.manager/javascript/credentialManager.js index 9e5ce221..168a17af 100644 --- a/source/compose.manager/javascript/credentialManager.js +++ b/source/compose.manager/javascript/credentialManager.js @@ -102,6 +102,18 @@ tokenUrlLabel: '', instructions: 'Enter your custom registry hostname (e.g. registry.example.com), username, and authentication token or password.', canOAuth: false + }, + git: { + label: 'Git repository (HTTPS token, for git stacks)', + defaultRegistry: '', + registryReadonly: false, + namePlaceholder: 'e.g. GitHub stacks repository', + usernamePlaceholder: 'Git user name (any value for a GitHub token)', + secretPlaceholder: 'Read-only access token', + tokenUrl: '', + tokenUrlLabel: '', + instructions: 'For a private repository that a git stack deploys from. Set the host to the git server only, such as github.com or git.example.com:3000. Use a token that can only read the repository, such as a GitHub fine-grained token with read access to its contents. Give it to a stack with compose-git (--credential).', + canOAuth: false } }; @@ -110,7 +122,7 @@ $('body').append( '