Skip to content

feat(git): private repositories for git stacks (#138, part 2) - #174

Merged
mstrhakr merged 17 commits into
mstrhakr:devfrom
grantemsley:feat/git-stacks-credentials
Oct 9, 2026
Merged

mstrhakr merged 17 commits into
mstrhakr:devfrom
grantemsley:feat/git-stacks-credentials

Conversation

@grantemsley

Copy link
Copy Markdown

Second of four PRs for git-backed stacks (#138). Part 1 handles public repositories; this adds private ones,
in the two ways git hosts offer:

  • HTTPS token: a new "Git repository" kind in the existing credential vault, added and tested on the
    Credentials tab, given to a stack with compose-git add ... --credential <name>.
  • SSH deploy key: compose-git add with an ssh address makes a key for that stack, keeps the private half
    in the vault, prints the public half to add to the repository, and pins the server's host keys. One key per
    stack, because GitHub allows a deploy key on one repository only.

How a secret reaches git: never through the command line, the environment, .git/config or a log. For each
run it is written to a root-only file in RAM and removed afterwards, the same pattern the vault already uses
for registry logins. A token is offered only for the stack's own repository; ssh uses only the stack's key and
pinned host keys, so a changed host key stops the run.

Also: compose-git credential, deploy-key and trust-host; "Used by" and delete protection count git
stacks; a git credential can't be picked as a registry login; git.json gains two optional keys, so older
versions refuse such a stack by name rather than half-read it.

Reading it: 13 commits, in order, each one step with its tests: per-run git settings, credentialId,
the token kind, tokens reaching git, "Used by", --credential, the Credentials tab; then the same for ssh
(addresses and host keys, the key kind, GitSsh, compose-git setup, the Credentials tab); then docs.

Testing: 56 new PHPUnit tests; the full suite and PHPStan pass at every commit. On Unraid 7.3.2 against a
private repository on a Forgejo server, two end-to-end scripts (17 checks each) covered add, deploy and
redeploy with a token and with a deploy key, wrong and missing credentials refused with nothing left behind,
no secret in the clone, stack folder or syslog, and a changed host key stopping a deploy until trust-host.

grantemsley and others added 13 commits October 8, 2026 14:29
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
add and convert take --credential <name>, 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 <noreply@anthropic.com>
…als 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 <noreply@anthropic.com>
A git stack's repository can now be an ssh address,
ssh://git@host[:port]/path or git@host:path, reached with the stack's
deploy key (its credentialId). The user, host and port go to ssh, so each
is held to plain characters and none may start with "-", where ssh would
read it as an option.

git.json gets a second optional key, sshKnownHosts: the repository host's
keys in known_hosts format, pinned when the stack is set up. Like
credentialId it is written only when set, and it is refused on an https
or local repository.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The vault gets a "git-ssh" provider: the private half of a git stack's
ssh deploy key, kept encrypted like every other secret the plugin holds,
for one repository host. Git stacks make these themselves; nothing in the
Credentials tab creates one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An ssh stack's git runs get its deploy key, written from the vault to a
root-only file for the run, and its pinned host keys, and ssh is told to
use those and nothing else: no config file, only that key, no prompts,
and strict host key checking against the pinned keys only. A host whose
key has changed therefore stops the run. ssh is allowed as a transport
for that stack's runs only.

GitSsh also makes a new ed25519 key pair, reads a deploy key's public
half back from the vault (so it is not stored twice), asks a host for its
keys, and shows their SHA256 fingerprints for a person to compare.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e-git

add and convert with an ssh address make a deploy key for the stack (an
ed25519 key in the vault, named "<stack> deploy key") and pin the host's
keys, printing their fingerprints to compare with the ones the git host
publishes. If the clone then fails, as it does until the repository
knows the key, the public key is shown with what to do: add it as a
read-only deploy key and run the same command again, which reuses the
key made the first time.

deploy-key shows a stack's public key again. trust-host pins the host's
keys again after the server's keys change, showing the old and new
fingerprints, and saves them only once the repository has been reached.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A deploy key made by compose-git is listed as "SSH deploy key (git
stack)" with the stack that uses it. It has no Edit button, since it is
a key pair, not something typed in. Test reaches the stack's repository
with it, as for an HTTPS token, and it cannot be chosen or set as a
stack's registry credential.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A new section on reaching a private repository: an HTTPS access token
added on the Credentials tab and named with --credential, or an ssh
deploy key that the stack gets of its own, with what to do the first
time and when the server's host keys change. The command reference gets
--credential, credential, deploy-key and trust-host, and the limitation
about public repositories is replaced by the one that remains: an https
server needs a certificate this server trusts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This comment was marked as resolved.

grantemsley and others added 4 commits October 8, 2026 16:01
compose-git looked credentials up among HTTPS tokens and deploy keys
alike, so --credential and compose-git credential could give a stack
another stack's deploy key, against the rule that each ssh stack has a
key of its own (and leaving its own key unused in the vault).

Only HTTPS tokens are found by name or id now. Adding or converting an
ssh stack with --credential is refused before anything is made, and an
ssh stack's credential cannot be changed: it keeps the key made for it.

Found by Copilot on mstrhakr#174.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Editing a credential could change it from a registry login to a git
token or back, keeping its id. A stack using it then failed at its next
run: a git stack needs a git credential, and a registry login is never a
git one (and a hand-made request could even turn a deploy key into a
token).

The vault now refuses to change a saved credential between a registry
kind and a git kind; changes among registry kinds stay allowed, as
before. The Credentials tab's Edit no longer offers such a change, and
the web action refuses to add a deploy key, which only compose-git makes.

Found by Copilot on mstrhakr#174.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nt lock

Deleting a credential takes the assignment lock and refuses while a stack
uses it. compose-git add, convert and credential reached the repository
first, then saved the stack's credential without that lock. A delete in
between (during a clone, say) was allowed, since no stack used the
credential yet, and the stack was then saved naming a credential that no
longer existed. A new ssh stack's deploy key could be lost this way.

These three now check that the credential still exists and save, both
under the lock, as the web UI's stack settings already do. The lock is
not held while the repository is reached. The docs also say not to
delete a new ssh stack's key before the second run of add.

Found by Copilot on mstrhakr#174.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Git run files (an HTTPS token, a deploy key, pinned host keys) go into a
folder in /var/tmp, where anyone can create things. A folder already
there was used as it was: one another user made first, or a symlink,
would let its owner read a token while its file was still readable,
swap the pinned host keys, or have root clear out another folder.

The folder is now used only when it is a plain folder owned by whoever
runs the plugin (root on Unraid), and run files are created private
rather than made private after.

Found by Copilot on mstrhakr#174.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@grantemsley

Copy link
Copy Markdown
Author

Thanks Copilot, all four are right, and each is fixed as its own commit on top:

  • Deploy keys selectable as credentials: 386ceb7. Only HTTPS tokens are found by name or id now; an ssh
    stack can't be added with another credential, and its credential can't be changed: it keeps the key made for it.
  • Provider-class changes: cab2ed8. A saved credential can't change between a registry kind and a git kind
    (changes among registry kinds still work); Edit no longer offers it, and the web action refuses to add a
    deploy key.
  • Assignments vs. deletion: fdc9ea4. add, convert and credential now check the credential still exists
    and save under the same assignment lock that delete takes, without holding it across the network.
  • The run-file folder in /var/tmp: f8e5301. It's used only when it's a plain folder owned by the user the
    plugin runs as, and run files are created private instead of being made private after.

@mstrhakr
mstrhakr merged commit 0bab126 into mstrhakr:dev Oct 9, 2026
5 checks passed
mstrhakr pushed a commit that referenced this pull request Oct 9, 2026
Git runs for git stacks use a home folder in /var/tmp, where anyone can
create things, and the global config git reads (the list of trusted
repositories) is kept there. A folder already there was used as it
was: if another user made it first, or put a symlink there, they could
give git a config of their own, which root then ran with.

The folder is now used only when it is a plain folder owned by whoever
runs the plugin (root on Unraid), and made private; otherwise git is not
run and the result says why.

Found while checking Copilot's findings on #174, which raised the same
pattern for the folder that holds git credentials.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants