feat(git identity): one section per git host; CodeCommit SSH key ID makes a plain ssh:// clone work
AWS CodeCommit logs an SSH key in by its IAM SSH key ID (APKA…), so a plain `git clone ssh://git-codecommit.<region>.amazonaws.com/v1/repos/x` was refused as the Linux account name. The account can now save an SSH user per host — PUT/DELETE /api/me/git/ssh-user, listed as `sshUsers` — written as `url."ssh://<user>@<host>/".insteadOf "ssh://<host>/"` in its own ~/.gitconfig (as the member under S3), so the plain URL works in a pane, in Clone and in the git bar. Only rules of exactly that shape are listed or removed; any other insteadOf is the user's and left alone. Settings → Git → Your git identity now shows the SSH keys one section per kind of host, each with only its own numbered steps: GitHub · GitLab · Gitea · Bitbucket (ED25519) and AWS CodeCommit (RSA: copy → upload in IAM → paste the key ID and pick the region, then a "Ready" clone line), plus a collapsed "Other SSH hosts (advanced)". The shared key-type picker is gone: each section has its own Generate/Replace/Delete. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -393,6 +393,56 @@ describe("/api/me/git/ssh-terminal (issue #82)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("/api/me/git/ssh-user (CodeCommit SSH key ID)", () => {
|
||||
const getUrl = (url) =>
|
||||
execFileSync("git", ["ls-remote", "--get-url", url], { encoding: "utf8" }).trim();
|
||||
const HOST = "git-codecommit.ap-southeast-1.amazonaws.com";
|
||||
|
||||
it("makes a plain ssh:// URL log in as the saved user, and only for that host", async () => {
|
||||
const r = await request("PUT", "/api/me/git/ssh-user", {
|
||||
host: ` ${HOST.toUpperCase()} `,
|
||||
user: "APKAEXAMPLE1",
|
||||
});
|
||||
assert.equal(r.status, 200);
|
||||
assert.deepEqual(r.body.sshUsers, [{ host: HOST, user: "APKAEXAMPLE1" }]);
|
||||
assert.equal(getUrl(`ssh://${HOST}/v1/repos/x`), `ssh://APKAEXAMPLE1@${HOST}/v1/repos/x`);
|
||||
assert.equal(getUrl("ssh://other.test/x"), "ssh://other.test/x");
|
||||
// A second save replaces, never stacks a second rewrite.
|
||||
const again = await request("PUT", "/api/me/git/ssh-user", {
|
||||
host: HOST,
|
||||
user: "APKAEXAMPLE2",
|
||||
});
|
||||
assert.deepEqual(again.body.sshUsers, [{ host: HOST, user: "APKAEXAMPLE2" }]);
|
||||
assert.equal(getUrl(`ssh://${HOST}/v1/repos/x`), `ssh://APKAEXAMPLE2@${HOST}/v1/repos/x`);
|
||||
const del = await request("DELETE", `/api/me/git/ssh-user/${HOST}`);
|
||||
assert.deepEqual(del.body.sshUsers, []);
|
||||
assert.equal(getUrl(`ssh://${HOST}/v1/repos/x`), `ssh://${HOST}/v1/repos/x`);
|
||||
assert.equal((await request("DELETE", `/api/me/git/ssh-user/${HOST}`)).status, 404);
|
||||
});
|
||||
|
||||
it("refuses a bad host or user, and leaves the user's own insteadOf alone", async () => {
|
||||
const own = "url.ssh://me@mine.test/.insteadOf";
|
||||
execFileSync("git", ["config", "--global", own, "ssh://mine.test/"]);
|
||||
try {
|
||||
assert.equal(
|
||||
(await request("PUT", "/api/me/git/ssh-user", { host: "-oProxy", user: "a" })).body.error
|
||||
.code,
|
||||
"EBADHOST"
|
||||
);
|
||||
const bad = await request("PUT", "/api/me/git/ssh-user", { host: HOST, user: "a@b" });
|
||||
assert.equal(bad.status, 400);
|
||||
assert.equal(bad.body.error.code, "EBADSSHUSER");
|
||||
// Ours lists only our shape; a rule the user wrote for another host is kept.
|
||||
const r = await request("GET", "/api/me/git");
|
||||
assert.deepEqual(r.body.sshUsers, [{ host: "mine.test", user: "me" }]);
|
||||
assert.equal((await request("DELETE", "/api/me/git/ssh-user/other.test")).status, 404);
|
||||
assert.equal(getUrl("ssh://mine.test/x"), "ssh://me@mine.test/x");
|
||||
} finally {
|
||||
execFileSync("git", ["config", "--global", "--unset", own]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("git-identity, member account (S3, stubbed)", () => {
|
||||
const runAs = require("../lib/run-as");
|
||||
const worktree = require("../lib/worktree");
|
||||
|
||||
@@ -23,7 +23,8 @@
|
||||
* A plain `git` typed in a terminal reads none of that, so the account's own
|
||||
* `~/.gitconfig` may also carry our `core.sshCommand` (`setTerminalSsh`: on
|
||||
* by default for a member, a switch for the host; a user's own is never
|
||||
* overwritten).
|
||||
* overwritten), plus a per-host SSH login name (`setSshUser`: AWS CodeCommit
|
||||
* wants the IAM SSH key ID as the user) as a `url.<…>.insteadOf` rule there.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -468,6 +469,79 @@ async function setTerminalSsh(account, on) {
|
||||
return terminalSshState(account);
|
||||
}
|
||||
|
||||
// ── SSH login name per host (`~/.gitconfig` url.<…>.insteadOf) ─────────────
|
||||
|
||||
// AWS CodeCommit logs an SSH key in by its IAM key ID (`APKA…`) as the user
|
||||
// name, so `ssh://git-codecommit…/v1/repos/x` (no user) is refused. One
|
||||
// `url."ssh://<user>@<host>/".insteadOf "ssh://<host>/"` per host makes the
|
||||
// plain URL work for every git of the account — a clone typed in a pane, the
|
||||
// Clone dialog's and the git bar's — without touching `~/.ssh/config`. The
|
||||
// user name is no secret (an ID, not a key), so it may cross sudo's argv.
|
||||
const SSH_USER_RE = /^[A-Za-z0-9_][A-Za-z0-9._-]{0,63}$/;
|
||||
const insteadOfBase = (host, user) => `ssh://${user}@${host}/`;
|
||||
|
||||
/** `[{host, user}]` — only the rules of exactly our shape; any other insteadOf is the user's. */
|
||||
async function listSshUsers(account) {
|
||||
const { git } = require("./worktree");
|
||||
const out = await git(homeOf(account), [
|
||||
"config",
|
||||
"--global",
|
||||
"--get-regexp",
|
||||
"^url\\..*\\.insteadof$",
|
||||
]).then(
|
||||
(r) => r.stdout,
|
||||
() => ""
|
||||
);
|
||||
const rows = [];
|
||||
for (const line of out.split("\n")) {
|
||||
const m = /^url\.ssh:\/\/([^@/\s]+)@([^/\s]+)\/\.insteadof ssh:\/\/([^/\s]+)\/$/.exec(line);
|
||||
if (m && m[2] === m[3] && SSH_USER_RE.test(m[1]) && HOST_RE.test(m[2])) {
|
||||
rows.push({ host: m[2], user: m[1] });
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** Drop our rule for `host` (whatever its user); false when there was none. */
|
||||
async function removeSshUser(account, host) {
|
||||
const { git } = require("./worktree");
|
||||
const h = String(host || "").toLowerCase();
|
||||
const rows = (await listSshUsers(account)).filter((r) => r.host === h);
|
||||
for (const r of rows) {
|
||||
await git(homeOf(account), [
|
||||
"config",
|
||||
"--global",
|
||||
"--fixed-value",
|
||||
"--unset-all",
|
||||
`url.${insteadOfBase(r.host, r.user)}.insteadOf`,
|
||||
`ssh://${r.host}/`,
|
||||
]);
|
||||
}
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
/** Log in to `host` over SSH as `user` (replaces our earlier rule for that host). */
|
||||
async function setSshUser(account, { host, user } = {}) {
|
||||
const h = String(host || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const u = String(user || "").trim();
|
||||
if (!HOST_RE.test(h)) {
|
||||
throw err("EBADHOST", "a host name like git-codecommit.us-east-1.amazonaws.com");
|
||||
}
|
||||
if (!SSH_USER_RE.test(u)) throw err("EBADSSHUSER", "a user name of letters, digits, . _ -");
|
||||
await removeSshUser(account, h);
|
||||
const { git } = require("./worktree");
|
||||
await git(homeOf(account), [
|
||||
"config",
|
||||
"--global",
|
||||
"--add",
|
||||
`url.${insteadOfBase(h, u)}.insteadOf`,
|
||||
`ssh://${h}/`,
|
||||
]);
|
||||
return listSshUsers(account);
|
||||
}
|
||||
|
||||
// ── Network commands in an existing repo (git bar fetch/pull/push) ─────────
|
||||
|
||||
/**
|
||||
@@ -714,6 +788,9 @@ module.exports = {
|
||||
terminalSshCommand,
|
||||
terminalSshState,
|
||||
setTerminalSsh,
|
||||
listSshUsers,
|
||||
setSshUser,
|
||||
removeSshUser,
|
||||
listHttps,
|
||||
saveHttps,
|
||||
removeHttps,
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
* mounted at `/api/me/git`: the SSH keys a clone uses — ed25519 and/or rsa (make, replace, delete —
|
||||
* only the PUBLIC key is ever returned) and the HTTPS logins (save, delete —
|
||||
* `hasToken` only, never a password), whether the account's own terminal
|
||||
* git uses that key (`core.sshCommand`), and the commit author (`user.name` /
|
||||
* git uses that key (`core.sshCommand`), the SSH login name per host
|
||||
* (`url.<…>.insteadOf` — AWS CodeCommit's SSH key ID), and the commit author (`user.name` /
|
||||
* `user.email` of the account's global git config — a member's commits fail
|
||||
* without one). The account is the signed-in user's
|
||||
* own Linux account under S3, else the host's, which only an admin (or local
|
||||
@@ -29,6 +30,7 @@ const STATUS = {
|
||||
ESTOREUNREADABLE: 409,
|
||||
EBADKEYTYPE: 400,
|
||||
EFOREIGNSSH: 409,
|
||||
EBADSSHUSER: 400,
|
||||
};
|
||||
|
||||
function fail(res, e) {
|
||||
@@ -47,6 +49,7 @@ async function view(account) {
|
||||
ssh: keys[0] ? { ...keys[0], terminal } : null,
|
||||
sshKeys: keys,
|
||||
sshTerminal: terminal ?? null,
|
||||
sshUsers: await identity.listSshUsers(account),
|
||||
https: identity.listHttps(account),
|
||||
author: await identity.getAuthor(account),
|
||||
};
|
||||
@@ -115,6 +118,32 @@ router.put("/ssh-terminal", sameOriginGuard, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* The SSH user name for one host (`{host, user}`) — e.g. AWS CodeCommit's SSH
|
||||
* key ID, so a plain `ssh://git-codecommit…/` URL logs in. DELETE drops it.
|
||||
*/
|
||||
router.put("/ssh-user", sameOriginGuard, async (req, res) => {
|
||||
try {
|
||||
const account = accountOf(req, { write: true });
|
||||
await identity.setSshUser(account, req.body || {});
|
||||
res.json(await view(account));
|
||||
} catch (e) {
|
||||
fail(res, e);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete("/ssh-user/:host", sameOriginGuard, async (req, res) => {
|
||||
try {
|
||||
const account = accountOf(req, { write: true });
|
||||
if (!(await identity.removeSshUser(account, req.params.host))) {
|
||||
return res.status(404).json({ error: { code: "ENOTFOUND", message: "no such SSH user" } });
|
||||
}
|
||||
res.json(await view(account));
|
||||
} catch (e) {
|
||||
fail(res, e);
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/https", sameOriginGuard, async (req, res) => {
|
||||
try {
|
||||
const account = accountOf(req, { write: true });
|
||||
|
||||
Reference in New Issue
Block a user