Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,17 @@ one-off DNS token table and unlocks private image pulls on any host. The app
catalog is installable end to end, the audit log moves out of Settings onto its
own page, and uploads stop failing at 1 MB.

### Security

- **Job writes are authorized against the job's own target servers** — jobs are
instance-wide, so the `job:write` permission (which checks organization
membership) is not by itself authority over the servers a command job runs on.
Editing and deleting a job now go through the same per-target server check that
creating and running one already did, and a denial is indistinguishable from a
job that doesn't exist. Reported externally; regression tests added.
Self-hosted instances with more than one trust level should upgrade; Openship
Cloud was never affected (the Jobs API is `localOnly`).

### Credentials

- **One store for third-party secrets** — a provider registry (container
Expand Down Expand Up @@ -103,6 +114,19 @@ own page, and uploads stop failing at 1 MB.

### Fixes

- **`openship reset-admin-password` works on a Compose install** — it
authenticated with `~/.openship/internal-token`, a file the Compose path never
writes: the api container is booted with the `INTERNAL_TOKEN` from
`~/.openship/compose/.env`. So on a Compose box the command *minted* a brand-new
random token, sent that, and reported `Unauthorized` — the lockout-recovery
command was unusable on exactly the install that needed it. Which token this box
is running with is now resolved in one place, readers never mint, and a
root-owned `.env` this user can't open says so (re-run with sudo) instead of
reporting an authorization failure. Same fix reaches the control panel's "Reset
admin password", `openship doctor` (whose health readout came back empty on
every Compose stack), and a bare box's `:80/:443` takeover, which looked for the
Compose token and skipped importing the migrated sites after stopping the
operator's proxy.
- **A password reset uses a 6-digit code** — rather than an emailed link.
- **A cancelled deployment keeps its reason** — the failure message was gated on
`failed` alone, which blanked the reason on every cancelled row. But a cancel is
Expand All @@ -117,6 +141,19 @@ own page, and uploads stop failing at 1 MB.
to the bare hostname as its id, and every guard downstream reads that id as
proof the server row exists. So a hostname with no row yet rendered a live
Verify button that 404'd, along with a DNS-records panel that couldn't load.
- **An abbreviated commit is not a new commit** — `POST /deployments` takes
`commitSha` as whatever the caller sends (`openship deploy --commit 1eeaf76`, the
MCP deploy tool, a CI script), and git checks an abbreviation out happily: the
right code shipped while the row recorded a name no comparison could match. The
drift check compared it against the 40-char branch HEAD, and since both sides
render seven characters, the project page advertised "New commit available
1eeaf76 … you're deployed on 1eeaf76" — permanently, with a Redeploy that could
never clear it. Two shas now name the same commit when one is a prefix of the
other at git's own abbreviation floor, a ref that is not a sha at all (a tag,
`HEAD`) reads as "can't tell" rather than as drift, and a caller's ref is
resolved to the full sha before anything stores or compares it — which also
unbreaks the per-service commit checks GitHub rejects a short sha for, and the
webhook's already-deploying dedupe.

## 0.6.5

Expand Down
6 changes: 6 additions & 0 deletions apps/api/src/middleware/internal-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ import { isLoopbackRequest, peerAddress } from "./loopback-peer";
*
* Uses timing-safe comparison to prevent side-channel leakage on the
* normal path.
*
* The refusal body is exactly `{"error":"Unauthorized"}`, and the CLI reads it:
* lib/loopback-api's internalFetch treats THAT shape (and only it) as "the token was
* refused, before any handler ran", which is what makes retrying with this box's other
* token safe. A handler's own 401 — /cloud-connect after a single-use PKCE exchange —
* must stay distinguishable from this one, so keep the wording.
*/
export async function internalAuth(c: Context, next: Next) {
if (!env.INTERNAL_TOKEN) {
Expand Down
19 changes: 11 additions & 8 deletions apps/api/src/modules/deployments/build-pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ import {
} from "../../lib/routing-domains";
import { normalizeTargetPath } from "../../lib/public-endpoints";
import { resolveRuntimeResources, resolveBuildResources } from "../../lib/resources";
import { resolveBuildGitToken } from "../github/clone-auth";
import { cloneOnServerAvailable, resolveBuildGitToken } from "../github/clone-auth";
import { openDeployRelay } from "../../lib/git-forwarding";
import { resolveOrgOwner } from "../../lib/org-actor";
import { resolveAcmeProviderOptions } from "../../lib/acme-config";
Expand Down Expand Up @@ -411,7 +411,13 @@ export async function finalizeComposeDeploy(opts: {
extra: { meta: { ...meta, composeDeployment } },
sse: {
status: "ready",
meta: { warningMessage },
// `decisionPending` explicitly, because THIS is the only thing that means a
// keep/reject decision is being held. The live event used to carry only
// `warningMessage`, so the client inferred the decision from "a warning exists on
// success" — and every OTHER warning then opened the failed-services modal. A
// successful deploy whose domains have no cert yet showed "Deployment finished with
// failed services · 0 of 5 services failed · Retry 0 Failed Services".
meta: { warningMessage, decisionPending: true },
},
});
} else if (rolled === "failed") {
Expand Down Expand Up @@ -777,12 +783,9 @@ async function executeBuildAndDeploy(project: Project, dep: Deployment, buildSes
// nothing qualifies, fall back to cloning on the API host and transferring the
// context — warn, never hard-fail. (The BARE runtime always clones on the
// target and is gated by preflight separately, so this only changes DOCKER.)
const cloneCredentialAvailable =
!!gitCred.ambient ||
gitCred.relay === true ||
!!gitCred.ssh ||
gitCred.anonymous === true ||
(!!gitCred.token && !gitCred.apiHostFallback);
// The rule itself lives with the credential type (`cloneOnServerAvailable`), so a capability
// check shown in the picker and the decision made here can never disagree.
const cloneCredentialAvailable = cloneOnServerAvailable(gitCred).available;
const effectiveCloneOnServer =
cloneOnServer && (runtime.name === "bare" || cloneCredentialAvailable);
if (cloneOnServer && runtime.name !== "bare" && !cloneCredentialAvailable) {
Expand Down
50 changes: 44 additions & 6 deletions apps/api/src/modules/deployments/build.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ import {
SYSTEM,
STACKS,
safeErrorMessage,
compareCommitSha,
getRuntimeImage,
isFullCommitSha,
isReleaseProvider,
looksLikeSecretKey,
resolveProjectVolumes,
Expand All @@ -43,7 +45,7 @@ import { resolveCloudResourceConfig } from "./cloud-resources";
import type { TBuildAccessBody } from "./deployment.schema";
import { platform } from "../../lib/controller-helpers";
import { encrypt } from "../../lib/encryption";
import { getLatestCommit, getRepository } from "../github/github.service";
import { getCommitByRef, getLatestCommit, getRepository } from "../github/github.service";
import { assertGitHubRepoAccess } from "../github/github-access";
import { firePreDeployBackups } from "../backups/triggers/pre-deploy";
import { resolveSmartRoute } from "./smart-route";
Expand Down Expand Up @@ -476,6 +478,36 @@ async function resolveLatestCommitInfo(ctx: RequestContext, project: Project, br
return head ? { commitSha: head.sha, commitMessage: head.message } : {};
}

/**
* Canonicalize a caller-supplied commit ref to the commit's full sha.
*
* `POST /deployments` takes `commitSha` as a free string — `openship deploy
* --commit 1eeaf76`, the MCP deploy tool, a CI script — and git checks out
* anything it is given, so an abbreviated sha builds exactly the right code while
* the row records a name nothing downstream can match by value: the drift check
* compares it against a 40-char branch HEAD (which is how a project deployed at
* `1eeaf76` ends up being offered `1eeaf76` as a new commit, permanently), the
* commit-status API rejects a short sha outright, and the in-flight webhook dedupe
* misses. Resolved ONCE here, before anything compares or stores it.
*
* Fail-soft: an unresolvable ref (no GitHub repo, no credential, rate limit) is
* kept verbatim. The deploy still knows how to check it out; only the bookkeeping
* is less precise, and that is not worth failing a deploy over.
*/
async function canonicalizeCommitRef(
ctx: RequestContext,
project: Project,
ref: string | undefined,
): Promise<string | undefined> {
const trimmed = ref?.trim();
if (!trimmed || isFullCommitSha(trimmed)) return trimmed;
if (!project.gitOwner || !project.gitRepo) return trimmed;
const found = await getCommitByRef(ctx, project.gitOwner, project.gitRepo, trimmed).catch(
() => null,
);
return found?.sha ?? trimmed;
}

async function resolveProjectBranch(ctx: RequestContext, project: Project, branch?: string) {
const configuredBranch = branch?.trim() || project.gitBranch?.trim();
if (configuredBranch) return configuredBranch;
Expand Down Expand Up @@ -1791,20 +1823,26 @@ export async function triggerDeployment(

const branch = await resolveProjectBranch(ctx, project, data.branch);
const environment = data.environment ?? "production";
// Before the dedupe below and before anything stores it: one canonical sha, so
// the row a webhook compares against and the row the drift check reads are
// written in the same alphabet. See canonicalizeCommitRef.
const requestedCommitSha = await canonicalizeCommitRef(ctx, project, data.commitSha);

// Skip an auto (webhook) deploy whose commit is already in-flight or live —
// closes the App + repo-webhook double-deploy window. Manual/forceAll bypass.
if (data.trigger === "webhook" && !data.forceAll && data.commitSha) {
if (data.trigger === "webhook" && !data.forceAll && requestedCommitSha) {
const inFlight = await repos.deployment
.findInProgressByCommit(project.id, data.commitSha)
.findInProgressByCommit(project.id, requestedCommitSha)
.catch(() => undefined);
const active = project.activeDeploymentId
? await repos.deployment.findById(project.activeDeploymentId).catch(() => null)
: null;
const existing = inFlight ?? (active?.commitSha === data.commitSha ? active : null);
const existing =
inFlight ??
(compareCommitSha(active?.commitSha, requestedCommitSha) === "same" ? active : null);
if (existing) {
console.log(
`[Deploy] project ${project.id}: webhook deploy for ${data.commitSha} skipped — already ${inFlight ? "in progress" : "live"} (${existing.id}).`,
`[Deploy] project ${project.id}: webhook deploy for ${requestedCommitSha} skipped — already ${inFlight ? "in progress" : "live"} (${existing.id}).`,
);
return { deployment: existing, skipped: true as const };
}
Expand Down Expand Up @@ -1901,7 +1939,7 @@ export async function triggerDeployment(
}

// ── Resolve commit info: fetch HEAD from GitHub if not provided ────
let commitSha = data.commitSha;
let commitSha = requestedCommitSha;
let commitMessage = data.commitMessage;
if (data.refresh) {
// Refresh recreates the running containers with current env — it never
Expand Down
86 changes: 86 additions & 0 deletions apps/api/src/modules/deployments/compose/carried-host-port.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";

import { pickHostPort } from "@repo/adapters";

/**
* A host port cannot travel with a project.
*
* The deploy persists the loopback host port it pinned for a service and reuses it on the next
* deploy, which is right on the SAME host: the port was ours and still is. It is wrong the moment
* the host changes. A migration replays the source's port on a target that knows nothing about
* it, and if anything there holds it Docker refuses the bind:
*
* driver failed programming external connectivity on endpoint openship-clincai-api:
* Bind for 127.0.0.1:20001 failed: port is already allocated
*
* — which took down `api` and, by dependency, `dashboard` and `web`: 3 of 5 services, on a
* migration whose data had already transferred successfully.
*
* The allocator already had the right primitive (`preferred` = keep it if free), so the fix is to
* route the carried port through it instead of branching around it.
*/
describe("pickHostPort's preferred-port contract", () => {
it("keeps the carried port when it is free — stable redeploys", () => {
expect(pickHostPort(new Set(), { preferred: 20001 })).toBe(20001);
});

it("picks another when the carried port is occupied on THIS host", () => {
// The migration case: 20001 came from the source and is taken on the target.
const port = pickHostPort(new Set([20001]), { preferred: 20001 });
expect(port).not.toBe(20001);
expect(port).toBeGreaterThanOrEqual(20000);
});

it("picks another when a sibling in the same deploy already took it", () => {
expect(pickHostPort(new Set(), { preferred: 20001, avoid: [20001] })).not.toBe(20001);
});

it("keeps a carried port that predates the current range", () => {
// Documented behaviour of `preferred`, and worth pinning: a project pinned outside
// 20000-29999 must not be renumbered just for being old.
expect(pickHostPort(new Set(), { preferred: 15000 })).toBe(15000);
});

it("falls back to the range start when nothing is carried", () => {
expect(pickHostPort(new Set(), {})).toBe(20000);
});
});

describe("the deploy routes the carried port through the allocator", () => {
const src = readFileSync(
new URL("./deploy.service.ts", import.meta.url),
"utf8",
);
/** The loopback-port allocation block, bounded by its own loop. */
const block = (() => {
const from = src.indexOf("for (const containerPort of routedContainerPorts) {");
return src.slice(from, src.indexOf("usedHostPorts.add(hostPort);", from));
})();

it("passes the carried port as `preferred`, not as the answer", () => {
expect(block).toContain("preferred: carried");
});

it("no longer short-circuits the allocator when a carried port exists", () => {
// The bug in one line: `if (carried) { hostPort = carried; }` — no availability check, on a
// host that had never seen that port.
expect(block).not.toMatch(/if \(carried\) \{\s*hostPort = carried;/);
});

it("still avoids ports this same deploy already handed out", () => {
expect(block).toContain("avoid: usedHostPorts");
});

it("says so when it had to move a carried port", () => {
// Otherwise a port silently changing between deploys looks like a bug from the outside.
expect(block).toContain("hostPort !== carried");
expect(block).toContain("is taken on this server");
});

it("keeps the unreadable-occupancy warning, which is a different failure", () => {
// A scan that could not run is not "nothing is listening" (#490) — with `preferred` set,
// that case now returns the carried port, so the warning is the only signal.
expect(block).toContain("allocation.scanned");
});
});
53 changes: 36 additions & 17 deletions apps/api/src/modules/deployments/compose/deploy.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1936,23 +1936,42 @@ export async function deployComposeServices(
containerPort === primaryRoutedPort
? previousByServiceId.get(svc.id)?.hostPort
: undefined;
let hostPort: number;
if (carried) {
hostPort = carried;
} else {
const allocation = await allocateHostPort(opts.executor, { avoid: usedHostPorts });
hostPort = allocation.port;
// "Couldn't read occupancy" is not "nothing is listening" — without this the
// bind failure that follows blames Docker for an unreachable host (#490).
if (!allocation.scanned) {
logger.log(
`Couldn't read live port occupancy on the target, so ${allocation.port} for ` +
`${svc.name} avoids only ports this deploy already took. If publishing it fails ` +
`as "already allocated", check that Openship can reach this host ` +
`(Servers → this box).\n`,
"warn",
);
}
/**
* A carried port is a PREFERENCE, never a given.
*
* It used to be taken verbatim whenever one existed, which is right for the case it was
* written for — a redeploy on the same host, where the port was ours and still is. It is
* wrong the moment the host changes: a MIGRATION carries the source's port to a target
* that knows nothing about it, and if anything there holds it Docker refuses the bind
* with "port is already allocated" and the service (plus everything depending on it)
* fails. A host port is a property of the HOST, not of the project, so it cannot travel
* with one.
*
* `preferred` is the allocator's own word for exactly this: keep it if it's free, pick
* another if it isn't. Passing it there rather than branching around the allocator means
* one rule for both cases and no second place that decides what a free port is.
*/
const allocation = await allocateHostPort(opts.executor, {
preferred: carried,
avoid: usedHostPorts,
});
const hostPort = allocation.port;
if (carried && hostPort !== carried) {
logger.log(
`Host port ${carried} for ${svc.name} is taken on this server — using ${hostPort}. ` +
`(Expected when a project moves to a different host.)\n`,
);
}
// "Couldn't read occupancy" is not "nothing is listening" — without this the
// bind failure that follows blames Docker for an unreachable host (#490).
if (!allocation.scanned) {
logger.log(
`Couldn't read live port occupancy on the target, so ${allocation.port} for ` +
`${svc.name} avoids only ports this deploy already took. If publishing it fails ` +
`as "already allocated", check that Openship can reach this host ` +
`(Servers → this box).\n`,
"warn",
);
}
usedHostPorts.add(hostPort);
pinnedHostPortByContainerPort.set(containerPort, hostPort);
Expand Down
Loading