diff --git a/.ai/lessons.md b/.ai/lessons.md
index c15a0558ec..dccfba874f 100644
--- a/.ai/lessons.md
+++ b/.ai/lessons.md
@@ -36,7 +36,7 @@
- In tests, turning on `is_metrics_enabled` (and other Sentinel settings) with `save()`/`update()` runs the settings hook, which starts Sentinel over a real SSH connection on the sync queue. Use `saveQuietly()` or `Queue::fake()`.
- Call `visit()` directly in each `tests/v4/Browser` test body; Pest does not mark a test that only uses helper-wrapped `visit()` as a browser test, so it fails with `sendText() on null`.
- The root team (id 0) has `personal_team = true`, so `personal_team` does not identify a user's own team. To clean up test users, detach them from shared teams and then delete the user; never delete a team selected by a filter. A Team delete revokes the team's API tokens and deletes its private keys before any guard can stop it.
-- SQLite returns `0`/`1` for boolean columns without a `boolean` cast, while Postgres returns real booleans. A strict `=== false` check on such a column never matches in tests, so tests can pass on behavior that Postgres blocks. Add the cast or compare loosely.
+- SQLite returns `0`/`1` for boolean columns without a `boolean` cast, while Postgres returns real booleans. A strict `=== false` check on such a column never matches in tests, so tests can pass on behavior that Postgres blocks. Add the cast or compare loosely. `Rule::exists()->where('column', false)` sends `false` as `""`, which Postgres rejects for a boolean column; use a closure (`->where(fn ($query) => $query->where('column', false))`).
- SQLite does not enforce `varchar(255)` lengths, while Postgres rejects a longer value (`SQLSTATE[22001]`). Tests cannot show a too-long name, so assert the length or the exact value of generated strings.
- `docker compose up` gives a recreated container the old mount at each path that the image declares with `VOLUME`, also a named volume or bind mount that the Compose file no longer has. Remove the old container first when a mount must go (`composeContainersWithRemovedVolumes()`); do not use `--renew-anon-volumes`, because it deletes the data of anonymous volumes.
@@ -47,6 +47,9 @@
- To prove BuildKit cache behavior, use a new Dockerfile with a unique step for each experiment and repeat each build. Old cache records can make a changed input look cached. BuildKit keys `RUN` steps on the `--add-host` names and their order, but not on the IPs.
+## Add dependencies through the trust gate
+- `composer require` stops until `vendor/bin/vet` records the new packages in `vet.json`. Read the package source first, then record it with `vendor/bin/vet` in a terminal (it needs a TTY). Prefer the existing dependencies (Guzzle, curl, Flysystem) over a package with a large dependency tree.
+
## Verify the complete user flow
- Do not use a passing unit test, a successful build, or a healthy process as proof for a reported UI failure.
- Verify the exact live flow, persisted state, relevant logs, and queue state when they affect the result.
@@ -169,3 +172,4 @@
## Measure database reads end to end
- On PostgreSQL 15, `EXPLAIN (ANALYZE, BUFFERS)` does not detoast or send the selected columns, so a wide `jsonb`/`json`/`text` column does not show in it. Time the full fetch (`psql \timing` with `-o /dev/null`, or PDO `fetchAll`) as well before you blame or rule out an index.
+- Do not put a required write (audit rows, state that matches a committed change) or required cleanup in `defer()` or `dispatch()->afterResponse()`. Both run in the terminate phase, which PHP-FPM skips when the client or proxy closes the connection (`ignore_user_abort` is off) or the script hits a fatal error. Write it in `DB::afterCommit()`, or queue the job normally, instead. `afterResponse()` also runs a queued job on the `sync` connection in the web worker.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index af891acf8b..00f78fc3e7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -60,6 +60,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
- Retry GitHub API reads, such as the repository lookup before each deployment, up to three times when GitHub answers with a 5xx error or the connection fails. Before, one `502` or one timeout from GitHub failed the deployment. Writes, such as pull request comments, are not retried ([#9785](https://github.com/coollabsio/coolify/issues/9785))
- Keep an application that uses a server only as an additional destination when you delete that server with **Delete all resources** or with `DELETE /api/v1/servers/{uuid}?force=true`. Before, Coolify deleted the application on all its servers, also after **Promote** made another server its main server. Coolify now removes the application only from the deleted server and stops its containers there. A server that has only such applications can be deleted without **Delete all resources**. The delete confirmation lists the resources that it deletes and the applications that keep running on another server
- Fix the sign-up and login of the Rybbit service template. The proxy sent all requests to the client container, so the `/api` requests of the browser got a 404 error. The template now routes `/api` on the same domain to the backend on port 3001, and keeps the `/api` prefix. For an existing Rybbit service, add `SERVICE_URL_RYBBIT_3001=/api` to the `rybbit_backend` service, move `rybbit_backend` above `rybbit` in the Docker Compose file, set **Path prefixes** to **Keep prefixes** in the advanced settings of the backend, and redeploy the service ([#9055](https://github.com/coollabsio/coolify/issues/9055))
+- Add SMTP templates for common email providers, such as PostShiba, Amazon SES, Mailgun, SendGrid, Postmark, and Brevo, which fill in the host, port, and encryption
+- Save more than one named SMTP server on the transactional email and email notification pages, and switch between them
+- Give each SMTP server and Resend its own sender name and address, because each provider sends from its own verified domain
+- Disable a scheduled task automatically when it fails at least 5 times in a row and has had no successful run for 3 days. Coolify sends a notification on the scheduled task failure channels and shows the reason on the task page until you enable the task again
+- Connect Forgejo instances, including Codeberg and self-hosted Forgejo, with the Gitea / Forgejo App source. A new Provider list on the create form fills in the Gitea.com or Codeberg URLs, and webhooks also accept the `X-Forgejo-*` headers
+
+### Changed
+
+- Select the email provider from one list on the transactional email and email notification pages, which then show only the settings of that provider
+
+### Removed
+
+- Remove the "Copy from instance settings" button from the email notification page. Select "Use system-wide settings" to send team email with the instance email settings
## [v4.6.0] - 2026-11-02
diff --git a/DESIGN.md b/DESIGN.md
index 2f22c906c4..73d5cf74db 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -934,6 +934,15 @@ The shared workspace grid is:
Instance Settings uses `x-settings.layout` with the same full-width workspace.
+The Security item of the resource sidebars shows a status dot at its end
+(` `, set `'security'` on the item to
+the stored status): a red `bg-error` dot for update_now and a yellow
+`bg-warning` dot for update_available, each with an `aria-label`
+("Security: update now", "Security: security update") that is also the tooltip.
+Other statuses show nothing, and the item never shows a count. Status that needs
+live updates keeps its own component (the application Servers item uses the
+`badge` server status badge).
+
**Page titles (global):** `x-dashboard.navbar` H1s (`titleOnDesktop="false"`,
the default) hide at **lg+** only when the page renders a fixed tab strip or
actions; otherwise they stay visible. Collection indexes (Servers, Projects, …)
@@ -1005,6 +1014,13 @@ Anatomy:
The title row has `4px 8px 10px` padding inside the 6px outer padding, so
header actions sit 10px from the top and 14px from the right edge.
+A card that most users do not need open can be collapsible: pass
+`collapsible` (and `:open="true"` to start open). The title becomes a button
+with a chevron (`aria-expanded`, `aria-controls` the well), the well is hidden
+until it is opened, and the closed title row has a 4px bottom padding. Header
+actions stay visible while it is closed. The Security page uses it for "All
+findings ({n})", "Packages and SBOM", and "Settings".
+
The card keeps `overflow: visible` so listbox panels and tooltips can escape
it. Do not add `overflow: hidden` to the card or the well.
@@ -1179,6 +1195,30 @@ history filter in
Do not represent the empty state as a selectable `All` option. The footer reset
action is the single way to return the multi-select to its unfiltered state.
+#### Inherited listbox option
+
+A setting that inherits through levels (for example instance → team → server
+→ app for image scan rules) uses `x-forms.inherited-listbox`. It wraps
+`x-forms.listbox` and prepends one option with the value `''`:
+
+```blade
+
+```
+
+- the label is `Use {level} default ({value})`: `{level}` is the level the
+ inherited value comes from (`Coolify` for the instance level, then `team`,
+ `server`), and `{value}` is that value's option label;
+- choosing it stores `null`, so the field follows later changes of the level
+ above. Do not copy the inherited value into the row;
+- the top level has concrete values and no inherit option (pass no
+ `inheritLabel`), and the server rejects an empty value for it;
+- it saves instantly (`onChange`), like the other instant-save listboxes;
+- a related choice that is not inheritance, such as "Detect automatically",
+ uses a plain listbox with its own first option instead.
+
+The reference is `resources/views/livewire/image-scan/partials/scan-rules.blade.php`.
+
### Standard table controls
Dense tables use the shared `x-table.*` components so search, filters, sorting,
@@ -1198,6 +1238,32 @@ queries, permissions, and empty states. Backend-filtered or paginated tables
must use `x-table.loading`; frontend-only Alpine tables reuse the same toolbar
and control anatomy but do not show an artificial loading state.
+### Fix-state switch
+
+The "All findings ({n})" card of the Security page splits findings into "Fixed
+by an update" (a fixed version exists, or an action of the main card fixes it)
+and "No fix yet". This is a view switch, not a filter, so it is not in the
+`x-table.filter` menu and "Reset filters" keeps its meaning:
+
+- a segmented control in the table toolbar, after the search: a 4px-padded
+ neutral track (`bg-neutral-100`, dark `white/4%`) with 28px, 6px-radius
+ buttons; the current one is the raised white pill (dark `white/9%`) with a
+ hairline ring;
+- `role="group"` with an `aria-label`, and `aria-pressed` on each button;
+- each label has its count in parentheses: `Fixed by an update (5)`. The counts
+ follow the search and the filters, so a switch never hides results without
+ saying how many;
+- "Fixed by an update" is the default. When it is empty, the empty state says
+ "No finding has a fixed version." and links to "No fix yet ({n})";
+- the same segmented anatomy is used for the "By package / By CVE" grouping
+ toggle on the right of the toolbar;
+- ignored findings are not a third state. The table footer has an "{n} ignored"
+ link that opens the ignored list;
+- a finding of a Dockerfile build that the base image has too says "Comes from
+ {base}." in its details.
+
+The reference is `resources/views/livewire/image-scan/vulnerabilities.blade.php`.
+
### Buttons
- neutral actions use the shared `.button`;
@@ -1272,6 +1338,270 @@ Relevant classes:
Create a page-specific grid class when columns differ. Add responsive rules
that hide secondary columns before allowing horizontal overflow.
+### Severity badge, security marker, gate status, and the Security main card
+
+Image scan findings use shared pieces in
+`resources/views/components/image-scan/`. They keep the status badge rule:
+the surface stays neutral and only a small mark carries the color, and the
+severity word is always in the text, so color is never the only signal.
+
+Severity tokens live in `app.css`, with a light value in `@theme` and a dark
+value in `.dark`. They are OKLCH only and never follow the accent or
+`--color-warning`, because the custom theme recolors both.
+
+| Severity | Dot color | Why |
+|---|---|---|
+| critical | `--color-error` (`bg-error`) | the existing error red |
+| high | `--color-severity-high` (orange) | between error red and warning yellow |
+| medium | `--color-severity-medium` (sky blue) | not yellow like warnings, not purple like the accent |
+| low, unknown | neutral | no urgency |
+
+- Every severity dot clears 3:1 (non-text contrast) on the badge background in
+ both themes, and medium is more than 10 ΔE (OKLab ×100) from the accent and
+ the warning color. `tests/Unit/ImageScan/SeverityTokenContrastTest.php`
+ enforces both. Change a token and run it.
+- ` ` renders
+ `x-status-badge` with the types `severity-critical`, `severity-high`,
+ `severity-medium`, or `severity-low` (unknown is neutral). The label is
+ `2 critical`, or `Critical` (sentence case) when `count` is null.
+- ` `
+ is the security marker for lists: a `shield-alert` icon with visible text,
+ never a tooltip-only count, so it works on touch screens.
+ - Only two statuses get one: update_now ("Update now", the icon in
+ `text-error`) and no_fix ("No fix yet", a neutral icon). The color is on
+ the icon only; the text keeps the normal foreground. Every other status
+ renders nothing.
+ - `tooltip` (`data-tooltip`) is the sentence of the primary action, when
+ update_now has one. `label` (`aria-label`) names the status and that
+ action: "Security: update now. Update to 15.15." The marker sets
+ `data-tooltip-wrap`, so a long action sentence wraps at `max-w-xs`
+ instead of running off the screen. Short labels keep the default
+ one-line tooltip; add `data-tooltip-wrap` only to sentence tooltips.
+ - It sits right after the resource name: the environment list (table and
+ cards), the server Resources tab, and the canvas cards (in the chip row,
+ before the extra servers chip; `Card.svelte` draws the same anatomy from
+ the node's `security` value).
+ - Lists rendered by Alpine pass an expression instead of values:
+ ` `. Add
+ `relative` when the row has a full-row link overlay, so the tooltip still
+ opens.
+ - Compute the markers of a whole list at once with
+ `VulnerabilityMarkers::for($resources)`. It reads the stored statuses
+ (`SecurityStatus::storedMany()`) with a fixed number of queries, never one
+ per row, and never reads findings. A resource that is not scanned now
+ (instance does not allow scanning, its server has scanning off, or an app
+ turned its scan off; one rule in `ImageScanPolicyResolver::offState()`) is
+ not_checked: no marker, no sidebar dot, and no dashboard count.
+- The team Security page (Monitoring > Security,
+ `livewire/image-scan/fleet-vulnerabilities.blade.php`) is a top-level
+ destination: title and summary, one dense table with every resource the
+ user can see.
+ - Columns: Resource (with project / environment under the name), Status,
+ Action, Server, Checked.
+ - Status: the badge of the main card ("Update now" error, "Security update"
+ warning, "No fix yet" and "Check needed" neutral), or muted text "No action
+ needed" (ok) and "Not checked". A resource with several images that needs
+ an update adds "{k} of {n} images" under the badge (11px muted,
+ `TeamVulnerabilities::imagesLine()`). One row per resource; images are never
+ nested rows.
+ - Action: the sentence of the primary action (update_now and
+ update_available), as a link to the resource's Security card
+ (`#security-section`) in the text link style (`underline
+ decoration-neutral-400/40 underline-offset-4`); muted "Check again:
+ {reason}." as the same link for check_needed; muted, unlinked "No fix
+ released yet. Coolify checks daily." for no_fix; muted, unlinked "why not
+ checked" text for not_checked. No buttons and no bulk actions.
+ - Sort: update_now, update_available, no_fix, check_needed, ok,
+ not_checked, then the resource name. Filters: Status (with a count per
+ status, "Update now (3)", and in the URL as `statuses[]`), Server, and
+ Project in one `x-table.filter`. 25 rows per page by default.
+ - A search by CVE, GHSA, or package lists the resources with a matching
+ finding and switches the Status and Action columns to the match and its
+ state ("Fix available upstream", "No fix yet", "Ignored", the words of
+ All findings). A small status badge stays next to the resource name.
+ Only the search decodes findings.
+ - Resources that were not scanned are hidden by default (no status filter),
+ so they do not bury the ones that need an action, but never silently: a
+ muted line under the table says "{n} resources on {m} servers were not
+ scanned, so Coolify cannot tell." with the link "Show them" / "Hide them"
+ (`showNotChecked`, in the URL). The Status filter "Not checked" shows them
+ too.
+ - A scan event reloads the page at most once per 10 seconds
+ (`FleetVulnerabilities::RELOAD_INTERVAL`); a server scan sends one event
+ per image.
+- Fleet callouts:
+ - The dashboard shows one callout, only for update_now: "{n} {resource
+ needs/resources need} a security update now." + Review (danger), which
+ opens the Security page filtered to update_now. After a dismiss it shows
+ again only when a resource that was not dismissed needs an update now:
+ `resources/js/security-callout.js` keeps the dismissed resource UUIDs in
+ `localStorage` and clears them when no resource needs an update now.
+ Nothing else about security goes on the dashboard.
+ - The Security page shows admins "{n} servers do not scan images." Its
+ `localStorage` key holds the number, so a new number shows it again.
+ - Callout keys are per team (`….dismissed.{teamId}`): a dismiss in one team
+ never hides the callout of another.
+- ` ` is the
+ deploy gate state: a `x-status-badge` button ("Blocked" error, or "Not
+ scanned" warning when the scan could not run) that opens a popover with the
+ same anatomy as `x-configuration-warning` (icon tile, headline, links). The
+ popover is `position: fixed` and placed from the trigger, so it escapes the
+ `overflow: hidden` log viewer. It replaces the status dot in the deployment
+ log toolbar when blocked (no card above the log). The application heading
+ teleports it into `#configuration-warning-hud-slot` from `xl` and repeats it
+ in the in-flow heading below `xl`, for the latest non-preview deployment
+ only. A blocked popover shows the sentence of the primary action for the
+ blocked image (`:action`, from `SecurityCard::gateAction()`) and an "Open
+ Security" link; it never runs an update itself. "Deploy anyway" shows only
+ where the page can run it and the user has Write access; otherwise "Ask
+ someone with write access to deploy anyway."
+- The Security main card (`livewire/image-scan/vulnerabilities.blade.php`, data
+ from `SecurityCard::for()`) is the first layer card of a resource's Security
+ page, for people who are not security experts. It answers "Is this OK, and
+ what do I click?" first: status, sentence, and one action, then the why-list
+ (spec 15.11). It is one component for every resource: a list of the images
+ the status counts. No summary tiles, no severity counts, no separate
+ problems table, and no row menus: a count that the status does not use
+ (upstream package fixes, CPE-only matches, findings that are not relevant)
+ contradicts the sentence.
+ - Card header: title "Security" with the status badge of the resource in the
+ badges slot ("Update now" error, "Security update" warning, "No fix yet"
+ and "Check needed" neutral, none for ok and not checked), and "Checked
+ {time}" (absolute time in the tooltip) and Scan now in the header actions
+ (not for check_needed, where Scan now is the primary action in the body).
+ - Vocabulary: the plain layer (the card) says "problems"; the expert layer
+ (All findings, Packages and SBOM, the row footer, ignores) says
+ "findings". Never "issues" or "vulnerabilities" in the UI.
+ - Not scanned yet: apps say "Not scanned yet. The next deploy scans the
+ image."; databases and services say "Not scanned yet." with a highlighted
+ Scan now (only for users who may update the resource).
+ - One image (a database, a Docker image app, a Dockerfile app): the row is
+ open and has no list frame, no row header, no chevron, and no second status
+ badge; the card badge is the status. The image ref is shown in the
+ sentence only when the sentence starts with it; an own-built app ("This app
+ has…") shows the ref only in the footer.
+ - Rows: every image the status counts. That includes a scanned service
+ application or database without a result and every image of a Compose
+ app without a result in its current deployment (keyed
+ `compose-{md5(service)}`). These rows show "Not checked" and "{ref} has
+ not been checked yet.", and Scan now stays in the card header.
+ - Several images (a service, a Compose app): a 15px medium line "{k} of {n}
+ images need an update" only when k > 0 (k = rows with update_now or
+ update_available). For k = 0 there is no header line: the card badge
+ already says the status, and "All images are up to date" is never used.
+ Then one bordered list (`aria-label="Images"`, rows split by dividers).
+ Rows are worst first (update_now, update_available, no_fix, check_needed,
+ ok, not_checked, then the name). The first row with a problem is open; the
+ others, and every "No action needed" row, are closed.
+ - Row header (several images): a real `` with `aria-expanded` and
+ `aria-controls` that holds the chevron, the name (13px medium; the service
+ application or database, or the Compose service) and the image ref (mono,
+ 11px muted under a name, else 12px); then the status badge (muted text "No
+ action needed" or "Not checked" without one) and the primary action
+ (highlighted on the first row, else neutral) or copy chip. A merged
+ service update (spec 15.5) has its button on the first row of the group
+ only; the other rows show muted "Updated with {first name}".
+ - Open row content, from `partials/security-image.blade.php`, in this order:
+ - The sentence of `SecuritySentences::imageSentence()` in 15px medium
+ (starts with the image ref in mono when the sentence does).
+ - One image only: the primary action right under the sentence, on its own
+ line (highlighted button, or the copy chip at full width), or the
+ highlighted Scan now for check_needed. It is the first control of the
+ card in tab order.
+ - The attack note of `attackNote()` (update_available only), the no_fix
+ advice (`noFixAdvice()`), the extra lines, and the version check notes,
+ 13px dim.
+ - A Compose app image that a security update replaced: a 12px faint line
+ "Coolify deploys {to} instead of {from} from your compose file." with the
+ text button "Use the compose file" (`removeImageOverride()`), which drops
+ the override for the next deploy.
+ - Why-list "Fixed by this update" ("Not fixed yet" for no_fix): up to 3
+ rows in one bordered list, inside the open row of its image, so the
+ image is always clear. Line 1: the title (13px) and, on the right, the
+ severity badge with the CVSS score next to it. The score shows only when
+ its CVSS range has the same severity (9.0 critical, 7.0 high, 4.0
+ medium); otherwise the badge alone, with "CVSS 7.5" in the tooltip, so a
+ row never reads "Medium 7.5". Line 2, 11px muted: "{package} {version} ·
+ {display ID} · {signal}"; the ID links to NVD or GitHub, and "Used by
+ attackers" is in `text-error`. Then the link "{n} more problems this
+ update fixes →", which opens All findings (filtered to the image when
+ the resource has several).
+ - Provenance: one 12px muted line with the 12px medium label "How Coolify
+ knows:" and the sentence of `SecuritySentences::provenance()`.
+ - Other fixes: a `` "{n} other fixes" whose `chevron-down` points
+ right when closed and turns 90° when open (180ms,
+ `cubic-bezier(0.23,1,0.32,1)`, `motion-reduce:transition-none`). The open
+ list spans the full row width: each action with its sentence and a
+ neutral button or copy chip, then "and {n} more".
+ - Footer, above a divider: "Running {ref} {short digest}" (the first 12
+ characters after `sha256:` of the repo digest, else of the image ID, with
+ `x-copy-button` for the full value) · "{n} findings" · "Packages and
+ SBOM" · "Settings". The links use the text link style (`underline
+ decoration-neutral-400/40 underline-offset-4`, full decoration on
+ hover). "{n} findings" opens All findings, "Packages and SBOM" and
+ "Settings" open their cards: each dispatches `open-settings-section`
+ with the section id (`x-application.settings-section` opens itself and
+ scrolls into view). For several images they also filter the section to
+ that image (`showImageFindings()` and the Packages
+ `security-show-image-packages` event). A "·" separator stays with the
+ part after it, so no line ends with one.
+ - Provenance never states a count of its own, so it cannot contradict the
+ sentence; the "better" rule guarantees that an offered update adds no
+ problem with an exploit signal. When the stored entry has `added` > 0 it
+ adds "It has {m} other problems that attackers are not known to use."
+ Without a stored candidate there is no line, so it never claims a scan.
+ - Fix button for every deployment type (`UpdateResourceImage`): a new tag
+ changes the stored configuration (the tag of a Docker image app, `image`
+ of a database, the compose file and images of a service, the Coolify
+ variable when `${IMAGE}` or `repo:${TAG}` sets the image, or the image
+ override of a Compose app from Git), so it stays after later deploys. A
+ newer build of the same tag changes nothing: the next start pulls the
+ scanned digest and tags it (`ImageStartPin`). Raw Compose deployments and
+ other variable forms keep the "Change the version in the compose file"
+ sentence without a button.
+ - Confirmation (`x-modal-confirmation`): one line per image "{ref} {short
+ digest} → {new ref}" (with the name for a service), what restarts (a
+ service names every application and database it restarts), and the
+ warning: the patch release sentence for a database, the template warning
+ for a service. Buttons show only to users who may run them (Write for
+ updates, Deploy for Rebuild) and never for `id = 0` or a transferred-away
+ server. Scan now on a transferred-away server says "Another Coolify
+ instance manages this server now." and starts nothing.
+ - Motion: a row opens and closes by animating height and opacity together
+ (`grid-rows-[0fr]` → `grid-rows-[1fr]` with `opacity`, 180ms,
+ `cubic-bezier(0.23,1,0.32,1)`); the chevron turns 90° with the same
+ timing. The server renders the open state, so nothing animates on page
+ load, and `motion-reduce:transition-none` makes it instant. A closed body
+ is `inert`. Row keys are the scan UUID (or the owner UUID for an image
+ without a scan), never a position.
+ - Titles: `ScannerText::tidyTitle()` cleans parsed and stored titles. It
+ removes advisory labels ("Issue summary:"), leading changelog tokens
+ ("[1.0.9-10.0.1] - "), "[Orabug: …]" groups, and trailing "[CVE-…]"
+ references, and keeps only the first entry of a changelog. The parser
+ takes the related description when the finding's own description is a
+ changelog. A stored title that still looks like a changelog entry shows
+ the fallback "{package} {version}: {severity} vulnerability" until a
+ rescan (`ScannerText::storedTitle()`).
+ - No totals and no severity bar: the only total finding count of the page is
+ in the "All findings ({n})" header, and the row footer has the count of its
+ image. Every count uses `number_format()` ("1,362"). The collapsed "All
+ findings", "Packages and SBOM", and "Settings" cards follow, with the same
+ `gap-6` between every card. Settings holds the rules and ignored findings
+ of an app; on a database or service it lists the ignored findings only (a
+ service with the ignores of its applications and databases, "Set for"
+ names the image). Hidden helpers in the stack (the teleported ignore
+ modal) stay `hidden`, so they add no second gap.
+ - All findings: the fix-state switch "Fix available upstream ({n})" / "No
+ fix yet ({n})" (a fixed package version exists, which is not the same as
+ the card's update), and the column "Findings" in the By package view.
+ - Image picker (`x-image-scan.image-select`): the "All findings" and
+ "Packages and SBOM" toolbars show a single-select "All images" button
+ (`layers` icon, highlighted when one image is picked) left of Filter, only
+ when the resource has more than one image. The image is not in the Filter
+ menu, and "Reset filters" does not clear it.
+ - Row actions in the findings tables are icon buttons (`eye-off2`, tooltip
+ "Ignore" or "Ignore package"), not labelled buttons on every row.
+
---
### Domain rows on mobile
@@ -1436,6 +1766,9 @@ Use these as implementation references:
| Custom dropdown | `resources/views/components/forms/listbox.blade.php` |
| Empty state | `resources/views/components/empty.blade.php` |
| Status pill | `resources/views/components/status-badge.blade.php` |
+| Severity badge, security marker, gate status, security dot | `resources/views/components/image-scan/` |
+| Team Security page | `resources/views/livewire/image-scan/fleet-vulnerabilities.blade.php` |
+| Security main card | `resources/views/livewire/image-scan/vulnerabilities.blade.php` |
| Floating save pill | `resources/views/components/unsaved-bar.blade.php` |
| Global toast | `resources/views/components/toast.blade.php` |
| Command palette / global search | `resources/views/livewire/global-search.blade.php` |
diff --git a/app/Actions/Application/CleanupPreviewDeployment.php b/app/Actions/Application/CleanupPreviewDeployment.php
index 661abc8d43..c4cfd155be 100644
--- a/app/Actions/Application/CleanupPreviewDeployment.php
+++ b/app/Actions/Application/CleanupPreviewDeployment.php
@@ -4,6 +4,7 @@ namespace App\Actions\Application;
use App\Enums\ApplicationDeploymentStatus;
use App\Jobs\DeleteResourceJob;
+use App\Jobs\SyncGithubDeploymentJob;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\ApplicationPreview;
@@ -37,6 +38,13 @@ class CleanupPreviewDeployment
'status' => 'success',
];
+ // GitHub must not show a closed pull request as deployed, also when the server is down.
+ try {
+ SyncGithubDeploymentJob::deactivatePullRequest($application, $pull_request_id);
+ } catch (\Throwable $e) {
+ \Log::warning("Failed to queue GitHub deployment deactivation for application {$application->id}: {$e->getMessage()}");
+ }
+
$server = $application->destination->server;
if (! $server->isFunctional()) {
diff --git a/app/Actions/Database/StartClickhouse.php b/app/Actions/Database/StartClickhouse.php
index 0760269b3a..3ee2e0b0b0 100644
--- a/app/Actions/Database/StartClickhouse.php
+++ b/app/Actions/Database/StartClickhouse.php
@@ -121,7 +121,7 @@ class StartClickhouse
$readme = generate_readme_file($this->database->name, now());
$this->commands[] = "echo '{$readme}' | tee $this->configuration_dir/README.md > /dev/null";
$this->commands[] = 'echo '.escapeshellarg("Pulling {$database->image} image.");
- $this->commands[] = serverDockerConfigPrefix($this->database->destination->server)."docker compose -f $this->configuration_dir/docker-compose.yml pull";
+ array_push($this->commands, ...$this->pullImageCommands("{$this->configuration_dir}/docker-compose.yml"));
$this->commands[] = dockerStopCommand(10, $container_name, $this->database->destination->server).' 2>/dev/null || true';
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
diff --git a/app/Actions/Database/StartDatabaseImport.php b/app/Actions/Database/StartDatabaseImport.php
index cb08f5a90c..b3b7a83a0e 100644
--- a/app/Actions/Database/StartDatabaseImport.php
+++ b/app/Actions/Database/StartDatabaseImport.php
@@ -9,6 +9,7 @@ use App\Models\Server;
use App\Models\ServiceDatabase;
use App\Models\StandaloneSqlite;
use App\Models\SwarmDocker;
+use App\Services\RcloneTransfer;
use App\Support\DatabaseBackupFileValidator;
use App\Support\DatabaseImport\DatabaseImportCleanup;
use App\Support\DatabaseImport\DatabaseImportCommandBuilder;
@@ -181,7 +182,7 @@ class StartDatabaseImport
$storage = S3Storage::ownedByCurrentTeamAPI($teamId)
->where(fn ($query) => $query->whereUuid($source->s3StorageUuid)->orWhere('id', ctype_digit((string) $source->s3StorageUuid) ? (int) $source->s3StorageUuid : -1))
->where('is_usable', true)->first();
- if (! $storage || ! ValidationPatterns::isValidS3BucketName($storage->bucket)) {
+ if (! $storage || ($storage->isS3() && ! ValidationPatterns::isValidS3BucketName($storage->bucket))) {
throw new DatabaseImportException('S3 storage was not found or has an invalid bucket.');
}
$key = ltrim((string) $source->path, '/');
@@ -194,8 +195,22 @@ class StartDatabaseImport
throw new DatabaseImportException('The S3 backup was not found or exceeds the 10 GiB limit.');
}
$helper = "s3-restore-{$operation}";
+ if ($storage->isS3()) {
+ $downloadCommand = $this->startS3Helper($storage, $server, $helper, $network, $key);
+ } else {
+ // The rclone image has the BusyBox tools that streamFromHelper() needs.
+ $transfer = new RcloneTransfer($storage, $server, $helper);
+ try {
+ $transfer->start(network: $network);
+ } catch (Throwable) {
+ instant_remote_process([$transfer->removeCommand()], $server, throwError: false);
+
+ throw new DatabaseImportException('Unable to start the restore helper.');
+ }
+ $downloadCommand = $transfer->downloadCommand($key, '/tmp/restore');
+ }
$commandList = [
- $this->startS3Helper($storage, $server, $helper, $network, $key),
+ $downloadCommand,
...$this->streamFromHelper($helper, $container, $containerPath),
'docker rm -f '.escapeshellarg($helper).' 2>/dev/null || true',
];
diff --git a/app/Actions/Database/StartDragonfly.php b/app/Actions/Database/StartDragonfly.php
index f2f074e510..a821c52473 100644
--- a/app/Actions/Database/StartDragonfly.php
+++ b/app/Actions/Database/StartDragonfly.php
@@ -186,7 +186,7 @@ class StartDragonfly
$readme = generate_readme_file($this->database->name, now());
$this->commands[] = "echo '{$readme}' | tee $this->configuration_dir/README.md > /dev/null";
$this->commands[] = 'echo '.escapeshellarg("Pulling {$database->image} image.");
- $this->commands[] = serverDockerConfigPrefix($this->database->destination->server)."docker compose -f $this->configuration_dir/docker-compose.yml pull";
+ array_push($this->commands, ...$this->pullImageCommands("{$this->configuration_dir}/docker-compose.yml"));
if ($this->database->enable_ssl) {
$this->commands[] = "chown -R 999:999 $this->configuration_dir/ssl/server.key $this->configuration_dir/ssl/server.crt";
}
diff --git a/app/Actions/Database/StartKeydb.php b/app/Actions/Database/StartKeydb.php
index 51e8b859d4..95b6912159 100644
--- a/app/Actions/Database/StartKeydb.php
+++ b/app/Actions/Database/StartKeydb.php
@@ -201,7 +201,7 @@ class StartKeydb
$readme = generate_readme_file($this->database->name, now());
$this->commands[] = "echo '{$readme}' | tee $this->configuration_dir/README.md > /dev/null";
$this->commands[] = 'echo '.escapeshellarg("Pulling {$database->image} image.");
- $this->commands[] = serverDockerConfigPrefix($this->database->destination->server)."docker compose -f $this->configuration_dir/docker-compose.yml pull";
+ array_push($this->commands, ...$this->pullImageCommands("{$this->configuration_dir}/docker-compose.yml"));
if ($this->database->enable_ssl) {
$this->commands[] = "chown -R 999:999 $this->configuration_dir/ssl/server.key $this->configuration_dir/ssl/server.crt";
}
diff --git a/app/Actions/Database/StartMariadb.php b/app/Actions/Database/StartMariadb.php
index 1ba35c8866..d2a17e550e 100644
--- a/app/Actions/Database/StartMariadb.php
+++ b/app/Actions/Database/StartMariadb.php
@@ -202,7 +202,7 @@ class StartMariadb
$readme = generate_readme_file($this->database->name, now());
$this->commands[] = "echo '{$readme}' | tee $this->configuration_dir/README.md > /dev/null";
$this->commands[] = 'echo '.escapeshellarg("Pulling {$database->image} image.");
- $this->commands[] = serverDockerConfigPrefix($this->database->destination->server)."docker compose -f $this->configuration_dir/docker-compose.yml pull";
+ array_push($this->commands, ...$this->pullImageCommands("{$this->configuration_dir}/docker-compose.yml"));
if ($this->database->enable_ssl) {
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml run --rm --no-deps --user root --entrypoint chown $container_name mysql:mysql /etc/mysql/certs/server.key /etc/mysql/certs/server.crt < /dev/null";
}
diff --git a/app/Actions/Database/StartMongodb.php b/app/Actions/Database/StartMongodb.php
index 6008166852..cd8085b710 100644
--- a/app/Actions/Database/StartMongodb.php
+++ b/app/Actions/Database/StartMongodb.php
@@ -250,7 +250,7 @@ class StartMongodb
$readme = generate_readme_file($this->database->name, now());
$this->commands[] = "echo '{$readme}' | tee $this->configuration_dir/README.md > /dev/null";
$this->commands[] = 'echo '.escapeshellarg("Pulling {$database->image} image.");
- $this->commands[] = serverDockerConfigPrefix($this->database->destination->server)."docker compose -f $this->configuration_dir/docker-compose.yml pull";
+ array_push($this->commands, ...$this->pullImageCommands("{$this->configuration_dir}/docker-compose.yml"));
if ($this->database->enable_ssl) {
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml run --rm --no-deps --user root --entrypoint chown $container_name mongodb:mongodb /etc/mongo/certs/server.pem < /dev/null";
}
diff --git a/app/Actions/Database/StartMysql.php b/app/Actions/Database/StartMysql.php
index 8f3825ffc0..85d63ca69b 100644
--- a/app/Actions/Database/StartMysql.php
+++ b/app/Actions/Database/StartMysql.php
@@ -205,7 +205,7 @@ class StartMysql
$readme = generate_readme_file($this->database->name, now());
$this->commands[] = "echo '{$readme}' | tee $this->configuration_dir/README.md > /dev/null";
$this->commands[] = 'echo '.escapeshellarg("Pulling {$database->image} image.");
- $this->commands[] = serverDockerConfigPrefix($this->database->destination->server)."docker compose -f $this->configuration_dir/docker-compose.yml pull";
+ array_push($this->commands, ...$this->pullImageCommands("{$this->configuration_dir}/docker-compose.yml"));
if ($this->database->enable_ssl) {
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml run --rm --no-deps --user root --entrypoint chown $container_name mysql:mysql /etc/mysql/certs/server.key /etc/mysql/certs/server.crt < /dev/null";
}
diff --git a/app/Actions/Database/StartPostgresql.php b/app/Actions/Database/StartPostgresql.php
index f307d543af..affd5f47d5 100644
--- a/app/Actions/Database/StartPostgresql.php
+++ b/app/Actions/Database/StartPostgresql.php
@@ -214,7 +214,7 @@ class StartPostgresql
$readme = generate_readme_file($this->database->name, now());
$this->commands[] = "echo '{$readme}' | tee $this->configuration_dir/README.md > /dev/null";
$this->commands[] = 'echo '.escapeshellarg("Pulling {$database->image} image.");
- $this->commands[] = serverDockerConfigPrefix($this->database->destination->server)."docker compose -f $this->configuration_dir/docker-compose.yml pull";
+ array_push($this->commands, ...$this->pullImageCommands("{$this->configuration_dir}/docker-compose.yml"));
if ($this->database->enable_ssl) {
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml run --rm --no-deps --user root --entrypoint chown $container_name postgres:postgres /var/lib/postgresql/certs/server.key /var/lib/postgresql/certs/server.crt < /dev/null";
}
diff --git a/app/Actions/Database/StartRedis.php b/app/Actions/Database/StartRedis.php
index a58ec72b46..6669791fe0 100644
--- a/app/Actions/Database/StartRedis.php
+++ b/app/Actions/Database/StartRedis.php
@@ -197,7 +197,7 @@ class StartRedis
$readme = generate_readme_file($this->database->name, now());
$this->commands[] = "echo '{$readme}' | tee $this->configuration_dir/README.md > /dev/null";
$this->commands[] = 'echo '.escapeshellarg("Pulling {$database->image} image.");
- $this->commands[] = serverDockerConfigPrefix($this->database->destination->server)."docker compose -f $this->configuration_dir/docker-compose.yml pull";
+ array_push($this->commands, ...$this->pullImageCommands("{$this->configuration_dir}/docker-compose.yml"));
if ($this->database->enable_ssl) {
$this->commands[] = "chown -R 999:999 $this->configuration_dir/ssl/server.key $this->configuration_dir/ssl/server.crt";
}
diff --git a/app/Actions/Database/StartSqlite.php b/app/Actions/Database/StartSqlite.php
index 60dd19c28b..e716260cd9 100644
--- a/app/Actions/Database/StartSqlite.php
+++ b/app/Actions/Database/StartSqlite.php
@@ -99,7 +99,7 @@ class StartSqlite
$readme = generate_readme_file($this->database->name, now());
$this->commands[] = "echo '{$readme}' | tee $this->configuration_dir/README.md > /dev/null";
$this->commands[] = 'echo '.escapeshellarg("Pulling {$database->image} image.");
- $this->commands[] = serverDockerConfigPrefix($this->database->destination->server)."docker compose -f $this->configuration_dir/docker-compose.yml pull";
+ array_push($this->commands, ...$this->pullImageCommands("{$this->configuration_dir}/docker-compose.yml"));
$this->commands[] = dockerStopCommand(10, $container_name, $this->database->destination->server).' 2>/dev/null || true';
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
diff --git a/app/Actions/ImageScan/RefreshCandidates.php b/app/Actions/ImageScan/RefreshCandidates.php
new file mode 100644
index 0000000000..9121203924
--- /dev/null
+++ b/app/Actions/ImageScan/RefreshCandidates.php
@@ -0,0 +1,914 @@
+id}";
+ }
+
+ /**
+ * @param int $lockWaitSeconds A deploy gate waits for the server lock; 0 tries once (background checks).
+ * @param bool $retryWhenBusy Throw ScanLockBusy instead of storing `lock_busy`, so the caller retries later.
+ *
+ * @throws ScanLockBusy
+ */
+ public function handle(ImageScan $scan, int $lockWaitSeconds = 0, bool $retryWhenBusy = false): void
+ {
+ $this->retryWhenBusy = $retryWhenBusy && $lockWaitSeconds <= 0;
+ $scan = ImageScan::query()->with(['resource', 'server.settings', 'sbom'])->find($scan->id);
+ if (! $scan instanceof ImageScan) {
+ return;
+ }
+ if (! self::needsCandidates($scan)) {
+ ScanNotifications::reevaluate($scan);
+
+ return;
+ }
+
+ $server = $scan->server;
+ $resource = $scan->resource;
+ if (! $server instanceof Server || ! ImageRescanJob::canRunOn($server) || ! ImageScanPolicyResolver::for($resource, $server)->scanEnabled) {
+ // No check runs, so an alert that waits for it is sent now.
+ ScanNotifications::reevaluate($scan, checkGaveUp: true);
+
+ return;
+ }
+
+ $this->commands = new ScannerCommands($server, Str::lower(Str::random(24)));
+ $ranOnServer = false;
+ $result = ['status' => 'skipped', 'error' => null, 'entries' => [], 'tags_truncated' => false];
+ $base = null;
+
+ try {
+ if (self::needsImageCandidates($scan)) {
+ $result = $this->guarded(fn () => $this->check($scan, $resource, $lockWaitSeconds, $ranOnServer));
+ }
+ if (self::needsBaseCandidates($scan)) {
+ $base = $this->guarded(fn () => $this->checkBase($scan, $resource, $lockWaitSeconds, $ranOnServer));
+ }
+ } finally {
+ if ($ranOnServer) {
+ instant_remote_process($this->commands->cleanup(), $server, throwError: false, timeout: self::TIMEOUT_DEFAULT);
+ }
+ }
+
+ $this->store($scan, $result, $base);
+ }
+
+ /**
+ * Runs one check with a fresh registry state and adds whether the tag list was cut.
+ *
+ * @param callable(): array{status: string, error: ?string, entries: list>} $check
+ * @return array{status: string, error: ?string, entries: list>, tags_truncated: bool}
+ */
+ private function guarded(callable $check): array
+ {
+ $this->auth = null;
+ $this->loginRead = false;
+ $this->authRequired = false;
+ $this->anonymousWorked = false;
+ $this->tagsTruncated = false;
+ $this->error = null;
+
+ try {
+ $result = $check();
+ } catch (ScanLockBusy $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ report($e);
+ $result = ['status' => 'failed', 'error' => 'unknown', 'entries' => [], 'attributable' => null, 'attributable_ids' => [], 'current_digest' => null];
+ }
+
+ return [...$result, 'tags_truncated' => $this->tagsTruncated];
+ }
+
+ public static function needsCandidates(ImageScan $scan): bool
+ {
+ return self::needsImageCandidates($scan) || self::needsBaseCandidates($scan);
+ }
+
+ /**
+ * Candidates are looked for only for images the user does not build in Coolify (a Docker image app, or an image a
+ * Compose app pulls), with a relevant finding or an end-of-life distro (spec 15.4).
+ */
+ private static function needsImageCandidates(ImageScan $scan): bool
+ {
+ $resource = $scan->resource;
+ if ($scan->status !== ImageScanStatus::Completed || ! $resource instanceof Model) {
+ return false;
+ }
+ if ($resource instanceof Application && $resource->build_pack !== 'dockerimage'
+ && ($resource->build_pack !== 'dockercompose' || $scan->builds_image !== false)) {
+ return false;
+ }
+
+ return self::hasEolDistro($scan) || self::relevantFindings($scan, $resource) !== [];
+ }
+
+ /**
+ * A newer base image is looked for on the same trigger when a Dockerfile build has a known base image (spec 15.4).
+ * Nixpacks, Railpack, and static builds have no base image.
+ */
+ private static function needsBaseCandidates(ImageScan $scan): bool
+ {
+ $resource = $scan->resource;
+
+ return $scan->status === ImageScanStatus::Completed
+ && $resource instanceof Application
+ && $resource->build_pack === 'dockerfile'
+ && filled($scan->base_image_ref)
+ && (self::hasEolDistro($scan) || self::relevantFindings($scan, $resource) !== []);
+ }
+
+ /**
+ * The relevant findings of the scan (spec 15.2), by the rules of the resource unless `$rules` is given.
+ *
+ * @return list
+ */
+ public static function relevantFindings(ImageScan $scan, Model $resource, ?RelevanceRules $rules = null): array
+ {
+ $rules ??= RelevanceRules::forResource($resource);
+
+ return array_values(array_filter(
+ ResourceScans::findings($scan),
+ fn (Finding $finding): bool => SecurityRules::isRelevant($finding, $rules),
+ ));
+ }
+
+ private static function hasEolDistro(ImageScan $scan): bool
+ {
+ return is_array(data_get($scan->summary, 'eol_distro'));
+ }
+
+ /**
+ * The last check finished less than FRESH_HOURS ago with a result that does not need a retry, also for the base image.
+ */
+ public static function isFresh(ImageScan $scan): bool
+ {
+ $candidates = $scan->candidates;
+ $checkedAt = is_array($candidates) ? ($candidates['checked_at'] ?? null) : null;
+ $fresh = ['ok', 'no_better', 'cannot_compare', 'skipped'];
+ if (! is_string($checkedAt) || ! in_array($scan->candidateStatus(), $fresh, true)) {
+ return false;
+ }
+ if (self::needsBaseCandidates($scan) && ! in_array(data_get($candidates, 'base.status'), $fresh, true)) {
+ return false;
+ }
+
+ try {
+ return now()->subHours(self::FRESH_HOURS)->lt($checkedAt);
+ } catch (Throwable) {
+ return false;
+ }
+ }
+
+ public static function shouldRefresh(ImageScan $scan): bool
+ {
+ return $scan->exists && ! self::isFresh($scan) && self::needsCandidates($scan);
+ }
+
+ /**
+ * Text for a version check (or, with $base, a base check) without a better candidate (spec 7.3, 11). Null when a
+ * candidate is better or nothing was checked.
+ */
+ public static function message(ImageScan $scan, bool $base = false): ?string
+ {
+ $result = $base ? data_get($scan->candidates, 'base') : $scan->candidates;
+ $status = is_array($result) ? ($result['status'] ?? null) : null;
+ try {
+ $image = ImageRef::parse((string) ($base ? $scan->base_image_ref : $scan->image_ref));
+ } catch (InvalidArgumentException) {
+ return null;
+ }
+ $name = (string) ImageRef::parse($image->qualifiedName());
+ $server = (string) ($scan->server?->name ?? 'the server');
+
+ return match ($status) {
+ 'no_better' => "No newer version of {$name} is better yet. Coolify checks again every day.",
+ 'cannot_compare' => 'Coolify cannot compare versions of this image. Check the image page for a newer version.',
+ 'auth_required' => "Coolify cannot read the tags of {$name}. Log in to {$image->registry} on {$server}.",
+ 'failed' => data_get($result, 'error') === 'daily_limit'
+ ? "Coolify reached the daily limit of version checks on {$server}. It checks again tomorrow."
+ : "Coolify could not check for newer versions of {$name}. It tries again tomorrow.",
+ default => null,
+ };
+ }
+
+ /**
+ * @return array{status: string, error: ?string, entries: list>}
+ */
+ private function check(ImageScan $scan, Model $resource, int $lockWaitSeconds, bool &$ranOnServer): array
+ {
+ try {
+ $this->image = ImageRef::parse((string) $scan->image_ref);
+ } catch (InvalidArgumentException) {
+ return ['status' => 'cannot_compare', 'error' => null, 'entries' => []];
+ }
+ if ($this->image->digest !== null) {
+ return ['status' => 'cannot_compare', 'error' => null, 'entries' => []];
+ }
+
+ $platform = $scan->sbom?->platform;
+ if (! is_string($platform) || $platform === '') {
+ return ['status' => 'failed', 'error' => 'unknown', 'entries' => []];
+ }
+
+ $ranOnServer = true;
+ $this->currentImageId = $scan->image_id;
+ $tag = $this->image->tag ?? 'latest';
+ $selector = new TagSelector;
+ $isVersion = $selector->isVersion($tag);
+ $selected = ['line' => null, 'patch' => null, 'major' => null, 'floating' => false];
+
+ if ($isVersion) {
+ $tags = $this->registry(fn () => $this->commands->tags($this->image->qualifiedName(), $this->auth), 'tags');
+ if ($tags === null) {
+ return $this->failure();
+ }
+ $tags = array_values(array_filter(array_map('trim', explode("\n", $tags)), fn (string $line) => $line !== ''));
+ $this->tagsTruncated = count($tags) > TagSelector::MAX_TAGS;
+ $selected = $selector->select($tag, $tags);
+ }
+
+ // The smallest change first: a newer build of the same tag, the newest patch of the same minor, then the newest
+ // tag of the major. A safer patch ends the search of the line. The same-tag build does not take one of the
+ // MAX_CANDIDATE_SCANS slots, so the patch and the line tag always fit.
+ $entries = [];
+ $scannedDigests = [];
+ $tagScans = 0;
+ if ($scan->repo_digest !== null) {
+ $digest = $this->digest($tag);
+ if ($digest === null && ($this->authRequired || ! $isVersion)) {
+ return $this->failure();
+ }
+ if ($digest !== null && $digest !== $scan->repo_digest) {
+ $scannedDigests[] = $digest;
+ $entries = [...$entries, ...$this->evaluate($scan, $resource, [['kind' => 'same_tag', 'tag' => $tag, 'digest' => $digest]], $platform, $lockWaitSeconds)];
+ }
+ }
+
+ foreach (array_filter([$selected['patch'], $selected['line']], fn (?string $lineTag): bool => $lineTag !== null && $lineTag !== $tag) as $lineTag) {
+ $digest = $this->digest($lineTag);
+ if ($digest === null) {
+ $this->error ??= 'registry';
+
+ continue;
+ }
+ if ($digest === $scan->repo_digest || in_array($digest, $scannedDigests, true)) {
+ continue;
+ }
+ $scannedDigests[] = $digest;
+ $tagScans++;
+ $lineEntries = $this->evaluate($scan, $resource, [['kind' => 'line', 'tag' => $lineTag, 'digest' => $digest]], $platform, $lockWaitSeconds);
+ $entries = [...$entries, ...$lineEntries];
+ if (($lineEntries[0]['better'] ?? false) === true) {
+ break;
+ }
+ }
+
+ if ($selected['major'] !== null) {
+ $entries[] = $this->majorEntry($scan, $resource, $selected['major'], $platform, $lockWaitSeconds,
+ withScan: ! in_array(true, array_column($entries, 'better'), true) && $tagScans < self::MAX_CANDIDATE_SCANS);
+ }
+
+ $scanned = array_filter($entries, fn (array $entry) => $entry['kind'] !== 'major');
+
+ return [
+ 'status' => match (true) {
+ in_array(true, array_column($entries, 'better'), true) => 'ok',
+ $this->authRequired => 'auth_required',
+ $this->error !== null => 'failed',
+ ! $isVersion && $scanned === [] => 'cannot_compare',
+ default => 'no_better',
+ },
+ 'error' => $this->error,
+ 'entries' => $entries,
+ ];
+ }
+
+ /**
+ * The newest tag of a higher major. It is never offered as an update: a major version can need a data migration or
+ * code changes. When nothing of the same major is safer, the check has a scan left, and the server has at least
+ * MAJOR_SCAN_DAILY_RESERVE daily scans left, Coolify scans it, so the page can say whether it would fix the problems
+ * (`fixes_problems`, the "better" rule). Its registry and scan problems, also a busy lock, never change the status
+ * of the check.
+ *
+ * @return array
+ */
+ private function majorEntry(ImageScan $scan, Model $resource, string $majorTag, string $platform, int $lockWaitSeconds, bool $withScan): array
+ {
+ $entry = [
+ 'ref' => (string) $this->image->withTag($majorTag),
+ 'digest' => null,
+ 'kind' => 'major',
+ 'better' => false,
+ 'fixed_ids' => [],
+ 'fixes' => null,
+ 'added' => null,
+ 'left' => null,
+ 'eol' => null,
+ 'scanned_at' => null,
+ ];
+ if (! $withScan || self::dailyScansLeft($this->commands->server) < self::MAJOR_SCAN_DAILY_RESERVE) {
+ return $entry;
+ }
+
+ [$error, $authRequired] = [$this->error, $this->authRequired];
+ try {
+ $digest = $this->digest($majorTag);
+ $scanned = $digest === null ? [] : $this->evaluate($scan, $resource, [['kind' => 'major', 'tag' => $majorTag, 'digest' => $digest]], $platform, $lockWaitSeconds);
+ } catch (ScanLockBusy) {
+ $scanned = [];
+ }
+ [$this->error, $this->authRequired] = [$error, $authRequired];
+
+ if ($scanned === []) {
+ return $entry;
+ }
+
+ return [...$scanned[0], 'better' => false, 'fixes_problems' => $scanned[0]['better']];
+ }
+
+ /**
+ * Scans the current base image and the newest tag of its line from the registry (spec 7.4): two candidate scans
+ * with the usual lock, disk, reuse, and daily rules. Only a different tag can change the `FROM` line, so a newer
+ * build of the same tag is not checked. The current base image is scanned also when no newer tag exists or the tag
+ * cannot be compared (not a version, or pinned by digest), so `attributable` is set on every result but a failure,
+ * an unparsable reference, or a truncated scan of the current base image.
+ *
+ * @return array{status: string, error: ?string, entries: list>, attributable: ?int,
+ * attributable_ids: list, current_digest: ?string}
+ */
+ private function checkBase(ImageScan $scan, Model $resource, int $lockWaitSeconds, bool &$ranOnServer): array
+ {
+ $result = fn (string $status, array $entries = [], ?array $attributable = null, ?string $currentDigest = null): array => [
+ 'status' => $status,
+ 'error' => $status === 'cannot_compare' ? null : $this->error,
+ 'entries' => $entries,
+ 'attributable' => $attributable === null ? null : count($attributable),
+ 'attributable_ids' => $attributable === null ? [] : array_values(array_unique(array_map(fn (Finding $finding) => $finding->id, $attributable))),
+ 'current_digest' => $currentDigest,
+ ];
+ $failure = fn (?array $attributable = null, ?string $currentDigest = null): array => [
+ ...$result('failed', [], $attributable, $currentDigest),
+ ...$this->failure(),
+ ];
+
+ try {
+ $this->image = ImageRef::parse((string) $scan->base_image_ref);
+ } catch (InvalidArgumentException) {
+ return $result('cannot_compare');
+ }
+
+ $tag = $this->image->tag ?? 'latest';
+ $selector = new TagSelector;
+ $comparable = $this->image->digest === null && $selector->isVersion($tag);
+
+ $platform = $scan->sbom?->platform;
+ if (! is_string($platform) || $platform === '') {
+ $this->error = 'unknown';
+
+ return $result('failed');
+ }
+
+ $ranOnServer = true;
+ // The disk check of the registry scans uses the size of the app image, an upper bound of its base image.
+ $this->currentImageId = $scan->image_id;
+ $this->currentImageSize = null;
+ // A truncated scan of the current base image cannot tell which findings come from it.
+ $attributableOf = fn (array $currentBase): ?array => $currentBase['truncated']
+ ? null
+ : (new CandidateEvaluator)->shared(self::relevantFindings($scan, $resource, new RelevanceRules(IgnoreMatcher::forResource($resource), countMedium: true)), $currentBase['findings']);
+
+ if (! $comparable) {
+ // No newer tag can be chosen, but the current base image (by tag or pinned digest) still tells the origin.
+ $currentDigest = $this->image->digest ?? $this->digest($tag);
+ if ($currentDigest === null) {
+ return $failure();
+ }
+ $currentBase = $this->scanCandidate($currentDigest, $platform, $lockWaitSeconds);
+ if ($currentBase === null) {
+ return $failure(null, $currentDigest);
+ }
+
+ return $result('cannot_compare', [], $attributableOf($currentBase), $currentDigest);
+ }
+
+ $tags = $this->registry(fn () => $this->commands->tags($this->image->qualifiedName(), $this->auth), 'tags');
+ if ($tags === null) {
+ return $failure();
+ }
+ $tags = array_values(array_filter(array_map('trim', explode("\n", $tags)), fn (string $line) => $line !== ''));
+ $this->tagsTruncated = count($tags) > TagSelector::MAX_TAGS;
+ $line = $selector->select($tag, $tags)['line'];
+ $hasLine = $line !== null && $line !== $tag;
+
+ $currentDigest = $this->digest($tag);
+ $lineDigest = $currentDigest === null || ! $hasLine ? null : $this->digest($line);
+ if ($currentDigest === null || ($hasLine && $lineDigest === null)) {
+ return $failure(null, $currentDigest);
+ }
+
+ // The current base image is scanned also without a newer tag, so the actions know which findings come from it.
+ $currentBase = $this->scanCandidate($currentDigest, $platform, $lockWaitSeconds);
+ if ($currentBase === null) {
+ return $failure(null, $currentDigest);
+ }
+
+ $rules = RelevanceRules::forResource($resource);
+ $evaluator = new CandidateEvaluator;
+ $attributable = $attributableOf($currentBase);
+ if (! $hasLine || $lineDigest === $currentDigest) {
+ return $result('no_better', [], $attributable, $currentDigest);
+ }
+
+ $candidate = $this->scanCandidate($lineDigest, $platform, $lockWaitSeconds);
+ if ($candidate === null) {
+ return $failure($attributable, $currentDigest);
+ }
+
+ $candidateFindings = $candidate['findings'];
+ $better = ! $currentBase['truncated'] && ! $candidate['truncated']
+ && $evaluator->isBetter($currentBase['findings'], $candidateFindings, $rules, $currentBase['eol'], $candidate['eol']);
+ $entry = [
+ 'ref' => (string) $this->image->withTag($line),
+ 'digest' => $lineDigest,
+ 'kind' => 'line',
+ 'better' => $better,
+ 'fixed_ids' => $evaluator->fixedIds($attributable ?? [], $candidateFindings, $rules->withAllMediums()),
+ 'fixes' => $evaluator->counts($evaluator->fixed($attributable ?? [], $candidateFindings)),
+ 'added' => $evaluator->added($evaluator->relevant($currentBase['findings'], $rules), $candidateFindings, $rules),
+ 'left' => $evaluator->counts($evaluator->relevant($candidateFindings, $rules)),
+ 'eol' => $candidate['eol'],
+ 'scanned_at' => now()->toIso8601String(),
+ ];
+
+ return $result($better ? 'ok' : 'no_better', [$entry], $attributable, $currentDigest);
+ }
+
+ /**
+ * @param list $candidates
+ * @return list>
+ */
+ private function evaluate(ImageScan $scan, Model $resource, array $candidates, string $platform, int $lockWaitSeconds): array
+ {
+ if ($candidates === []) {
+ return [];
+ }
+
+ $rules = RelevanceRules::forResource($resource);
+ $evaluator = new CandidateEvaluator;
+ $current = ResourceScans::findings($scan);
+ $currentRelevant = $evaluator->relevant($current, $rules);
+ $currentEol = data_get($scan->summary, 'eol_distro');
+ $currentEol = is_array($currentEol) ? $currentEol : null;
+
+ $entries = [];
+ foreach ($candidates as $candidate) {
+ $report = $this->scanCandidate($candidate['digest'], $platform, $lockWaitSeconds);
+ if ($report === null) {
+ continue;
+ }
+
+ $findings = $report['findings'];
+ $entries[] = [
+ 'ref' => (string) $this->image->withTag($candidate['tag']),
+ 'digest' => $candidate['digest'],
+ 'kind' => $candidate['kind'],
+ 'better' => ! $report['truncated'] && $evaluator->isBetter($current, $findings, $rules, $currentEol, $report['eol']),
+ 'fixed_ids' => $evaluator->fixedIds($current, $findings, $rules->withAllMediums()),
+ 'fixes' => $evaluator->counts($evaluator->fixed($currentRelevant, $findings)),
+ 'added' => $evaluator->added($currentRelevant, $findings, $rules),
+ 'left' => $evaluator->counts($evaluator->relevant($findings, $rules)),
+ 'eol' => $report['eol'],
+ 'scanned_at' => now()->toIso8601String(),
+ ];
+ }
+
+ return $entries;
+ }
+
+ /**
+ * Runs Grype on the SBOM of a candidate digest; makes the SBOM from the registry first when this server has none.
+ * Takes the scan lock of the server, so a deploy scan waits at most one candidate scan. A background check never
+ * waits for the lock and leaves it to a deploy gate that waits.
+ *
+ * @return array{findings: list, eol: ?array{name: string, version: string}, truncated: bool}|null
+ */
+ private function scanCandidate(string $digest, string $platform, int $lockWaitSeconds): ?array
+ {
+ $server = $this->commands->server;
+ $lock = ScanImage::serverLock($server);
+ if (! ScanImage::acquireServerLock($lock, $server, $lockWaitSeconds)) {
+ if ($this->retryWhenBusy) {
+ throw new ScanLockBusy;
+ }
+ $this->error ??= 'lock_busy';
+
+ return null;
+ }
+
+ try {
+ $hex = substr($digest, strlen('sha256:'));
+ $sbom = ImageSbom::query()->where('server_id', $server->id)->where('image_id', $digest)->first();
+
+ if ($sbom !== null) {
+ $grype = $this->runStep($this->commands->grype($hex), 'grype', self::TIMEOUT_GRYPE);
+ if ($grype !== null && $grype['code'] === 0) {
+ $sbom->forceFill(['last_used_at' => now()])->save();
+
+ return $this->report($grype);
+ }
+ if ($grype === null || $grype['code'] !== self::EXIT_DOCKER_RUN) {
+ $this->error ??= 'scan';
+
+ return null;
+ }
+ }
+
+ if (! $this->hasDiskSpace()) {
+ $this->error ??= 'disk';
+
+ return null;
+ }
+
+ if (! $this->reserveDailyScan($server)) {
+ $this->error ??= 'daily_limit';
+
+ return null;
+ }
+
+ $created = $this->runStep($this->commands->sbomFromRegistry($this->image->qualifiedName().'@'.$digest, $platform, $hex, $this->auth), 'sbom', self::TIMEOUT_SBOM);
+ $packages = $created !== null && $created['code'] === 0 ? $this->runStep($this->commands->packages($hex), 'packages', self::TIMEOUT_GRYPE) : null;
+ if ($packages === null || $packages['code'] !== 0) {
+ $this->error ??= 'scan';
+
+ return null;
+ }
+
+ $parsed = (new SyftPackageParser)->parse($this->payload($packages)[0]);
+ ($sbom ?? new ImageSbom(['server_id' => $server->id, 'image_id' => $digest]))->forceFill([
+ 'platform' => $platform,
+ 'packages' => array_map(fn ($package) => $package->toArray(), $parsed['packages']),
+ 'packages_truncated' => $parsed['truncated'] || $this->payload($packages)[1],
+ 'last_used_at' => now(),
+ ])->save();
+
+ $grype = $this->runStep($this->commands->grype($hex), 'grype', self::TIMEOUT_GRYPE);
+ if ($grype === null || $grype['code'] !== 0) {
+ $this->error ??= 'scan';
+
+ return null;
+ }
+
+ return $this->report($grype);
+ } finally {
+ $lock->release();
+ }
+ }
+
+ /**
+ * The disk rule of local scans (image size × 2 + 1 GB), with the size of the current image: Syft writes the
+ * candidate's layers to the scratch directory. A current image that is no longer on the server counts as 0 bytes.
+ */
+ private function hasDiskSpace(): bool
+ {
+ if ($this->currentImageSize === null) {
+ $this->currentImageSize = 0;
+ if (is_string($this->currentImageId) && preg_match('/\Asha256:[a-f0-9]{64}\z/', $this->currentImageId) === 1) {
+ $inspect = $this->runStep($this->commands->inspect($this->currentImageId), 'inspect', self::TIMEOUT_DEFAULT);
+ $parts = $inspect !== null && $inspect['code'] === 0 ? explode('|', trim($inspect['payload'])) : [];
+ if (count($parts) === 4 && preg_match('/\A\d{1,18}\z/', $parts[3]) === 1) {
+ $this->currentImageSize = (int) $parts[3];
+ }
+ }
+ }
+
+ try {
+ ScanImage::checkDiskSpace($this->commands, $this->currentImageSize);
+ } catch (ScanFailed) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * The registry scans the server has left today.
+ */
+ private static function dailyScansLeft(Server $server): int
+ {
+ $settings = ServerSetting::query()->where('server_id', $server->id)->first(['image_candidate_scans_today', 'image_candidate_scans_date']);
+ $date = $settings?->image_candidate_scans_date;
+ if ($date === null || ! today()->isSameDay($date)) {
+ return self::DAILY_CANDIDATE_SCANS;
+ }
+
+ return max(0, self::DAILY_CANDIDATE_SCANS - (int) $settings->image_candidate_scans_today);
+ }
+
+ /**
+ * Counts a registry scan against the daily limit of the server; false when the limit is reached.
+ */
+ private function reserveDailyScan(Server $server): bool
+ {
+ $today = today()->toDateString();
+
+ ServerSetting::query()
+ ->where('server_id', $server->id)
+ ->where(fn ($query) => $query->whereNull('image_candidate_scans_date')->orWhereDate('image_candidate_scans_date', '<', $today))
+ ->update(['image_candidate_scans_today' => 0, 'image_candidate_scans_date' => $today]);
+
+ return ServerSetting::query()
+ ->where('server_id', $server->id)
+ ->where('image_candidate_scans_today', '<', self::DAILY_CANDIDATE_SCANS)
+ ->increment('image_candidate_scans_today') > 0;
+ }
+
+ private function digest(string $tag): ?string
+ {
+ $payload = $this->registry(fn () => $this->commands->digest((string) $this->image->withTag($tag), null, $this->auth), 'digest');
+ $digest = $payload === null ? '' : trim($payload);
+
+ return ImageRef::isValidDigest($digest) ? $digest : null;
+ }
+
+ /**
+ * Runs a registry command anonymously first. After a failure it reads the server's login for the registry of the
+ * image once and repeats the command with it. Without a usable login, a private image needs one. When an anonymous
+ * call already worked, a failure is a registry error, not a missing login.
+ *
+ * @param callable(): list $commands
+ */
+ private function registry(callable $commands, string $step): ?string
+ {
+ $result = $this->runStep($commands(), $step, self::TIMEOUT_REGISTRY);
+ if ($result !== null && $result['code'] === 0) {
+ $this->anonymousWorked = $this->anonymousWorked || $this->auth === null;
+
+ return $this->payload($result)[0];
+ }
+ if ($result === null || in_array($result['code'], self::EXIT_TIMEOUT, true)) {
+ $this->error ??= 'registry';
+
+ return null;
+ }
+ if ($this->loginRead || $this->anonymousWorked) {
+ $this->error ??= 'registry';
+
+ return null;
+ }
+
+ $this->loginRead = true;
+ $login = DockerRegistryLogins::registryAuthConfig($this->commands->server, $this->image->registry);
+ if ($login['status'] !== 'ok') {
+ $this->authRequired = $login['status'] === 'helper' || ! $this->image->isDockerOfficial();
+ $this->error ??= 'registry';
+
+ return null;
+ }
+
+ $this->auth = $login['config'];
+ $result = $this->runStep($commands(), $step, self::TIMEOUT_REGISTRY);
+ if ($result !== null && $result['code'] === 0) {
+ return $this->payload($result)[0];
+ }
+
+ $this->authRequired = true;
+ $this->error ??= 'registry';
+
+ return null;
+ }
+
+ /**
+ * @return array{status: string, error: ?string, entries: list>}
+ */
+ private function failure(): array
+ {
+ return [
+ 'status' => $this->authRequired ? 'auth_required' : 'failed',
+ 'error' => $this->authRequired ? 'registry_auth' : ($this->error ?? 'registry'),
+ 'entries' => [],
+ ];
+ }
+
+ /**
+ * @param array{code: int, size: ?int, payload: string} $grype
+ * @return array{findings: list, eol: ?array{name: string, version: string}, truncated: bool}
+ */
+ private function report(array $grype): array
+ {
+ [$payload, $cut] = $this->payload($grype);
+ $report = (new GrypeReportParser)->parse($payload);
+
+ return ['findings' => $report['findings'], 'eol' => $report['eol'], 'truncated' => $report['truncated'] || $cut];
+ }
+
+ /**
+ * Null when the batch did not run or printed no marker for $step.
+ *
+ * @param list $commands
+ * @return array{code: int, size: ?int, payload: string}|null
+ */
+ private function runStep(array $commands, string $step, int $timeout): ?array
+ {
+ $output = instant_remote_process($commands, $this->commands->server, throwError: false, timeout: $timeout);
+ $parsed = ScannerCommands::parseOutput($output);
+ if (! array_key_exists($step, $parsed['exit_codes'])) {
+ return null;
+ }
+
+ return ['code' => $parsed['exit_codes'][$step], 'size' => $parsed['sizes'][$step] ?? null, 'payload' => $parsed['payload']];
+ }
+
+ /**
+ * Output over the limit was cut by `head -c`: keep only its complete lines.
+ *
+ * @param array{code: int, size: ?int, payload: string} $result
+ * @return array{0: string, 1: bool}
+ */
+ private function payload(array $result): array
+ {
+ $payload = $result['payload'];
+ $cut = ($result['size'] ?? 0) > ScannerCommands::OUTPUT_LIMIT || strlen($payload) > ScannerCommands::OUTPUT_LIMIT;
+ if ($cut) {
+ $payload = substr($payload, 0, ScannerCommands::OUTPUT_LIMIT);
+ $payload = substr($payload, 0, (int) strrpos($payload, "\n"));
+ }
+
+ return [$payload, $cut];
+ }
+
+ /**
+ * Writes the result on the current row and recomputes its summary: a better candidate makes findings of images
+ * the user does not build fixable now (spec 7.5).
+ *
+ * @param array{status: string, error: ?string, entries: list>, tags_truncated: bool} $result
+ * @param array{status: string, error: ?string, entries: list>, tags_truncated: bool,
+ * attributable: ?int, attributable_ids: list, current_digest: ?string}|null $base
+ */
+ private function store(ImageScan $scan, array $result, ?array $base): void
+ {
+ $fresh = ImageScan::query()->find($scan->id);
+ if (! $fresh instanceof ImageScan || ! $scan->resource instanceof Model) {
+ return;
+ }
+
+ $checkedAt = now()->toIso8601String();
+ $fresh->candidates = [
+ 'status' => $result['status'],
+ 'checked_at' => $checkedAt,
+ 'error' => $result['error'],
+ 'tags_truncated' => $result['tags_truncated'],
+ 'entries' => $result['entries'],
+ ...($base === null ? [] : ['base' => [
+ 'image' => (string) $scan->base_image_ref,
+ 'status' => $base['status'],
+ 'checked_at' => $checkedAt,
+ 'error' => $base['error'],
+ 'tags_truncated' => $base['tags_truncated'],
+ 'attributable' => $base['attributable'],
+ 'attributable_ids' => $base['attributable_ids'],
+ 'current_digest' => $base['current_digest'],
+ 'entries' => $base['entries'],
+ ]]),
+ ];
+ $fresh->setRelation('resource', $scan->resource);
+ ResourceScans::refreshSummaries(collect([$fresh]));
+
+ // The alert for a scan waits for its version check, which can also bring a fix (spec 15.8).
+ try {
+ ScanNotifications::afterScan($fresh, candidateCheck: true);
+ } catch (Throwable $e) {
+ report($e);
+ }
+
+ self::forgetQueued($fresh);
+ $teamId = method_exists($scan->resource, 'team') ? $scan->resource->team()?->id : null;
+ ImageScanUpdated::dispatch($teamId, (string) $scan->resource->getAttribute('uuid'), (string) $fresh->uuid);
+ }
+}
diff --git a/app/Actions/ImageScan/ScanImage.php b/app/Actions/ImageScan/ScanImage.php
new file mode 100644
index 0000000000..08ce3c37ab
--- /dev/null
+++ b/app/Actions/ImageScan/ScanImage.php
@@ -0,0 +1,986 @@
+runId = Str::lower(Str::random(24));
+ $this->pullWhenMissing = $pullWhenMissing;
+ $this->startedAt = CarbonImmutable::now();
+ $policy = ImageScanPolicyResolver::for($resource, $server);
+ $context = ['server' => (string) $server->name];
+
+ if ($server->isTransferredAway() || ! $policy->scanEnabled) {
+ return $this->unsavedFailure($server, $resource, $imageRef, null, ImageScanErrorCode::ScanningOff, $context);
+ }
+
+ try {
+ ImageRef::parse($imageRef);
+ } catch (InvalidArgumentException) {
+ return $this->failBeforeRow($server, $resource, $imageRef, null, ImageScanErrorCode::Unknown, $context);
+ }
+
+ $lock = self::serverLock($server);
+ if (! self::acquireServerLock($lock, $server, $lockWaitSeconds)) {
+ return $retryWhenBusy
+ ? $this->unsavedFailure($server, $resource, $imageRef, null, ImageScanErrorCode::LockBusy, $context)
+ : $this->failBeforeRow($server, $resource, $imageRef, null, ImageScanErrorCode::LockBusy, $context);
+ }
+
+ try {
+ $scan = $this->scan($server, $resource, $imageRef, $deployment, $policy, $context, self::validBaseImageRef($baseImageRef));
+ } finally {
+ $lock->release();
+ }
+
+ $this->notifyAfterScan($scan, $deployment?->id);
+ if ($queueCandidateCheck) {
+ $this->queueCandidateCheck($scan);
+ }
+
+ return $scan;
+ }
+
+ /**
+ * Records what the notifications covered and alerts for new problems that attackers are using or are likely to
+ * use (spec 15.8). The alert is a queued notification, so a deploy never waits for it.
+ */
+ private function notifyAfterScan(ImageScan $scan, ?int $deploymentId = null): void
+ {
+ try {
+ ScanNotifications::afterScan($scan, $deploymentId);
+ } catch (Throwable $e) {
+ report($e);
+ }
+ }
+
+ /**
+ * Looks for a newer version that fixes the findings in a queued job, after the lock is released, so a deploy never waits for it (spec 7.1).
+ */
+ private function queueCandidateCheck(ImageScan $scan): void
+ {
+ try {
+ if (RefreshCandidates::shouldRefresh($scan)) {
+ RefreshCandidatesJob::queueCheck($scan);
+ }
+ } catch (Throwable $e) {
+ report($e);
+ }
+ }
+
+ /**
+ * Rescans one local image for every resource that runs it on the server (spec 4.5): one SBOM and one Grype run,
+ * then the row of each resource, with its own summary. Never throws for scan problems; a failure is recorded per
+ * resource like in handle(), and completed rows keep their results.
+ *
+ * @param list $targets Each resource with the image ref of its container.
+ * @param bool $retryWhenBusy The caller retries later when the lock is busy: a busy lock is not recorded as the last error.
+ * @return list
+ */
+ public function rescan(Server $server, string $imageId, array $targets, int $lockWaitSeconds = 0, bool $retryWhenBusy = false): array
+ {
+ $this->runId = Str::lower(Str::random(24));
+ $this->startedAt = CarbonImmutable::now();
+ $context = ['server' => (string) $server->name];
+
+ if ($server->isTransferredAway()) {
+ return [];
+ }
+
+ $targets = array_values(array_filter(
+ array_map(fn (array $target): array => [...$target, ImageScanPolicyResolver::for($target[0], $server)], $targets),
+ fn (array $target): bool => $target[2]->scanEnabled,
+ ));
+ if ($targets === []) {
+ return [];
+ }
+
+ if (preg_match('/\Asha256:[a-f0-9]{64}\z/', $imageId) !== 1) {
+ return array_map(fn (array $target) => $this->failBeforeRow($server, $target[0], $target[1], null, ImageScanErrorCode::Unknown, $context), $targets);
+ }
+
+ $lock = self::serverLock($server);
+ if (! self::acquireServerLock($lock, $server, $lockWaitSeconds)) {
+ return array_map(fn (array $target) => $retryWhenBusy
+ ? $this->unsavedFailure($server, $target[0], $target[1], $imageId, ImageScanErrorCode::LockBusy, $context)
+ : $this->failBeforeRow($server, $target[0], $target[1], $imageId, ImageScanErrorCode::LockBusy, $context), $targets);
+ }
+
+ try {
+ $scans = $this->scanShared($server, $imageId, $targets, $context);
+ } finally {
+ $lock->release();
+ }
+
+ foreach ($scans as $scan) {
+ $this->notifyAfterScan($scan);
+ }
+
+ return $scans;
+ }
+
+ /**
+ * One scan or scan data change at a time per server (spec 4.1).
+ */
+ public static function serverLock(Server $server): Lock
+ {
+ return Cache::lock("image-scan:{$server->id}", self::LOCK_TTL, Str::random(40));
+ }
+
+ /**
+ * Takes the server lock. A deploy gate ($lockWaitSeconds > 0) that has to wait marks it with
+ * `gateWaitingCacheKey()`, so background scans leave the lock to it. A background run ($lockWaitSeconds 0) tries
+ * once and does not take the lock while a gate waits; it retries later.
+ */
+ public static function acquireServerLock(Lock $lock, Server $server, int $lockWaitSeconds): bool
+ {
+ if ($lockWaitSeconds <= 0) {
+ return ! self::isGateWaiting($server) && (bool) $lock->get();
+ }
+
+ if ($lock->get()) {
+ return true;
+ }
+
+ Cache::put(self::gateWaitingCacheKey($server), true, $lockWaitSeconds + 5);
+ try {
+ return (bool) $lock->block($lockWaitSeconds);
+ } catch (LockTimeoutException) {
+ return false;
+ } finally {
+ Cache::forget(self::gateWaitingCacheKey($server));
+ }
+ }
+
+ public static function gateWaitingCacheKey(Server $server): string
+ {
+ return "image-scan:gate-waiting:{$server->id}";
+ }
+
+ /**
+ * A deploy gate waits for the server lock.
+ */
+ public static function isGateWaiting(Server $server): bool
+ {
+ return Cache::has(self::gateWaitingCacheKey($server));
+ }
+
+ /**
+ * The scan could not run because the server lock was busy and the caller retries later (see `$retryWhenBusy`).
+ */
+ public static function lockWasBusy(ImageScan $scan): bool
+ {
+ return ! $scan->exists && $scan->status === ImageScanStatus::Failed && $scan->error_code === ImageScanErrorCode::LockBusy;
+ }
+
+ public static function progressCacheKey(Model $resource): string
+ {
+ return "image-scan:progress:{$resource->getMorphClass()}:{$resource->getKey()}";
+ }
+
+ public static function lastErrorCacheKey(Model $resource): string
+ {
+ return "image-scan:last-error:{$resource->getMorphClass()}:{$resource->getKey()}";
+ }
+
+ /**
+ * A base image reference that ImageRef accepts, else null.
+ */
+ private static function validBaseImageRef(?string $ref): ?string
+ {
+ if ($ref === null || mb_strlen($ref) > 512) {
+ return null;
+ }
+
+ try {
+ ImageRef::parse($ref);
+ } catch (InvalidArgumentException) {
+ return null;
+ }
+
+ return $ref;
+ }
+
+ /**
+ * @param array{server: string} $context
+ */
+ private function scan(Server $server, Model $resource, string $imageRef, ?ApplicationDeploymentQueue $deployment, ResolvedImageScanPolicy $policy, array $context, ?string $baseImageRef = null): ImageScan
+ {
+ $commands = new ScannerCommands($server, $this->runId);
+ $isBaseline = ! $this->resourceScans($resource)->where('status', ImageScanStatus::Completed)->exists();
+ $image = null;
+
+ try {
+ $this->progress($resource, 'database');
+ $databaseBuiltAt = $this->databaseBuiltAt($commands);
+ $this->progress($resource, 'inspect');
+ $image = $this->inspectOrPull($commands, $resource, $imageRef);
+ $this->progress($resource, 'sbom');
+ [$sbom, $grype] = $this->sbomAndReport($commands, [$resource], $imageRef, $image);
+
+ return $this->complete($server, $resource, $imageRef, $deployment, $policy, $sbom, $this->report($grype), $image, $databaseBuiltAt, $isBaseline, $baseImageRef);
+ } catch (ScanFailed $e) {
+ $errorContext = [...$context, ...$e->context];
+
+ return $image === null
+ ? $this->failBeforeRow($server, $resource, $imageRef, null, $e->errorCode, $errorContext)
+ : $this->failAfterInspect($server, $resource, $imageRef, $deployment, $image, $e->errorCode, $errorContext);
+ } catch (Throwable $e) {
+ $this->rememberError($resource, ImageScanErrorCode::Unknown, $context);
+
+ throw $e;
+ } finally {
+ $this->forgetProgress($resource);
+ instant_remote_process($commands->cleanup(), $server, throwError: false, timeout: self::TIMEOUT_DEFAULT);
+ }
+ }
+
+ /**
+ * @param list $targets
+ * @param array{server: string} $context
+ * @return list
+ */
+ private function scanShared(Server $server, string $imageId, array $targets, array $context): array
+ {
+ $commands = new ScannerCommands($server, $this->runId);
+ $resources = array_map(fn (array $target): Model => $target[0], $targets);
+ $isBaseline = array_map(fn (Model $resource): bool => ! $this->resourceScans($resource)->where('status', ImageScanStatus::Completed)->exists(), $resources);
+ $image = null;
+
+ try {
+ $this->progressAll($resources, 'database');
+ $databaseBuiltAt = $this->databaseBuiltAt($commands);
+ $this->progressAll($resources, 'inspect');
+ // By ID, not by a tag that can point to another image by now.
+ $image = $this->inspect($commands, $imageId);
+ if ($image['id'] !== $imageId) {
+ $image = null;
+
+ throw new ScanFailed(ImageScanErrorCode::ImageMissing);
+ }
+ $this->progressAll($resources, 'sbom');
+ [$sbom, $grype] = $this->sbomAndReport($commands, $resources, $imageId, $image);
+ $report = $this->report($grype);
+
+ return array_map(
+ fn (array $target, int $index) => $this->complete($server, $target[0], $target[1], null, $target[2], $sbom, $report, $image, $databaseBuiltAt, $isBaseline[$index]),
+ $targets,
+ array_keys($targets),
+ );
+ } catch (ScanFailed $e) {
+ $errorContext = [...$context, ...$e->context];
+
+ return array_map(fn (array $target) => $image === null
+ ? $this->failBeforeRow($server, $target[0], $target[1], null, $e->errorCode, $errorContext)
+ : $this->failAfterInspect($server, $target[0], $target[1], null, $image, $e->errorCode, $errorContext), $targets);
+ } catch (Throwable $e) {
+ foreach ($resources as $resource) {
+ $this->rememberError($resource, ImageScanErrorCode::Unknown, $context);
+ }
+
+ throw $e;
+ } finally {
+ foreach ($resources as $resource) {
+ $this->forgetProgress($resource);
+ }
+ instant_remote_process($commands->cleanup(), $server, throwError: false, timeout: self::TIMEOUT_DEFAULT);
+ }
+ }
+
+ /**
+ * @return Builder
+ */
+ private function resourceScans(Model $resource): Builder
+ {
+ return ImageScan::query()
+ ->where('resource_type', $resource->getMorphClass())
+ ->where('resource_id', $resource->getKey());
+ }
+
+ private function progress(Model $resource, string $stage): void
+ {
+ Cache::put(self::progressCacheKey($resource), [
+ 'run_id' => $this->runId,
+ 'stage' => $stage,
+ 'started_at' => $this->startedAt->toIso8601String(),
+ ], self::LOCK_TTL);
+ $this->broadcast($resource, $this->runId, final: false);
+ }
+
+ /**
+ * @param list $resources
+ */
+ private function progressAll(array $resources, string $stage): void
+ {
+ foreach ($resources as $resource) {
+ $this->progress($resource, $stage);
+ }
+ }
+
+ /**
+ * Updates a missing database once. Grype refuses a database older than 5 days.
+ */
+ private function databaseBuiltAt(ScannerCommands $commands): CarbonImmutable
+ {
+ $builtAt = $this->databaseStatus($commands);
+
+ if ($builtAt === null) {
+ $this->updateDatabase($commands);
+ $builtAt = $this->databaseStatus($commands) ?? throw new ScanFailed(ImageScanErrorCode::DbMissing);
+ }
+
+ if ($builtAt->lt(now()->subDays(self::DB_MAX_AGE_DAYS))) {
+ throw new ScanFailed(ImageScanErrorCode::DbTooOld, ['n' => (int) floor($builtAt->diffInDays(now()))]);
+ }
+
+ return $builtAt;
+ }
+
+ private function updateDatabase(ScannerCommands $commands): void
+ {
+ $url = InstanceSettings::get()->image_scan_db_url;
+
+ try {
+ $update = $commands->dbUpdate(is_string($url) && trim($url) !== '' ? trim($url) : null);
+ } catch (InvalidArgumentException) {
+ return;
+ }
+
+ instant_remote_process($update, $commands->server, throwError: false, timeout: self::TIMEOUT_DB_UPDATE);
+ }
+
+ private function databaseStatus(ScannerCommands $commands): ?CarbonImmutable
+ {
+ $result = $this->runStep($commands->server, $commands->dbStatus(), 'db-status', self::TIMEOUT_DEFAULT);
+ if (in_array($result['code'], self::EXIT_TIMEOUT, true)) {
+ throw new ScanFailed(ImageScanErrorCode::Timeout);
+ }
+
+ $status = $result['code'] === 0 ? json_decode($result['payload'], true) : null;
+ $built = is_array($status) && ($status['valid'] ?? false) === true ? ($status['built'] ?? null) : null;
+
+ if (! is_string($built) || preg_match('/\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})\z/', $built) !== 1) {
+ return null;
+ }
+
+ return CarbonImmutable::parse($built);
+ }
+
+ /**
+ * Inspects the image. With $pullWhenMissing, an image that is not on the server and that Coolify does not build is
+ * pulled first.
+ *
+ * @return array{id: string, hex: string, repo_digest: ?string, platform: string, size: int}
+ */
+ private function inspectOrPull(ScannerCommands $commands, Model $resource, string $imageRef): array
+ {
+ try {
+ return $this->inspect($commands, $imageRef);
+ } catch (ScanFailed $e) {
+ if ($e->errorCode !== ImageScanErrorCode::ImageMissing || ! $this->pullWhenMissing || self::buildsImage($resource, $imageRef)) {
+ throw $e;
+ }
+ }
+
+ $this->progress($resource, 'pull');
+ $result = $this->runStep($commands->server, $commands->pull($imageRef), 'pull', self::TIMEOUT_PULL);
+ if ($result['code'] !== 0) {
+ throw new ScanFailed(in_array($result['code'], self::EXIT_TIMEOUT, true) ? ImageScanErrorCode::Timeout : ImageScanErrorCode::PullFailed);
+ }
+ $this->progress($resource, 'inspect');
+
+ return $this->inspect($commands, $imageRef);
+ }
+
+ /**
+ * @return array{id: string, hex: string, repo_digest: ?string, platform: string, size: int}
+ */
+ private function inspect(ScannerCommands $commands, string $imageRef): array
+ {
+ $result = $this->runStep($commands->server, $commands->inspect($imageRef), 'inspect', self::TIMEOUT_DEFAULT);
+ if ($result['code'] !== 0) {
+ throw new ScanFailed(ImageScanErrorCode::ImageMissing);
+ }
+
+ $parts = explode('|', trim($result['payload']));
+ if (count($parts) !== 4
+ || preg_match('/\Asha256:([a-f0-9]{64})\z/', $parts[0], $id) !== 1
+ || preg_match('#\A[a-z0-9]+/[a-z0-9_]+\z#', $parts[2]) !== 1
+ || preg_match('/\A\d{1,18}\z/', $parts[3]) !== 1) {
+ throw new ScanFailed(ImageScanErrorCode::Unknown);
+ }
+
+ $repoDigest = Str::afterLast($parts[1], '@');
+
+ return [
+ 'id' => $parts[0],
+ 'hex' => $id[1],
+ 'repo_digest' => str_contains($parts[1], '@') && ImageRef::isValidDigest($repoDigest) ? $repoDigest : null,
+ 'platform' => $parts[2],
+ 'size' => (int) $parts[3],
+ ];
+ }
+
+ /**
+ * `docker save` and the SBOM need image size × 2 + 1 GiB on the Docker root and on the scan directory.
+ * Candidate scans from a registry use the same rule (Syft writes the layers to the scratch directory).
+ *
+ * @throws ScanFailed
+ */
+ public static function checkDiskSpace(ScannerCommands $commands, int $imageSize): void
+ {
+ $output = instant_remote_process($commands->diskFree(), $commands->server, throwError: false, timeout: self::TIMEOUT_DEFAULT);
+ if (preg_match('/(\d+) +(\d+)\s*\z/', (string) $output, $free) !== 1) {
+ throw new ScanFailed(ImageScanErrorCode::Unknown);
+ }
+
+ $needed = $imageSize * 2 + self::DISK_RESERVE_BYTES;
+ if ((int) $free[1] < $needed || (int) $free[2] < $needed) {
+ throw new ScanFailed(ImageScanErrorCode::DiskSpace, ['n' => (int) ceil($needed / self::DISK_RESERVE_BYTES)]);
+ }
+ }
+
+ /**
+ * Reuses the SBOM of the image on this server. When its file is gone, Grype cannot mount it: the file is
+ * created once more and the row is updated in place (scans of other resources keep their link).
+ *
+ * @param list $resources
+ * @param array{id: string, hex: string, repo_digest: ?string, platform: string, size: int} $image
+ * @return array{0: ImageSbom, 1: array{code: int, size: ?int, payload: string, exit_codes: array, oom: list}}
+ */
+ private function sbomAndReport(ScannerCommands $commands, array $resources, string $imageRef, array $image): array
+ {
+ $sbom = ImageSbom::query()->where('server_id', $commands->server->id)->where('image_id', $image['id'])->first();
+
+ if ($sbom !== null) {
+ $this->progressAll($resources, 'vulnerabilities');
+ $grype = $this->runStep($commands->server, $commands->grype($image['hex']), 'grype', self::TIMEOUT_GRYPE);
+
+ if ($grype['code'] !== self::EXIT_DOCKER_RUN) {
+ $this->assertSucceeded($grype, 'grype');
+ $sbom->forceFill(['last_used_at' => now()])->save();
+
+ return [$sbom, $grype];
+ }
+
+ $this->progressAll($resources, 'sbom');
+ }
+
+ $sbom = $this->createSbom($commands, $imageRef, $image, $sbom);
+ $this->progressAll($resources, 'vulnerabilities');
+ $grype = $this->runStep($commands->server, $commands->grype($image['hex']), 'grype', self::TIMEOUT_GRYPE);
+ $this->assertSucceeded($grype, 'grype');
+
+ return [$sbom, $grype];
+ }
+
+ /**
+ * @param array{id: string, hex: string, repo_digest: ?string, platform: string, size: int} $image
+ */
+ private function createSbom(ScannerCommands $commands, string $imageRef, array $image, ?ImageSbom $sbom): ImageSbom
+ {
+ self::checkDiskSpace($commands, $image['size']);
+
+ $result = $this->runStep($commands->server, $commands->sbomFromLocal($imageRef, $image['hex']), 'save', self::TIMEOUT_SBOM);
+ // `docker save` has no memory limit: its 137 is always a timeout kill.
+ $this->assertSucceeded($result, 'save');
+ $this->assertSucceeded($result, 'sbom');
+
+ $packages = $this->runStep($commands->server, $commands->packages($image['hex']), 'packages', self::TIMEOUT_GRYPE);
+ $this->assertSucceeded($packages, 'packages');
+ [$payload, $cut] = $this->readPayload($packages);
+ $parsed = (new SyftPackageParser)->parse($payload);
+
+ $values = [
+ 'platform' => $image['platform'],
+ 'packages' => array_map(fn (Package $package) => $package->toArray(), $parsed['packages']),
+ 'packages_truncated' => $parsed['truncated'] || $cut,
+ 'last_used_at' => now(),
+ ];
+
+ if ($sbom !== null) {
+ $sbom->forceFill($values)->save();
+
+ return $sbom;
+ }
+
+ return ImageSbom::query()->create([
+ 'server_id' => $commands->server->id,
+ 'image_id' => $image['id'],
+ ...$values,
+ ]);
+ }
+
+ /**
+ * Findings of the Grype output, sorted by risk.
+ *
+ * @param array{code: int, size: ?int, payload: string, exit_codes: array, oom: list} $grype
+ * @return array{findings: list, eol: ?array{name: string, version: string}, truncated: bool}
+ */
+ private function report(array $grype): array
+ {
+ [$payload, $cut] = $this->readPayload($grype);
+ $report = (new GrypeReportParser)->parse($payload);
+ $findings = $report['findings'];
+ usort($findings, fn (Finding $a, Finding $b) => ($b->risk ?? 0.0) <=> ($a->risk ?? 0.0));
+
+ return ['findings' => $findings, 'eol' => $report['eol'], 'truncated' => $report['truncated'] || $cut];
+ }
+
+ /**
+ * @param array{findings: list, eol: ?array{name: string, version: string}, truncated: bool} $report
+ * @param array{id: string, hex: string, repo_digest: ?string, platform: string, size: int} $image
+ */
+ private function complete(Server $server, Model $resource, string $imageRef, ?ApplicationDeploymentQueue $deployment, ResolvedImageScanPolicy $policy, ImageSbom $sbom, array $report, array $image, CarbonImmutable $databaseBuiltAt, bool $isBaseline, ?string $baseImageRef = null): ImageScan
+ {
+ $findings = $report['findings'];
+
+ $buildsImage = self::buildsImage($resource, $imageRef);
+ $existing = $this->resourceScans($resource)->where('image_id', $image['id'])->with('deploymentQueue:id,pull_request_id')->first();
+ $summary = ResourceScans::summaryFor($findings, IgnoreMatcher::forResource($resource), $report['eol'], $report['truncated']);
+
+ $scan = $this->upsert($resource, $image['id'], [
+ 'server_id' => $server->id,
+ 'image_sbom_id' => $sbom->id,
+ 'repo_digest' => $image['repo_digest'],
+ 'image_ref' => mb_substr($imageRef, 0, 512),
+ ...$this->deploymentLink($resource, $existing, $deployment),
+ ...($baseImageRef !== null ? ['base_image_ref' => $baseImageRef] : []),
+ 'status' => ImageScanStatus::Completed,
+ 'error_code' => null,
+ 'error_context' => null,
+ 'summary' => $summary,
+ 'findings' => array_map(fn (Finding $finding) => $finding->toArray(), $findings),
+ 'builds_image' => $buildsImage,
+ 'is_baseline' => $isBaseline,
+ 'grype_db_built_at' => $databaseBuiltAt,
+ 'scanned_at' => now(),
+ ]);
+ $scan->setRelation('resource', $resource);
+ SecurityStatus::store($scan);
+ $scan->save();
+
+ Cache::forget(self::lastErrorCacheKey($resource));
+ $this->forgetProgress($resource);
+ $this->broadcast($resource, (string) $scan->uuid);
+
+ return $scan;
+ }
+
+ /**
+ * The deployment a scan row belongs to, which decides the current scans of an application (all images of one
+ * deployment). A scan without a deployment (rescan, Scan now) joins the latest finished deployment of the
+ * application, unless the row belongs to a newer deployment. A preview never takes over the row of the application.
+ *
+ * @return array{deployment_queue_id?: int}
+ */
+ private function deploymentLink(Model $resource, ?ImageScan $existing, ?ApplicationDeploymentQueue $deployment): array
+ {
+ $linked = $existing?->deployment_queue_id;
+ $linkedToPreview = $linked !== null && (int) ($existing->deploymentQueue?->pull_request_id ?? 0) !== 0;
+
+ if ($deployment !== null) {
+ return (int) $deployment->pull_request_id !== 0 && $existing !== null && ! $linkedToPreview
+ ? []
+ : ['deployment_queue_id' => (int) $deployment->id];
+ }
+
+ if (! $resource instanceof Application || $linkedToPreview) {
+ return [];
+ }
+
+ $latest = ApplicationDeploymentQueue::query()
+ ->where('application_id', (string) $resource->getKey())
+ ->where('pull_request_id', 0)
+ ->where('status', ApplicationDeploymentStatus::FINISHED->value)
+ ->max('id');
+
+ return $latest === null || ($linked !== null && (int) $linked >= (int) $latest)
+ ? []
+ : ['deployment_queue_id' => (int) $latest];
+ }
+
+ /**
+ * Creates or updates the row of (resource, image ID). A row that another run inserts at the same time is updated.
+ *
+ * @param array $values
+ */
+ private function upsert(Model $resource, string $imageId, array $values): ImageScan
+ {
+ $scan = $this->firstOrCreateScan($resource, $imageId, $values) ?? throw new ScanFailed(ImageScanErrorCode::Unknown);
+
+ if (! $scan->wasRecentlyCreated) {
+ $scan->forceFill($values)->save();
+ }
+
+ return $scan;
+ }
+
+ /**
+ * `firstOrCreate` catches a unique violation and reads the other row. Its savepoint can roll back that row inside
+ * an outer transaction, so one more attempt is made before giving up.
+ *
+ * @param array $values
+ */
+ private function firstOrCreateScan(Model $resource, string $imageId, array $values): ?ImageScan
+ {
+ $keys = [
+ 'resource_type' => $resource->getMorphClass(),
+ 'resource_id' => $resource->getKey(),
+ 'image_id' => $imageId,
+ ];
+
+ for ($attempt = 0; $attempt < 2; $attempt++) {
+ try {
+ return ImageScan::query()->firstOrCreate($keys, $values);
+ } catch (UniqueConstraintViolationException) {
+ continue;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Keeps an existing row of the image (and its results). Saves a Failed row only for an image without one.
+ *
+ * @param array{id: string, hex: string, repo_digest: ?string, platform: string, size: int} $image
+ * @param array $context
+ */
+ private function failAfterInspect(Server $server, Model $resource, string $imageRef, ?ApplicationDeploymentQueue $deployment, array $image, ImageScanErrorCode $code, array $context): ImageScan
+ {
+ $values = [
+ 'server_id' => $server->id,
+ 'repo_digest' => $image['repo_digest'],
+ 'image_ref' => mb_substr($imageRef, 0, 512),
+ 'deployment_queue_id' => $deployment?->id,
+ 'status' => ImageScanStatus::Failed,
+ 'error_code' => $code,
+ 'error_context' => $context,
+ 'builds_image' => self::buildsImage($resource, $imageRef),
+ ];
+
+ $scan = $this->firstOrCreateScan($resource, $image['id'], $values);
+
+ if ($scan === null || ! $scan->wasRecentlyCreated) {
+ return $this->failBeforeRow($server, $resource, $imageRef, $image['id'], $code, $context, $image['repo_digest']);
+ }
+
+ $scan->setRelation('resource', $resource);
+ $this->rememberError($resource, $code, $context);
+ $this->forgetProgress($resource);
+ $this->broadcast($resource, (string) $scan->uuid);
+
+ return $scan;
+ }
+
+ /**
+ * @param array $context
+ */
+ private function failBeforeRow(Server $server, Model $resource, string $imageRef, ?string $imageId, ImageScanErrorCode $code, array $context, ?string $repoDigest = null): ImageScan
+ {
+ $scan = $this->unsavedFailure($server, $resource, $imageRef, $imageId, $code, $context, $repoDigest);
+
+ // Another run of the resource is scanning: its progress stays, and a busy lock is not its error.
+ $otherRun = data_get(Cache::get(self::progressCacheKey($resource)), 'run_id');
+ $otherRunActive = is_string($otherRun) && $otherRun !== $this->runId;
+ if (! ($otherRunActive && $code === ImageScanErrorCode::LockBusy)) {
+ $this->rememberError($resource, $code, $context);
+ }
+ $this->forgetProgress($resource);
+ $this->broadcast($resource, $this->runId);
+
+ return $scan;
+ }
+
+ /**
+ * Removes the progress of the resource only when it is the progress of this run.
+ */
+ private function forgetProgress(Model $resource): void
+ {
+ if (data_get(Cache::get(self::progressCacheKey($resource)), 'run_id') === $this->runId) {
+ Cache::forget(self::progressCacheKey($resource));
+ }
+ }
+
+ /**
+ * @param array $context
+ */
+ private function unsavedFailure(Server $server, Model $resource, string $imageRef, ?string $imageId, ImageScanErrorCode $code, array $context, ?string $repoDigest = null): ImageScan
+ {
+ $scan = (new ImageScan)->forceFill([
+ 'server_id' => $server->id,
+ 'resource_type' => $resource->getMorphClass(),
+ 'resource_id' => $resource->getKey(),
+ 'image_id' => $imageId,
+ 'repo_digest' => $repoDigest,
+ 'image_ref' => mb_substr($imageRef, 0, 512),
+ 'status' => ImageScanStatus::Failed,
+ 'error_code' => $code,
+ 'error_context' => $context,
+ ]);
+ $scan->setRelation('resource', $resource);
+
+ return $scan;
+ }
+
+ /**
+ * @param array $context
+ */
+ private function rememberError(Model $resource, ImageScanErrorCode $code, array $context): void
+ {
+ Cache::put(self::lastErrorCacheKey($resource), [
+ 'code' => $code->value,
+ 'context' => $context,
+ 'at' => now()->toIso8601String(),
+ ], now()->addDays(self::LAST_ERROR_TTL_DAYS));
+ }
+
+ /**
+ * Whether the team builds the image: Coolify builds the images of Dockerfile, Nixpacks, Railpack, and static apps,
+ * and the Compose images of a service with `build` (see composeBuildsImage). Docker images of the team are built by
+ * the team unless it says otherwise; Docker Hub official images are not. Other images are pulled.
+ */
+ public static function buildsImage(Model $resource, string $imageRef): bool
+ {
+ if (! $resource instanceof Application) {
+ return false;
+ }
+ if ($resource->build_pack === 'dockercompose') {
+ return self::composeBuildsImage($resource, $imageRef);
+ }
+ if ($resource->build_pack !== 'dockerimage') {
+ return true;
+ }
+
+ $buildsByTeam = $resource->settings?->image_builds_by_team;
+ if ($buildsByTeam !== null) {
+ return (bool) $buildsByTeam;
+ }
+
+ try {
+ return ! ImageRef::parse($imageRef)->isDockerOfficial();
+ } catch (InvalidArgumentException) {
+ return true;
+ }
+ }
+
+ /**
+ * A Compose image is built by Coolify when its name is `{app uuid}_{service}` (the name the parser gives a service
+ * with `build` and no `image`), or when a service with `build` in the app's Compose file names the same image
+ * (tag ignored). Every other image of the file is pulled.
+ */
+ private static function composeBuildsImage(Application $application, string $imageRef): bool
+ {
+ $name = self::imageName($imageRef);
+ if (filled($application->uuid) && str_starts_with($name, strtolower($application->uuid).'_')) {
+ return true;
+ }
+
+ try {
+ $compose = Yaml::parse((string) ($application->docker_compose ?: $application->docker_compose_raw));
+ } catch (ParseException) {
+ return false;
+ }
+
+ foreach ((array) data_get($compose, 'services', []) as $service) {
+ $image = data_get($service, 'image');
+ if (data_get($service, 'build') !== null && is_string($image) && $image !== '' && self::imageName($image) === $name) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * The image reference without tag and digest, lowercase.
+ */
+ public static function imageName(string $imageRef): string
+ {
+ $name = strtolower(trim(explode('@', $imageRef, 2)[0]));
+
+ return (string) preg_replace('#:[^/]*$#', '', $name);
+ }
+
+ /**
+ * Runs one command batch and reads the exit code of $step. No output or no marker means the batch did not run.
+ *
+ * @param list $commands
+ * @return array{code: int, size: ?int, payload: string, exit_codes: array, oom: list}
+ */
+ private function runStep(Server $server, array $commands, string $step, int $timeout): array
+ {
+ $output = instant_remote_process($commands, $server, throwError: false, timeout: $timeout);
+ $parsed = ScannerCommands::parseOutput($output);
+
+ if ($output === null || trim($output) === '' || ! array_key_exists($step, $parsed['exit_codes'])) {
+ throw new ScanFailed(ImageScanErrorCode::Unknown);
+ }
+
+ return [
+ 'code' => $parsed['exit_codes'][$step],
+ 'size' => $parsed['sizes'][$step] ?? null,
+ 'payload' => $parsed['payload'],
+ 'exit_codes' => $parsed['exit_codes'],
+ 'oom' => $parsed['oom'],
+ ];
+ }
+
+ /**
+ * Throws for a failed $step of the batch. Only Docker's own `OOMKilled` (the `coolify-scan-oom` line of the
+ * memory-limited Syft and Grype containers) means out of memory; a 137 without it was killed by `timeout`.
+ *
+ * @param array{code: int, size: ?int, payload: string, exit_codes: array, oom: list} $result
+ */
+ private function assertSucceeded(array $result, string $step): void
+ {
+ $code = $result['exit_codes'][$step] ?? null;
+ if ($code !== 0 && $step !== 'save' && in_array($step, $result['oom'], true)) {
+ throw new ScanFailed(ImageScanErrorCode::OutOfMemory);
+ }
+ if (in_array($code, self::EXIT_TIMEOUT, true)) {
+ throw new ScanFailed(ImageScanErrorCode::Timeout);
+ }
+ if ($code !== 0) {
+ throw new ScanFailed(ImageScanErrorCode::Unknown);
+ }
+ }
+
+ /**
+ * Output over the limit was cut by `head -c`: keep only the complete lines that were read.
+ *
+ * @param array{code: int, size: ?int, payload: string, exit_codes: array, oom: list} $result
+ * @return array{0: string, 1: bool}
+ */
+ private function readPayload(array $result): array
+ {
+ $payload = $result['payload'];
+ $cut = ($result['size'] ?? 0) > ScannerCommands::OUTPUT_LIMIT || strlen($payload) > ScannerCommands::OUTPUT_LIMIT;
+
+ if ($cut) {
+ $payload = substr($payload, 0, ScannerCommands::OUTPUT_LIMIT);
+ $lastNewline = strrpos($payload, "\n");
+ $payload = $lastNewline === false ? '' : substr($payload, 0, $lastNewline);
+ }
+
+ return [$payload, $cut];
+ }
+
+ /**
+ * @param bool $final False for a progress update while the scan runs.
+ */
+ private function broadcast(Model $resource, string $scanUuid, bool $final = true): void
+ {
+ $teamId = method_exists($resource, 'team') ? $resource->team()?->id : null;
+
+ ImageScanUpdated::dispatch($teamId, (string) $resource->getAttribute('uuid'), $scanUuid, $final);
+ }
+}
diff --git a/app/Actions/ImageScan/SetServerImageScanning.php b/app/Actions/ImageScan/SetServerImageScanning.php
new file mode 100644
index 0000000000..351da4f1a8
--- /dev/null
+++ b/app/Actions/ImageScan/SetServerImageScanning.php
@@ -0,0 +1,55 @@
+settings->update(['is_image_scan_enabled' => false]);
+ PrepareImageScannerJob::forget($server);
+
+ return;
+ }
+
+ self::ensureManagedHere($server);
+ if (! InstanceSettings::get()->is_image_scanning_allowed) {
+ throw new ImageScanningRefused('Image scanning is turned off for this Coolify instance.');
+ }
+
+ $server->settings->update(['is_image_scan_enabled' => true]);
+ self::prepareScanner($server);
+ }
+
+ /**
+ * Gets the scanner on the server again, for a server that scans images.
+ */
+ public static function prepareScanner(Server $server): void
+ {
+ Cache::put(PrepareImageScannerJob::stateCacheKey($server), PrepareImageScannerJob::STATE_PULLING, now()->addHour());
+ PrepareImageScannerJob::dispatch($server);
+ }
+
+ public static function ensureManagedHere(Server $server): void
+ {
+ if ($server->isTransferredAway()) {
+ throw new ImageScanningRefused('This server is managed by another Coolify instance.');
+ }
+ }
+}
diff --git a/app/Actions/ImageScan/UpdateResourceImage.php b/app/Actions/ImageScan/UpdateResourceImage.php
new file mode 100644
index 0000000000..68412f5e9a
--- /dev/null
+++ b/app/Actions/ImageScan/UpdateResourceImage.php
@@ -0,0 +1,800 @@
+ $resource,
+ $resource instanceof ServiceApplication, $resource instanceof ServiceDatabase => $resource->service,
+ default => null,
+ };
+
+ $this->authorizeFor($service ?? $resource, $by, (int) $resource->getKey() === 0);
+
+ if ($resource instanceof Application && ! in_array($resource->build_pack, ['dockerimage', 'dockercompose'], true)) {
+ throw new ImageUpdateRefused('Coolify builds the image of this app, so there is no image to update.');
+ }
+ if (! $resource instanceof Application && $service === null && ! in_array($resource::class, STANDALONE_DATABASE_MODELS, true)) {
+ throw new ImageUpdateRefused('Coolify cannot update the image of this resource.');
+ }
+
+ $owners = match (true) {
+ $resource instanceof Service => ResourceScans::owners($resource),
+ default => collect([$resource]),
+ };
+ [$owner, $scan, $candidate] = $this->candidate($owners, $newRef);
+
+ $oldRef = (string) $scan->image_ref;
+ $change = $this->change($owner, $scan, $candidate, $newRef);
+
+ $updated = match (true) {
+ $service !== null => $this->updateService($service, [$change]),
+ $owner instanceof Application && $owner->build_pack === 'dockercompose' => $this->updateComposeApplication($owner, [$change]),
+ $owner instanceof Application => $this->updateApplication($owner, $change),
+ default => $this->updateDatabase($owner, $change),
+ };
+
+ $this->audit($service ?? $owner, $by, $updated, [['old_ref' => $oldRef, 'new_ref' => $newRef, 'kind' => $candidate['kind']]]);
+ }
+
+ /**
+ * Updates several images of one service in one change and one restart: the parts of a merged update ("Update n8n
+ * and n8n runners to 2.10.4."). Each part must be a better candidate of the current scan of its own application
+ * or database. When the restart fails, every image and the compose file are put back.
+ *
+ * @param list $parts
+ *
+ * @throws AuthorizationException When the user may not update the service, or the service belongs to Coolify (id 0).
+ * @throws ImageUpdateRefused When a part is not a better candidate, or the update cannot start.
+ */
+ public function handleParts(Service $service, array $parts, User $by): void
+ {
+ $this->authorizeFor($service, $by, false);
+
+ if ($parts === []) {
+ throw new ImageUpdateRefused('This update has no images.');
+ }
+
+ $owners = ResourceScans::owners($service);
+ $changes = [];
+ $log = [];
+ foreach ($parts as $part) {
+ $owner = $owners->first(fn (Model $candidate): bool => $candidate->getMorphClass() === $part['owner']->getMorphClass()
+ && (string) $candidate->getKey() === (string) $part['owner']->getKey());
+ if (! $owner instanceof Model) {
+ throw new ImageUpdateRefused('An image of this update does not belong to this service.');
+ }
+
+ [, $scan, $candidate] = $this->candidate(collect([$owner]), $part['ref']);
+ $changes[] = $this->change($owner, $scan, $candidate, $part['ref']);
+ $log[] = ['old_ref' => (string) $scan->image_ref, 'new_ref' => $part['ref'], 'kind' => $candidate['kind']];
+ }
+
+ $updated = $this->updateService($service, $changes);
+
+ $this->audit($service, $by, $updated, $log);
+ }
+
+ /**
+ * @throws AuthorizationException
+ * @throws ImageUpdateRefused When another Coolify instance manages the server now.
+ */
+ private function authorizeFor(Model $target, User $by, bool $isCoolifyOwner): void
+ {
+ Gate::forUser($by)->authorize('update', $target);
+
+ if ($isCoolifyOwner || (int) $target->getKey() === 0) {
+ throw new AuthorizationException('Coolify does not change the images of its own resources.');
+ }
+
+ // Another Coolify instance manages a transferred server; this one keeps only an archive of it.
+ $server = $target instanceof Service ? $target->server : $target->getAttribute('destination')?->server;
+ if ($server instanceof Server && $server->isTransferredAway()) {
+ throw new ImageUpdateRefused('Another Coolify instance manages this server now.');
+ }
+ }
+
+ /**
+ * @param list $updated
+ * @param non-empty-list $changes
+ */
+ private function audit(Model $target, User $by, array $updated, array $changes): void
+ {
+ auditLog('ui.image_scan.image_updated', [
+ 'team_id' => $target->team()?->id,
+ 'resource_type' => $target->getMorphClass(),
+ 'resource_uuid' => (string) $target->getAttribute('uuid'),
+ 'resource_name' => (string) $target->getAttribute('name'),
+ 'updated_uuids' => $updated,
+ 'old_ref' => $changes[0]['old_ref'],
+ 'new_ref' => $changes[0]['new_ref'],
+ 'kind' => $changes[0]['kind'],
+ ...(count($changes) > 1 ? ['changes' => $changes] : []),
+ 'user_id' => $by->id,
+ 'actor_id' => $by->id,
+ 'actor_name' => $by->name,
+ 'actor_email' => $by->email,
+ ]);
+ }
+
+ /**
+ * The owner, its current scan, and the candidate entry that matches the ref.
+ *
+ * @param Collection $owners
+ * @return array{0: Model, 1: ImageScan, 2: array}
+ */
+ private function candidate(Collection $owners, string $newRef): array
+ {
+ foreach ($owners as $owner) {
+ foreach (ResourceScans::currentFor($owner) as $scan) {
+ foreach ($scan->updateCandidates() as $entry) {
+ if ($entry['ref'] !== $newRef) {
+ continue;
+ }
+ $blocked = self::blockedReason($scan, $owner);
+ if ($blocked !== null) {
+ throw new ImageUpdateRefused($blocked);
+ }
+
+ return [$owner, $scan, $entry];
+ }
+ }
+ }
+
+ throw new ImageUpdateRefused('This version is not one that fixes vulnerabilities of the current image.');
+ }
+
+ /**
+ * Why Coolify cannot switch the image of a scan, even with a better candidate. Null when it can.
+ */
+ public static function blockedReason(ImageScan $scan, Model $owner): ?string
+ {
+ if ($owner instanceof Application && $owner->build_pack === 'dockercompose') {
+ $refused = self::composeRefusal($owner);
+ if ($refused !== null) {
+ return $refused;
+ }
+
+ try {
+ if (self::composeMatches($owner, self::composeServices($owner), $owner->activeComposeImageOverrides(), (string) $scan->image_ref) === []) {
+ return self::NO_COMPOSE_SERVICE;
+ }
+ } catch (ImageUpdateRefused $e) {
+ return $e->getMessage();
+ }
+ }
+
+ $image = (string) $owner->getAttribute('image');
+ if (ComposeImageVariable::hasVariable($image) && ComposeImageVariable::parse($image) === null) {
+ return 'The compose file sets this image in a way Coolify cannot change. Change the version in the compose file.';
+ }
+
+ if (ResourceScans::runsOtherImage($scan, $owner)) {
+ return 'The running image is not the scanned image. Scan it again to check for newer versions.';
+ }
+
+ return null;
+ }
+
+ /**
+ * One image change: the owner, the scanned image, the new tag, and for a newer build of the same tag its digest.
+ *
+ * @param array $candidate
+ * @return ImageChange
+ */
+ private function change(Model $owner, ImageScan $scan, array $candidate, string $newRef): array
+ {
+ return [
+ 'owner' => $owner,
+ 'old' => (string) $scan->image_ref,
+ 'tag' => (string) ImageRef::parse($newRef)->tag,
+ 'same_tag' => $candidate['kind'] === 'same_tag',
+ 'digest' => is_string($candidate['digest'] ?? null) ? $candidate['digest'] : null,
+ ];
+ }
+
+ /**
+ * Waits the scanned build of a newer build of the same tag for the next start of `$starter`.
+ *
+ * @param ImageChange $change
+ */
+ private function pin(Model $starter, array $change): void
+ {
+ if ($change['digest'] === null) {
+ throw new ImageUpdateRefused('Coolify does not know the digest of the newer build. Check for newer versions again.');
+ }
+
+ ImageStartPin::put($starter, $change['old'], $change['digest']);
+ }
+
+ /**
+ * @param ImageChange $change
+ * @return list
+ */
+ private function updateApplication(Application $application, array $change): array
+ {
+ $oldTag = $application->docker_registry_image_tag;
+ if ($change['same_tag']) {
+ $this->pin($application, $change);
+ } else {
+ $application->docker_registry_image_tag = $change['tag'];
+ $application->save();
+ }
+
+ $this->deploy($application, function () use ($application, $oldTag, $change): void {
+ if (! $change['same_tag']) {
+ $application->docker_registry_image_tag = $oldTag;
+ $application->save();
+ }
+ });
+
+ return [(string) $application->uuid];
+ }
+
+ /**
+ * A Compose app from Git: an image override per Compose service, or the environment variable that sets the image,
+ * then a deploy. Refused while the app deploys its compose file as written.
+ *
+ * @param non-empty-list $changes
+ * @return list
+ */
+ private function updateComposeApplication(Application $application, array $changes): array
+ {
+ $refused = self::composeRefusal($application);
+ if ($refused !== null) {
+ throw new ImageUpdateRefused($refused);
+ }
+
+ $services = self::composeServices($application);
+ $oldOverrides = $application->docker_compose_image_overrides;
+ $overrides = $application->activeComposeImageOverrides();
+ $variables = [];
+ $variableServices = [];
+ $pinned = false;
+
+ foreach ($changes as $change) {
+ if ($change['same_tag']) {
+ $this->pin($application, $change);
+ $pinned = true;
+
+ continue;
+ }
+
+ $matches = self::composeMatches($application, $services, $overrides, $change['old']);
+ if ($matches === []) {
+ throw new ImageUpdateRefused(self::NO_COMPOSE_SERVICE);
+ }
+ foreach ($matches as $name => $match) {
+ if ($match['variable'] !== null) {
+ $variables = $this->addVariable($variables, $match['variable']->key, $match['variable']->valueFor($match['current'], $change['tag']));
+ $variableServices[$name] = $match['variable']->key;
+ } else {
+ $overrides[$name] = ['from' => $services[$name], 'to' => $this->withTag($match['current'], $change['tag'])];
+ }
+ }
+ }
+
+ // A variable that also sets the image of a service that this update does not change would change that image too.
+ foreach ($services as $name => $image) {
+ $variable = ComposeImageVariable::parse($image);
+ if ($variable !== null && isset($variables[$variable->key]) && ! isset($variableServices[$name])) {
+ throw new ImageUpdateRefused("The variable {$variable->key} also sets the image of {$name}. Change the versions in the compose file.");
+ }
+ }
+ self::refuseOtherVariableUses((string) $application->docker_compose_raw, $variableServices);
+
+ $application->docker_compose_image_overrides = $overrides === [] ? null : $overrides;
+ $application->save();
+ $restoreVariables = $this->setVariables($application, $variables);
+
+ $this->deploy($application, function () use ($application, $oldOverrides, $restoreVariables, $pinned): void {
+ $application->docker_compose_image_overrides = $oldOverrides;
+ $application->save();
+ $restoreVariables();
+ if ($pinned) {
+ ImageStartPin::forget($application);
+ }
+ });
+
+ return [(string) $application->uuid];
+ }
+
+ /**
+ * Queues a deploy of the app; runs `$restore` and refuses when it cannot start.
+ */
+ private function deploy(Application $application, callable $restore): void
+ {
+ try {
+ $result = queue_application_deployment(application: $application, deployment_uuid: new_public_id());
+ } catch (Throwable $e) {
+ $restore();
+ ImageStartPin::forget($application);
+ report($e);
+
+ throw new ImageUpdateRefused('The deploy could not start. Coolify kept the old image.', previous: $e);
+ }
+
+ if (($result['status'] ?? null) !== 'queued') {
+ $restore();
+ ImageStartPin::forget($application);
+
+ throw new ImageUpdateRefused(($result['status'] ?? null) === 'skipped'
+ ? 'A deploy of this app is already queued. Try again when it is done.'
+ : (string) ($result['message'] ?? 'The deploy could not be queued.'));
+ }
+ }
+
+ /**
+ * @param ImageChange $change
+ * @return list
+ */
+ private function updateDatabase(Model $database, array $change): array
+ {
+ $oldImage = (string) $database->getAttribute('image');
+ if ($change['same_tag']) {
+ $this->pin($database, $change);
+ } else {
+ $database->setAttribute('image', $this->withTag($oldImage, $change['tag']));
+ $database->save();
+ }
+
+ $restore = function () use ($database, $oldImage, $change): void {
+ ImageStartPin::forget($database);
+ if (! $change['same_tag']) {
+ $database->setAttribute('image', $oldImage);
+ $database->save();
+ }
+ };
+
+ try {
+ $result = RestartDatabase::run($database);
+ } catch (Throwable $e) {
+ $restore();
+ report($e);
+
+ throw new ImageUpdateRefused('The restart failed. Coolify kept the old image.', previous: $e);
+ }
+
+ if (is_string($result)) {
+ $restore();
+
+ throw new ImageUpdateRefused($result);
+ }
+
+ return [(string) $database->getAttribute('uuid')];
+ }
+
+ /**
+ * Changes the images of the service to their new tags and restarts it once: the compose file and the image of each
+ * application and database, or the environment variable that sets the image. A newer build of the same tag only
+ * waits its scanned digest for the restart.
+ *
+ * @param non-empty-list $changes
+ * @return list
+ */
+ private function updateService(Service $service, array $changes): array
+ {
+ if (ResourceStartActivity::latestRunning((string) $service->uuid) !== null) {
+ throw new ImageUpdateRefused('A start of this service is running. Try again when it is done.');
+ }
+
+ /** @var array $targets */
+ $targets = [];
+ $variables = [];
+ $variableOwners = [];
+ $variableServices = [];
+ $pinned = false;
+ foreach ($changes as $change) {
+ if ($change['same_tag']) {
+ $this->pin($service, $change);
+ $pinned = true;
+
+ continue;
+ }
+
+ $raw = trim((string) $change['owner']->getAttribute('image'));
+ if (ComposeImageVariable::hasVariable($raw)) {
+ $variable = ComposeImageVariable::parse($raw);
+ $current = ResourceScans::configuredImage($change['owner']);
+ if ($variable === null || ! self::sameImage($current, $change['old'])) {
+ throw new ImageUpdateRefused('The compose file sets this image in a way Coolify cannot change. Change the version in the compose file.');
+ }
+ $this->refuseSharedVariable($service, $variable->key, $changes);
+ $variables = $this->addVariable($variables, $variable->key, $variable->valueFor($current, $change['tag']));
+ $variableOwners[] = (string) $change['owner']->getAttribute('uuid');
+ $variableServices[(string) $change['owner']->getAttribute('name')] = $variable->key;
+
+ continue;
+ }
+
+ $subResources = self::subResourcesUsing($service, $change['old']);
+ if ($subResources->isEmpty()) {
+ throw new ImageUpdateRefused('No application or database of this service runs this image.');
+ }
+ foreach ($subResources as $subResource) {
+ $targets[$subResource->getMorphClass().':'.$subResource->getKey()] = ['resource' => $subResource, 'tag' => $change['tag']];
+ }
+ }
+
+ self::refuseOtherVariableUses((string) $service->docker_compose_raw, $variableServices);
+
+ $oldCompose = $service->docker_compose_raw;
+ $oldImages = array_map(fn (array $target): string => (string) $target['resource']->image, $targets);
+
+ if ($targets !== []) {
+ $compose = $this->composeWithImages($service, $targets);
+ DB::transaction(function () use ($service, $compose, $targets): void {
+ $service->docker_compose_raw = Yaml::dump($compose, 10, 2);
+ $service->save();
+ foreach ($targets as $target) {
+ $target['resource']->image = $this->withTag((string) $target['resource']->image, $target['tag']);
+ $target['resource']->save();
+ }
+ });
+ }
+ $restoreVariables = $this->setVariables($service, $variables);
+
+ try {
+ RestartService::run($service, true);
+ } catch (Throwable $e) {
+ if ($targets !== []) {
+ DB::transaction(function () use ($service, $oldCompose, $targets, $oldImages): void {
+ $service->docker_compose_raw = $oldCompose;
+ $service->save();
+ foreach ($targets as $key => $target) {
+ $target['resource']->image = $oldImages[$key];
+ $target['resource']->save();
+ }
+ });
+ }
+ $restoreVariables();
+ if ($pinned) {
+ ImageStartPin::forget($service);
+ }
+ report($e);
+
+ throw new ImageUpdateRefused('The restart failed. Coolify kept the old image.', previous: $e);
+ }
+
+ return array_values(array_unique([
+ ...array_map(fn (array $target): string => (string) $target['resource']->getAttribute('uuid'), $targets),
+ ...$variableOwners,
+ ]));
+ }
+
+ /**
+ * Why Coolify cannot change the images of a Compose app at all. Null when it can.
+ */
+ private static function composeRefusal(Application $application): ?string
+ {
+ if ($application->settings?->is_raw_compose_deployment_enabled) {
+ return 'This app deploys its compose file as written. Change the version in the compose file, then redeploy.';
+ }
+ if ((int) $application->compose_parsing_version < 3) {
+ return 'Change the version in the compose file of this app, then redeploy.';
+ }
+ // The command can build, pull, or start other images than the compose file names.
+ if (filled($application->docker_compose_custom_build_command)) {
+ return 'This app uses a custom build command, so Coolify cannot choose the image. Change the image in your compose file.';
+ }
+ if (filled($application->docker_compose_custom_start_command)) {
+ return 'This app uses a custom start command, so Coolify cannot choose the image. Change the image in your compose file.';
+ }
+
+ return null;
+ }
+
+ /**
+ * The services of the compose file that run the image in a way Coolify can change: an image line (or the override
+ * that replaced it), or a variable that sets the image. Keyed by the service name, with the image it runs now.
+ *
+ * @param array $services
+ * @param array $overrides
+ * @return array
+ */
+ private static function composeMatches(Application $application, array $services, array $overrides, string $imageRef): array
+ {
+ $matches = [];
+ foreach ($services as $name => $image) {
+ $variable = ComposeImageVariable::parse($image);
+ if ($variable !== null) {
+ $current = $variable->resolve(self::variableValue($application, $variable->key));
+ } else {
+ $current = ComposeImageVariable::hasVariable($image) ? null : ($overrides[$name]['to'] ?? $image);
+ }
+
+ if ($current !== null && self::sameImage($current, $imageRef)) {
+ $matches[$name] = ['variable' => $variable, 'current' => $current];
+ }
+ }
+
+ return $matches;
+ }
+
+ /**
+ * A variable that the update changes must set only the image lines it changes. Anywhere else in the compose file
+ * (environment, labels, a command, another image) the new value would change more than the image.
+ *
+ * @param array $variableServices The variable per compose service whose image it sets.
+ */
+ private static function refuseOtherVariableUses(string $rawCompose, array $variableServices): void
+ {
+ if ($variableServices === []) {
+ return;
+ }
+
+ try {
+ $compose = parseDockerComposeYaml($rawCompose);
+ } catch (Throwable) {
+ $compose = null;
+ }
+ if (! is_array($compose)) {
+ throw new ImageUpdateRefused('The compose file is not valid.');
+ }
+
+ foreach (array_keys($variableServices) as $name) {
+ if (is_array($compose['services'][$name] ?? null)) {
+ unset($compose['services'][$name]['image']);
+ }
+ }
+
+ $rest = (string) json_encode($compose, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PARTIAL_OUTPUT_ON_ERROR);
+ foreach (array_unique(array_values($variableServices)) as $key) {
+ if (preg_match('/\$\{?'.preg_quote($key, '/').'(?![A-Za-z0-9_])/', $rest) === 1) {
+ throw new ImageUpdateRefused("The compose file uses the variable {$key} in another place too. Change the version in the compose file.");
+ }
+ }
+ }
+
+ /**
+ * The images of the services of the compose file from Git, as written, keyed by the service name.
+ *
+ * @return array
+ */
+ private static function composeServices(Application $application): array
+ {
+ try {
+ $services = parseDockerComposeYaml((string) $application->docker_compose_raw)['services'] ?? null;
+ } catch (Throwable) {
+ $services = null;
+ }
+ if (! is_array($services)) {
+ throw new ImageUpdateRefused('The compose file of this app is not valid.');
+ }
+
+ $images = [];
+ foreach ($services as $name => $service) {
+ $image = is_array($service) ? ($service['image'] ?? null) : null;
+ if (is_string($image) && trim($image) !== '') {
+ $images[(string) $name] = trim($image);
+ }
+ }
+
+ return $images;
+ }
+
+ private static function sameImage(string $a, string $b): bool
+ {
+ try {
+ $first = ImageRef::parse(trim($a));
+ $second = ImageRef::parse(trim($b));
+ } catch (InvalidArgumentException) {
+ return false;
+ }
+
+ return $first->digest === null && $second->digest === null
+ && $first->qualifiedName() === $second->qualifiedName()
+ && ($first->tag ?? 'latest') === ($second->tag ?? 'latest');
+ }
+
+ /**
+ * The value of a Coolify environment variable of the resource; null when it is not set.
+ */
+ private static function variableValue(Application|Service $resource, string $key): ?string
+ {
+ $variable = $resource->environment_variables()->where('key', $key)->where('is_preview', false)->first();
+
+ return $variable instanceof EnvironmentVariable ? (string) $variable->value : null;
+ }
+
+ /**
+ * @param array $variables
+ * @return array
+ */
+ private function addVariable(array $variables, string $key, string $value): array
+ {
+ if (isset($variables[$key]) && $variables[$key] !== $value) {
+ throw new ImageUpdateRefused("The variable {$key} sets more than one of these images. Change the versions in the compose file.");
+ }
+ $variables[$key] = $value;
+
+ return $variables;
+ }
+
+ /**
+ * A variable that also sets the image of another application or database that this update does not change would
+ * change that image too.
+ *
+ * @param non-empty-list $changes
+ */
+ private function refuseSharedVariable(Service $service, string $key, array $changes): void
+ {
+ $changing = array_map(fn (array $change): string => $change['owner']->getMorphClass().':'.$change['owner']->getKey(), $changes);
+ foreach (ResourceScans::owners($service) as $owner) {
+ $image = ComposeImageVariable::parse((string) $owner->getAttribute('image'));
+ if ($image !== null && $image->key === $key && ! in_array($owner->getMorphClass().':'.$owner->getKey(), $changing, true)) {
+ throw new ImageUpdateRefused("The variable {$key} also sets the image of {$owner->getAttribute('name')}. Change the versions in the compose file.");
+ }
+ }
+ }
+
+ /**
+ * Sets the environment variables of the resource, and returns what puts the old values back. A value that refers
+ * to a shared variable or a secret is not replaced.
+ *
+ * @param array $variables
+ */
+ private function setVariables(Application|Service $resource, array $variables): callable
+ {
+ $old = [];
+ foreach ($variables as $key => $value) {
+ $variable = $resource->environment_variables()->where('key', $key)->where('is_preview', false)->first();
+ if ($variable instanceof EnvironmentVariable && $variable->is_shown_once) {
+ throw new ImageUpdateRefused("The variable {$key} is locked, so Coolify does not change it.");
+ }
+ if ($variable instanceof EnvironmentVariable && str_contains((string) $variable->value, '{{')) {
+ throw new ImageUpdateRefused("The variable {$key} refers to another variable. Change the version there.");
+ }
+ }
+
+ foreach ($variables as $key => $value) {
+ $variable = $resource->environment_variables()->where('key', $key)->where('is_preview', false)->first();
+ if ($variable instanceof EnvironmentVariable) {
+ $old[$key] = ['variable' => $variable, 'value' => $variable->value, 'created' => false];
+ $variable->value = $value;
+ $variable->save();
+ } else {
+ $old[$key] = ['variable' => $resource->environment_variables()->create(['key' => $key, 'value' => $value, 'is_preview' => false]), 'value' => null, 'created' => true];
+ }
+ }
+
+ return function () use ($old): void {
+ foreach ($old as $entry) {
+ if ($entry['created']) {
+ $entry['variable']->delete();
+ } else {
+ $entry['variable']->value = $entry['value'];
+ $entry['variable']->save();
+ }
+ }
+ };
+ }
+
+ /**
+ * The applications and databases of a service that run the image.
+ *
+ * @return Collection
+ */
+ public static function subResourcesUsing(Service $service, string $imageRef): Collection
+ {
+ try {
+ $scanned = ImageRef::parse($imageRef);
+ } catch (InvalidArgumentException) {
+ return collect();
+ }
+
+ return ResourceScans::owners($service)
+ ->filter(function (Model $subResource) use ($scanned): bool {
+ try {
+ $image = ImageRef::parse(trim((string) $subResource->getAttribute('image')));
+ } catch (InvalidArgumentException) {
+ return false;
+ }
+
+ return $image->digest === null && $scanned->digest === null
+ && $image->qualifiedName() === $scanned->qualifiedName()
+ && ($image->tag ?? 'latest') === ($scanned->tag ?? 'latest');
+ })
+ ->values();
+ }
+
+ /**
+ * The compose file with the new image for each sub-resource. Refuses when the compose file names another image
+ * for one of them, for example a variable, because then the image does not come from the field Coolify changes.
+ *
+ * @param array $targets
+ * @return array
+ */
+ private function composeWithImages(Service $service, array $targets): array
+ {
+ try {
+ $compose = parseDockerComposeYaml((string) $service->docker_compose_raw);
+ } catch (ParseException) {
+ $compose = null;
+ }
+ if (! is_array($compose) || ! is_array($compose['services'] ?? null)) {
+ throw new ImageUpdateRefused('The compose file of this service is not valid.');
+ }
+
+ foreach ($targets as $target) {
+ $subResource = $target['resource'];
+ $name = (string) $subResource->name;
+ $configured = $compose['services'][$name]['image'] ?? null;
+ if (! is_string($configured) || trim($configured) !== trim((string) $subResource->image)) {
+ throw new ImageUpdateRefused("The compose file sets the image of {$name} in another way. Change it in the compose file.");
+ }
+ $compose['services'][$name]['image'] = $this->withTag((string) $subResource->image, $target['tag']);
+ }
+
+ return $compose;
+ }
+
+ /**
+ * The configured image with another tag, keeping how the user wrote the name (`postgres`, `docker.io/library/postgres`).
+ */
+ private function withTag(string $image, string $tag): string
+ {
+ $image = trim($image);
+ $lastColon = strrpos($image, ':');
+ $lastSlash = strrpos($image, '/');
+ $name = $lastColon !== false && ($lastSlash === false || $lastColon > $lastSlash) ? substr($image, 0, $lastColon) : $image;
+
+ return $name.':'.$tag;
+ }
+}
diff --git a/app/Actions/Server/AuthorizedKeys.php b/app/Actions/Server/AuthorizedKeys.php
new file mode 100644
index 0000000000..d6bb9a2c31
--- /dev/null
+++ b/app/Actions/Server/AuthorizedKeys.php
@@ -0,0 +1,269 @@
+isTransferredAway() && ! $server->skipServer();
+ }
+
+ /** The exit code of add() when ~/.ssh/authorized_keys has no line with the old key. */
+ public const OLD_KEY_NOT_FOUND = 3;
+
+ /**
+ * Adds the new key with the options of every line of the old key. The old key stays until remove().
+ * Throws a RuntimeException with the code OLD_KEY_NOT_FOUND when the file has no line with the old key.
+ */
+ public static function add(Server $server, PrivateKey $privateKey, PrivateKey $oldKey): void
+ {
+ self::assertCanRunOn($server);
+ self::runLocked($server, [
+ 'f="$HOME/.ssh/authorized_keys"',
+ 'tmp=$(mktemp "$f.cpk-XXXXXX")',
+ ...self::rotatedLinesCommands($privateKey, $oldKey, '"$tmp"', '{ echo "The old key is not in ~/.ssh/authorized_keys of the SSH user." >&2; exit '.self::OLD_KEY_NOT_FOUND.'; }'),
+ // A last line without a final newline would be joined with the new line.
+ 'if [ -s "$f" ] && [ -n "$(tail -c1 "$f")" ]; then echo >> "$f"; fi',
+ // Do not add an unrestricted fallback, including on retry.
+ 'while IFS= read -r entry; do grep -qxF -- "$entry" "$f" || printf \'%s\\n\' "$entry" >> "$f"; done < "$tmp"',
+ ]);
+ }
+
+ /**
+ * Undoes add() after the new key could not log in: removes only the exact lines that add() wrote.
+ * Other lines stay, also lines that contain the new key. Returns false when another server record
+ * for the same host and user uses the new key.
+ */
+ public static function undoAdd(Server $server, PrivateKey $privateKey, PrivateKey $oldKey): bool
+ {
+ self::assertCanRunOn($server);
+ if (self::isUsedOnSameHost($server, $privateKey->publicKeyBody(), $privateKey->id)) {
+ return false;
+ }
+
+ self::runLocked($server, [
+ 'f="$HOME/.ssh/authorized_keys"',
+ 'if [ ! -f "$f" ]; then exit 0; fi',
+ 'tmp=$(mktemp "$f.cpk-XXXXXX")',
+ 'cp -p "$f" "$tmp"',
+ // Without a line of the old key, add() wrote nothing.
+ ...self::rotatedLinesCommands($privateKey, $oldKey, '"$tmp.lines"', 'exit 0'),
+ 'rc=0',
+ 'grep -vxF -f "$tmp.lines" -- "$f" > "$tmp" || rc=$?',
+ 'if [ "$rc" -gt 1 ]; then exit 1; fi',
+ 'mv -f "$tmp" "$f"',
+ ]);
+
+ return true;
+ }
+
+ /**
+ * Writes to $output the lines that add() writes: the new key with the options of each line of the old key.
+ * Runs $notFound when the file has no line with the old key.
+ *
+ * @return list
+ */
+ private static function rotatedLinesCommands(PrivateKey $privateKey, PrivateKey $oldKey, string $output, string $notFound): array
+ {
+ $line = escapeshellarg($privateKey->authorizedKeysLine());
+ $oldBody = escapeshellarg($oldKey->publicKeyBody());
+ // Tokenize outside quoted options; never interpret options as shell commands.
+ $script = <<<'AWK'
+function token( start,c,quoted,escaped) {
+ while (substr($0,pos,1) ~ /[ \t]/ && pos <= length($0)) pos++
+ start=pos; quoted=0; escaped=0
+ while (pos <= length($0)) {
+ c=substr($0,pos,1)
+ if (escaped) escaped=0
+ else if (c == "\\" && quoted) escaped=1
+ else if (c == "\"") quoted=!quoted
+ else if (!quoted && c ~ /[ \t]/) break
+ pos++
+ }
+ return substr($0,start,pos-start)
+}
+{
+ pos=1
+ first=token()
+ if (first == "" || substr(first,1,1) == "#") next
+ prefix=""
+ if (first != type) { prefix=first " "; first=token() }
+ data=token()
+ if (first == type && data == key) { print prefix replacement; found=1 }
+}
+END { if (!found) exit 1 }
+AWK;
+
+ return [
+ "old={$oldBody}",
+ 'type=${old%% *}; key=${old#* }',
+ "awk -v type=\"\$type\" -v key=\"\$key\" -v replacement={$line} ".escapeshellarg($script)." \"\$f\" > {$output} || {$notFound}",
+ ];
+ }
+
+ /**
+ * Removes every line with the public key, also lines that cloud-init or a cloud provider added.
+ * Returns false when another server record for the same host and user still uses the key.
+ */
+ public static function remove(Server $server, PrivateKey $privateKey): bool
+ {
+ return self::removePublicKey($server, $privateKey->publicKeyBody(), $privateKey->id);
+ }
+
+ /**
+ * Like remove(), for a key whose record may be deleted already.
+ *
+ * @param string $publicKeyBody The key type and the base64 data, without a comment.
+ */
+ public static function removePublicKey(Server $server, string $publicKeyBody, ?int $privateKeyId = null): bool
+ {
+ self::assertCanRunOn($server);
+ if (self::isUsedOnSameHost($server, $publicKeyBody, $privateKeyId)) {
+ return false;
+ }
+ $body = escapeshellarg($publicKeyBody);
+
+ self::runLocked($server, [
+ 'f="$HOME/.ssh/authorized_keys"',
+ 'if [ ! -f "$f" ]; then exit 0; fi',
+ 'tmp=$(mktemp "$f.cpk-XXXXXX")',
+ // Preserve the existing owner and mode before replacing the file atomically.
+ 'cp -p "$f" "$tmp"',
+ 'rc=0',
+ "grep -vF -- {$body} \"\$f\" > \"\$tmp\" || rc=\$?",
+ 'if [ "$rc" -gt 1 ]; then exit 1; fi',
+ 'mv -f "$tmp" "$f"',
+ ]);
+
+ return true;
+ }
+
+ /** The SHA256 fingerprint in the format of PrivateKey::$fingerprint, or null when the key cannot be read. */
+ public static function fingerprint(string $publicKeyBody): ?string
+ {
+ try {
+ return PublicKeyLoader::load($publicKeyBody)->getFingerprint('sha256');
+ } catch (\Throwable) {
+ return null;
+ }
+ }
+
+ /**
+ * All edits share a lock in the remote SSH home, also when server records use different aliases or keys.
+ * mkdir is atomic and available on supported hosts without requiring flock.
+ */
+ private static function runLocked(Server $server, array $commands): void
+ {
+ instant_remote_process([
+ 'if [ ! -d "$HOME/.ssh" ]; then mkdir -p "$HOME/.ssh" && chmod 700 "$HOME/.ssh"; fi',
+ 'lock="$HOME/.ssh/.coolify-authorized-keys.lock"',
+ 'attempt=0',
+ // A lock older than 10 minutes is left from an edit that crashed. Edits take a few seconds.
+ 'find "$lock" -maxdepth 0 -type d -mmin +10 -exec rmdir {} \\; 2>/dev/null || true',
+ 'until mkdir "$lock" 2>/dev/null; do',
+ 'attempt=$((attempt + 1))',
+ 'if [ "$attempt" -ge 30 ]; then echo "Another authorized_keys edit is running; try again later." >&2; exit 1; fi',
+ 'sleep 1',
+ 'done',
+ 'tmp=""',
+ "trap '[ -z \"\$tmp\" ] || rm -f \"\$tmp\" \"\$tmp.lines\"; rmdir \"\$lock\"' EXIT",
+ "trap 'exit 1' HUP INT TERM",
+ ...$commands,
+ ], $server, no_sudo: true);
+ }
+
+ private static function isUsedOnSameHost(Server $server, string $publicKeyBody, ?int $privateKeyId): bool
+ {
+ $fingerprint = self::fingerprint($publicKeyBody);
+ // Other teams count only with the same key material: removing it would lock them out.
+ // Keys saved before fingerprints existed have none stored, so compare their key material.
+ $others = Server::query()
+ ->with('privateKey')
+ ->where('user', $server->user)
+ ->whereKeyNot($server->id)
+ ->where(fn ($query) => $query->when($privateKeyId, fn ($query) => $query->where('private_key_id', $privateKeyId))
+ ->orWhereHas('privateKey', fn ($keys) => $keys->where('fingerprint', $fingerprint)->orWhereNull('fingerprint')))
+ ->with('settings:id,server_id,ssh_host_key')
+ ->get(['id', 'ip', 'private_key_id'])
+ ->filter(fn (Server $other) => ($privateKeyId && $other->private_key_id === $privateKeyId)
+ || ($other->privateKey && $fingerprint
+ && ($other->privateKey->fingerprint ?: PrivateKey::generateFingerprint($other->privateKey->private_key)) === $fingerprint));
+ $addresses = [];
+ foreach ($others as $other) {
+ $host = strtolower(rtrim(Server::normalizeIp($server->ip), '.'));
+ $otherHost = strtolower(rtrim(Server::normalizeIp($other->ip), '.'));
+ if ($host === $otherHost) {
+ return true;
+ }
+ // Two addresses of one machine (for example a public and a Tailscale IP) show the same host key.
+ // Without a pinned host key on both records, it is not known that these are different machines.
+ $hostKeys = self::hostKeys($server);
+ $otherHostKeys = self::hostKeys($other);
+ if ($hostKeys === [] || $otherHostKeys === [] || array_intersect($hostKeys, $otherHostKeys) !== []) {
+ return true;
+ }
+ $addresses[$host] ??= self::hostAddresses($host);
+ $addresses[$otherHost] ??= self::hostAddresses($otherHost);
+ // An unresolved hostname cannot prove that these are different machines. Keep the key.
+ if ($addresses[$host] === [] || $addresses[$otherHost] === []
+ || array_intersect($addresses[$host], $addresses[$otherHost]) !== []) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /** @return list The pinned SSH host keys of the server, as "type base64" lines. */
+ private static function hostKeys(Server $server): array
+ {
+ return array_values(array_filter(array_map(
+ fn (string $line) => implode(' ', array_slice(preg_split('/\s+/', trim($line)), 0, 2)),
+ preg_split('/\R/', (string) $server->settings?->ssh_host_key),
+ )));
+ }
+
+ /** @return list */
+ private static function hostAddresses(string $host): array
+ {
+ if (filter_var($host, FILTER_VALIDATE_IP)) {
+ return [$host];
+ }
+ $resolver = trim(explode(',', (string) instanceSettings()->custom_dns_servers)[0]);
+ if ($resolver === '') {
+ return [];
+ }
+ $addresses = [];
+ foreach ([DnsResolver::TYPE_A, DnsResolver::TYPE_AAAA] as $type) {
+ try {
+ $records = queryDnsServer($resolver, $host, $type, timeout: 2);
+ if ($records === false) {
+ return [];
+ }
+ foreach ($records as $record) {
+ $addresses[] = Server::normalizeIp($record->getData());
+ }
+ } catch (\Throwable) {
+ return [];
+ }
+ }
+
+ return $addresses;
+ }
+
+ private static function assertCanRunOn(Server $server): void
+ {
+ if (! self::canRunOn($server)) {
+ throw new \RuntimeException('Coolify does not run commands on this server.');
+ }
+ }
+}
diff --git a/app/Actions/Server/StartSentinel.php b/app/Actions/Server/StartSentinel.php
index b888892cea..079ae90608 100644
--- a/app/Actions/Server/StartSentinel.php
+++ b/app/Actions/Server/StartSentinel.php
@@ -89,6 +89,24 @@ class StartSentinel
];
}
+ /**
+ * Docker reads an env file line by line, so a line break in a value would add a variable.
+ *
+ * @param array $environments
+ */
+ public static function envFileContent(array $environments): string
+ {
+ $lines = [];
+ foreach ($environments as $key => $value) {
+ if (preg_match('/[\r\n]/', (string) $value)) {
+ throw new \RuntimeException("The Sentinel setting {$key} must not contain a line break.");
+ }
+ $lines[] = "{$key}={$value}";
+ }
+
+ return implode("\n", $lines)."\n";
+ }
+
/**
* @throws LockTimeoutException when another start for the server does not finish in time
*/
@@ -141,7 +159,8 @@ class StartSentinel
$image = $customImage;
}
}
- $dockerEnvironments = implode(' ', array_map(fn ($key, $value) => '-e '.escapeshellarg("$key=$value"), array_keys($environments), $environments));
+ $envFile = devHostDockerPath($server, base_configuration_dir().'/sentinel.env');
+ $envFileContent = self::envFileContent($environments);
$dockerLabels = implode(' ', array_map(fn ($key, $value) => "$key=$value", array_keys($labels), $labels));
$trafficLogDirectory = self::trafficLogDirectory($server);
$trafficMount = self::collectsTraffic($server)
@@ -150,7 +169,7 @@ class StartSentinel
$network = $server->isLocalhost() ? ' --network coolify' : '';
// A userns-remap daemon refuses --pid host unless the container opts out of remapping.
// On other daemons --userns host has no effect. https://docs.docker.com/engine/security/userns-remap/
- $dockerCommand = "docker run -d$network $dockerEnvironments --name coolify-sentinel --restart unless-stopped -v /var/run/docker.sock:/var/run/docker.sock -v $mountDir:/app/db {$trafficMount}--userns host --pid host --health-cmd \"curl --fail http://127.0.0.1:8888/api/health || exit 1\" --health-start-period 120s --health-interval 10s --health-retries 3 --add-host=host.docker.internal:host-gateway --label $dockerLabels $image";
+ $dockerCommand = "docker run -d$network --env-file $envFile --name coolify-sentinel --restart unless-stopped -v /var/run/docker.sock:/var/run/docker.sock -v $mountDir:/app/db {$trafficMount}--userns host --pid host --health-cmd \"curl --fail http://127.0.0.1:8888/api/health || exit 1\" --health-start-period 120s --health-interval 10s --health-retries 3 --add-host=host.docker.internal:host-gateway --label $dockerLabels $image";
$server->sentinelHeartbeat(isReset: true);
$server->forceFill(['sentinel_waiting_since' => now()])->save();
@@ -159,7 +178,18 @@ class StartSentinel
'docker rm -f coolify-sentinel || true',
"mkdir -p $mountDir",
...self::trafficLogPreparationCommands($server),
- $dockerCommand,
+ 'sh -ec '.escapeshellarg(implode("\n", [
+ 'trap '.escapeshellarg("rm -f $envFile").' EXIT',
+ // The mode is set before the token is written, so it does not depend on the umask of the server.
+ "rm -f $envFile",
+ "touch $envFile",
+ "chmod 600 $envFile",
+ "echo '".base64_encode($envFileContent)."' | base64 -d | tee $envFile > /dev/null",
+ $dockerCommand,
+ // Docker keeps the environment in the container configuration, so restarts do not read the file again.
+ "rm -f $envFile",
+ 'trap - EXIT',
+ ])),
"chown -R 9999:root $mountDir",
"chmod -R 700 $mountDir",
], $server);
diff --git a/app/Actions/Server/UpdateCoolify.php b/app/Actions/Server/UpdateCoolify.php
index 0ee8424ce1..877b2325e9 100644
--- a/app/Actions/Server/UpdateCoolify.php
+++ b/app/Actions/Server/UpdateCoolify.php
@@ -2,9 +2,11 @@
namespace App\Actions\Server;
+use App\Exceptions\UpgradeAlreadyRunningException;
use App\Jobs\DatabaseBackupJob;
use App\Models\Server;
use App\Models\StandalonePostgresql;
+use App\Services\AutoUpdateNotifier;
use App\Services\CoolifyUpgradeStatus;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Sleep;
@@ -40,6 +42,7 @@ class UpdateCoolify
if ($manual_update && filled($settings->update_custom_tag)) {
$this->latestVersion = $settings->update_custom_tag;
$this->backupDatabase();
+ $this->ensureNoUpgradeIsRunning();
$this->update(registryUrl: 'ghcr.io');
}
@@ -127,7 +130,15 @@ class UpdateCoolify
}
$this->backupDatabase();
+ // Check the lock before the attempt is saved, so a running upgrade keeps its own saved attempt.
+ $this->ensureNoUpgradeIsRunning();
+ if (! $manual_update) {
+ AutoUpdateNotifier::saveAttempt((string) $this->latestVersion);
+ }
$this->update();
+ if (! $manual_update) {
+ AutoUpdateNotifier::started((string) $this->currentVersion, (string) $this->latestVersion);
+ }
$settings->new_version_available = false;
$settings->save();
}
@@ -151,8 +162,6 @@ class UpdateCoolify
private function update(?string $registryUrl = null)
{
- $this->ensureNoUpgradeIsRunning();
-
$latestHelperImageVersion = getHelperVersion();
$upgradeScriptUrl = $this->upgradeScriptUrl ?? config('constants.coolify.upgrade_script_url');
$registryUrl ??= coolifyRegistryUrl();
@@ -175,10 +184,7 @@ class UpdateCoolify
'target_version' => $this->latestVersion,
'status' => $status,
]);
- throw new \Exception(
- 'Another Coolify upgrade is already running. Wait for it to finish. '.
- 'If it has stopped, you can upgrade again '.CoolifyUpgradeStatus::RUNNING_LOCK_EXPIRES_AFTER_MINUTES.' minutes after its last status update.'
- );
+ throw new UpgradeAlreadyRunningException;
}
}
diff --git a/app/Actions/Service/DeployServiceApplication.php b/app/Actions/Service/DeployServiceApplication.php
index f09967d8bd..0692f37521 100644
--- a/app/Actions/Service/DeployServiceApplication.php
+++ b/app/Actions/Service/DeployServiceApplication.php
@@ -5,8 +5,10 @@ namespace App\Actions\Service;
use App\Actions\Shared\EnsureContentFilesOnServer;
use App\Events\ServiceStartFinished;
use App\Jobs\NotifyResourceStoppedJob;
+use App\Jobs\ScanResourceImagesJob;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
+use App\Services\ImageScan\ImageStartPin;
use Lorisleiva\Actions\Concerns\AsAction;
use Spatie\Activitylog\Contracts\Activity;
@@ -46,6 +48,9 @@ class DeployServiceApplication
$commands->push(serverDockerConfigPrefix($service->server)."docker compose --project-directory {$safeWorkdir} -f {$safeComposeFile} --project-name {$safeProjectName} pull {$safeComposeServiceName} < /dev/null");
}
+ // After the pull: the scanned build of a "Pull latest" security update keeps the tag of the compose file.
+ $commands->push(...ImageStartPin::commands(ImageStartPin::take($service), serverDockerConfigPrefix($service->server)));
+
if ($service->networks()->count() > 0) {
$commands->push('echo Creating Docker network.');
$commands->push("docker network inspect {$safeProjectName} >/dev/null 2>&1 || docker network create --attachable {$safeProjectName}");
@@ -76,6 +81,9 @@ class DeployServiceApplication
$commands->push("docker network connect {$safeProjectName} coolify-proxy >/dev/null 2>&1 || true");
- return remote_process($commands->toArray(), $service->server, type_uuid: $service->uuid, callEventOnFinish: 'ServiceStartFinished', callEventData: ServiceStartFinished::dataFor($service), queue: deployment_queue());
+ $activity = remote_process($commands->toArray(), $service->server, type_uuid: $service->uuid, callEventOnFinish: 'ServiceStartFinished', callEventData: ServiceStartFinished::dataFor($service), queue: deployment_queue());
+ ScanResourceImagesJob::dispatchAfterStart($service);
+
+ return $activity;
}
}
diff --git a/app/Actions/Service/StartService.php b/app/Actions/Service/StartService.php
index 0e8dd32879..ac286d9619 100644
--- a/app/Actions/Service/StartService.php
+++ b/app/Actions/Service/StartService.php
@@ -5,12 +5,14 @@ namespace App\Actions\Service;
use App\Actions\Shared\EnsureContentFilesOnServer;
use App\Events\ServiceStartFinished;
use App\Jobs\NotifyResourceStoppedJob;
+use App\Jobs\ScanResourceImagesJob;
use App\Jobs\ServerFilesFromServerJob;
use App\Models\LocalFileVolume;
use App\Models\LocalPersistentVolume;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
+use App\Services\ImageScan\ImageStartPin;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Lorisleiva\Actions\Concerns\AsAction;
@@ -60,6 +62,8 @@ class StartService
$commands[] = "echo 'Pulling images.'";
$commands[] = "{$compose} pull < /dev/null";
}
+ // After the pull: the scanned build of a "Pull latest" security update keeps the tag of the compose file.
+ $commands = array_merge($commands, ImageStartPin::commands(ImageStartPin::take($service), serverDockerConfigPrefix($service->server)));
if ($service->networks()->count() > 0) {
$commands[] = "echo 'Creating Docker network.'";
$commands[] = "docker network inspect $service->uuid >/dev/null 2>&1 || docker network create --attachable $service->uuid";
@@ -91,7 +95,10 @@ class StartService
}
}
- return remote_process($commands, $service->server, type_uuid: $service->uuid, callEventOnFinish: 'ServiceStartFinished', callEventData: ServiceStartFinished::dataFor($service), queue: deployment_queue());
+ $activity = remote_process($commands, $service->server, type_uuid: $service->uuid, callEventOnFinish: 'ServiceStartFinished', callEventData: ServiceStartFinished::dataFor($service), queue: deployment_queue());
+ ScanResourceImagesJob::dispatchAfterStart($service);
+
+ return $activity;
}
/**
diff --git a/app/Actions/Shared/CheckDomainDns.php b/app/Actions/Shared/CheckDomainDns.php
index 627e0de9cb..b2dd10c479 100644
--- a/app/Actions/Shared/CheckDomainDns.php
+++ b/app/Actions/Shared/CheckDomainDns.php
@@ -4,9 +4,9 @@ namespace App\Actions\Shared;
use App\Models\Server;
use App\Services\Dns\CloudflareDnsProvider;
+use App\Services\Dns\DnsResolver;
use App\Support\DnsRecordHints;
use Lorisleiva\Actions\Concerns\AsAction;
-use PurplePixie\PhpDns\DNSTypes;
use Spatie\Url\Url;
class CheckDomainDns
@@ -66,7 +66,7 @@ class CheckDomainDns
return $this->result('ok', 'DNS looks correct.', $expectedIp);
}
- $type = dnsRecordTypeForIp($expectedIp) === 'AAAA' ? DNSTypes::NAME_AAAA : DNSTypes::NAME_A;
+ $type = dnsRecordTypeForIp($expectedIp) === 'AAAA' ? DnsResolver::TYPE_AAAA : DnsResolver::TYPE_A;
$receivedAddressRecord = false;
foreach ($dnsServers as $dnsServer) {
@@ -138,8 +138,8 @@ class CheckDomainDns
*/
protected function resolveWithSystemDns(string $host, string $type): array
{
- $recordType = $type === DNSTypes::NAME_AAAA ? DNS_AAAA : DNS_A;
- $addressKey = $type === DNSTypes::NAME_AAAA ? 'ipv6' : 'ip';
+ $recordType = $type === DnsResolver::TYPE_AAAA ? DNS_AAAA : DNS_A;
+ $addressKey = $type === DnsResolver::TYPE_AAAA ? 'ipv6' : 'ip';
try {
$records = @dns_get_record($host, $recordType);
diff --git a/app/Console/Commands/Init.php b/app/Console/Commands/Init.php
index 6cd914fd9c..2177d0747a 100644
--- a/app/Console/Commands/Init.php
+++ b/app/Console/Commands/Init.php
@@ -6,6 +6,7 @@ use App\Enums\ActivityTypes;
use App\Enums\ApplicationDeploymentStatus;
use App\Jobs\CheckHelperImageJob;
use App\Jobs\PullChangelog;
+use App\Jobs\SyncGithubDeploymentJob;
use App\Models\ApplicationDeploymentQueue;
use App\Models\Environment;
use App\Models\InstanceSettings;
@@ -15,6 +16,7 @@ use App\Models\ScheduledTaskExecution;
use App\Models\Server;
use App\Models\StandalonePostgresql;
use App\Models\User;
+use App\Services\AutoUpdateNotifier;
use App\Support\ResourceStartActivity;
use App\Support\RootSetupWindow;
use Carbon\Carbon;
@@ -74,6 +76,12 @@ class Init extends Command
}
get_public_ips();
+ try {
+ AutoUpdateNotifier::checkResult();
+ } catch (\Throwable $e) {
+ echo "Could not check the automatic update result: {$e->getMessage()}\n";
+ }
+
// Backward compatibility
$this->replaceSlashInEnvironmentName();
$this->restoreCoolifyDbBackup();
@@ -99,16 +107,24 @@ class Init extends Command
echo "Continuing with initialization - cleanup errors will not prevent Coolify from starting\n";
}
try {
- $updatedCount = ApplicationDeploymentQueue::whereIn('status', [
+ $stuckDeployments = ApplicationDeploymentQueue::whereIn('status', [
ApplicationDeploymentStatus::IN_PROGRESS->value,
ApplicationDeploymentStatus::QUEUED->value,
- ])->update([
+ ])->get(['id', 'application_id', 'parent_deployment_uuid']);
+ $updatedCount = ApplicationDeploymentQueue::whereKey($stuckDeployments->modelKeys())->update([
'status' => ApplicationDeploymentStatus::FAILED->value,
]);
if ($updatedCount > 0) {
echo "Marked {$updatedCount} stuck deployments as failed\n";
}
+
+ // The query update above fires no model event, so GitHub would show them in progress forever.
+ try {
+ $stuckDeployments->each(fn (ApplicationDeploymentQueue $deployment) => SyncGithubDeploymentJob::dispatchFor($deployment));
+ } catch (\Throwable $e) {
+ echo "Could not queue the GitHub deployment sync of stuck deployments: {$e->getMessage()}\n";
+ }
} catch (\Throwable $e) {
echo "Could not cleanup inprogress deployments: {$e->getMessage()}\n";
}
diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php
index 92e8cf88a3..d85a51b45a 100644
--- a/app/Console/Kernel.php
+++ b/app/Console/Kernel.php
@@ -10,6 +10,7 @@ use App\Jobs\CheckMissingVolumeBackupsJob;
use App\Jobs\CleanupInstanceStuffsJob;
use App\Jobs\CleanupOrphanedPreviewContainersJob;
use App\Jobs\CleanupStaleMultiplexedConnections;
+use App\Jobs\CleanupUnusedSshKeys;
use App\Jobs\PullChangelog;
use App\Jobs\PullTemplatesFromCDN;
use App\Jobs\ReconcileGithubRunnersJob;
@@ -48,6 +49,11 @@ class Kernel extends ConsoleKernel
$this->instanceTimezone = config('app.timezone');
}
+ // Files are local to each cloud node; do not dispatch to the shared queue.
+ $this->scheduleInstance->call(fn () => app(CleanupUnusedSshKeys::class)->handle())
+ ->name('cleanup:ssh-keys')
+ ->hourly();
+
$this->scheduleInstance->call(fn () => app(CleanupStaleMultiplexedConnections::class)->handle())
->name('cleanup:ssh-mux')
->hourly()
diff --git a/app/Enums/BackupStorageType.php b/app/Enums/BackupStorageType.php
new file mode 100644
index 0000000000..25f5461b20
--- /dev/null
+++ b/app/Enums/BackupStorageType.php
@@ -0,0 +1,55 @@
+ 'S3',
+ self::Sftp => 'SFTP',
+ self::Ftps => 'FTPS',
+ self::Webdav => 'WebDAV',
+ };
+ }
+
+ /**
+ * The URL schemes that the endpoint of this type can use.
+ *
+ * @return array
+ */
+ public function endpointSchemes(): array
+ {
+ return match ($this) {
+ self::S3, self::Webdav => ['https', 'http'],
+ self::Sftp => ['sftp'],
+ self::Ftps => ['ftps'],
+ };
+ }
+
+ public function defaultPort(): ?int
+ {
+ return match ($this) {
+ self::S3, self::Webdav => null,
+ self::Sftp => 22,
+ self::Ftps => 21,
+ };
+ }
+
+ /**
+ * @return array
+ */
+ public static function options(): array
+ {
+ return array_map(fn (self $type): array => ['value' => $type->value, 'label' => $type->label()], self::cases());
+ }
+}
diff --git a/app/Enums/ImageScanErrorCode.php b/app/Enums/ImageScanErrorCode.php
new file mode 100644
index 0000000000..04c8e4d408
--- /dev/null
+++ b/app/Enums/ImageScanErrorCode.php
@@ -0,0 +1,49 @@
+ "Not enough disk space on {$server}: {$n} GB needed.",
+ self::DbTooOld => "The vulnerability database is {$n} days old. {$server} cannot reach grype.anchore.io.",
+ self::DbMissing => "The vulnerability database is not on {$server} yet. Wait for the first update to finish or check that {$server} can reach grype.anchore.io.",
+ self::Timeout => 'The scan took too long and was stopped.',
+ self::OutOfMemory => 'The scanner needed more than 1 GB of memory for this image and was stopped.',
+ self::LockBusy => "Another scan is still running on {$server}.",
+ self::RegistryAuth => "Coolify cannot read this image from its registry. Log in to the registry on {$server}.",
+ self::BuildServerOff => 'Image scanning is off on the build server.',
+ self::ScanningOff => "Turn on image scanning on {$server}.",
+ self::CustomCommand => 'Coolify cannot check the image because this application uses a custom build or start command.',
+ self::ImageMissing => "The image is not on {$server}. Click Scan now to pull and scan it. An image that Coolify builds needs a new deploy.",
+ self::PullFailed => "Coolify could not pull the image to {$server}. Check the image name, and log in to its registry on {$server} if it is private.",
+ self::Truncated => 'The scan result was too large to read completely.',
+ self::Unknown => 'The scan could not run.',
+ };
+ }
+}
diff --git a/app/Enums/ImageScanFixState.php b/app/Enums/ImageScanFixState.php
new file mode 100644
index 0000000000..d61f5fc754
--- /dev/null
+++ b/app/Enums/ImageScanFixState.php
@@ -0,0 +1,10 @@
+ 'Off',
+ self::On => 'On',
+ self::Strict => 'Strict',
+ };
+ }
+
+ public function description(): ?string
+ {
+ return match ($this) {
+ self::Off => null,
+ self::On => 'Blocks a deploy when the new image adds a problem that attackers are using or are likely to use.',
+ self::Strict => 'Also blocks critical, high, and counted medium problems that have a fix, and operating systems that no longer get security fixes.',
+ };
+ }
+}
diff --git a/app/Enums/ImageScanOnError.php b/app/Enums/ImageScanOnError.php
new file mode 100644
index 0000000000..6ea23a4196
--- /dev/null
+++ b/app/Enums/ImageScanOnError.php
@@ -0,0 +1,17 @@
+ 'Deploy anyway',
+ self::Block => 'Block the deploy',
+ };
+ }
+}
diff --git a/app/Enums/ImageScanStatus.php b/app/Enums/ImageScanStatus.php
new file mode 100644
index 0000000000..9a35e66958
--- /dev/null
+++ b/app/Enums/ImageScanStatus.php
@@ -0,0 +1,11 @@
+teamId = $teamId;
+ }
+
+ /**
+ * @return array{resourceUuid: string, scanUuid: string, final: bool}
+ */
+ public function broadcastWith(): array
+ {
+ return ['resourceUuid' => $this->resourceUuid, 'scanUuid' => $this->scanUuid, 'final' => $this->final];
+ }
+}
diff --git a/app/Exceptions/UpgradeAlreadyRunningException.php b/app/Exceptions/UpgradeAlreadyRunningException.php
new file mode 100644
index 0000000000..67059277aa
--- /dev/null
+++ b/app/Exceptions/UpgradeAlreadyRunningException.php
@@ -0,0 +1,21 @@
+team_id)->findOrFail($server->private_key_id);
@@ -105,9 +131,10 @@ class SshMultiplexingHelper
}
$establishCommand .= self::getCommonSshOptions($server, $sshKeyLocation, $connectionTimeout, $serverInterval);
- $establishCommand .= self::escapedUserAtHost($server).' true';
+ $establishCommand .= '-- '.self::escapedUserAtHost($server).' true';
$establishProcess = Process::run($establishCommand);
+ self::noticeHostKeyMismatch($server, $establishProcess);
if ($establishProcess->exitCode() !== 0) {
return false;
}
@@ -196,10 +223,10 @@ class SshMultiplexingHelper
// Upload: local source -> remote dest
if ($server->isIpv6()) {
- return $scpCommand.escapeshellarg($source).' '.escapeshellarg($server->user).'@['.escapeshellarg($server->ip).']:'.escapeshellarg($dest);
+ return $scpCommand.'-- '.escapeshellarg($source).' '.escapeshellarg($server->user).'@['.escapeshellarg($server->ip).']:'.escapeshellarg($dest);
}
- return $scpCommand.escapeshellarg($source).' '.self::escapedUserAtHost($server).':'.escapeshellarg($dest);
+ return $scpCommand.'-- '.escapeshellarg($source).' '.self::escapedUserAtHost($server).':'.escapeshellarg($dest);
}
/**
@@ -237,10 +264,10 @@ class SshMultiplexingHelper
// Download: remote source -> local dest
if ($server->isIpv6()) {
- return $scpCommand.escapeshellarg($server->user).'@['.escapeshellarg($server->ip).']:'.escapeshellarg($remoteSource).' '.escapeshellarg($localDest);
+ return $scpCommand.'-- '.escapeshellarg($server->user).'@['.escapeshellarg($server->ip).']:'.escapeshellarg($remoteSource).' '.escapeshellarg($localDest);
}
- return $scpCommand.self::escapedUserAtHost($server).':'.escapeshellarg($remoteSource).' '.escapeshellarg($localDest);
+ return $scpCommand.'-- '.self::escapedUserAtHost($server).':'.escapeshellarg($remoteSource).' '.escapeshellarg($localDest);
}
public static function generateSshCommand(Server $server, string $command, bool $disableMultiplexing = false, ?int $commandTimeout = null): string
@@ -251,7 +278,7 @@ class SshMultiplexingHelper
$command = str_replace($delimiter, '', $command);
$remoteShellCommand = self::remoteShellCommand();
- return $sshCommand.self::escapedUserAtHost($server)." '{$remoteShellCommand}' << \\$delimiter".PHP_EOL
+ return $sshCommand.'-- '.self::escapedUserAtHost($server)." '{$remoteShellCommand}' << \\$delimiter".PHP_EOL
.$command.PHP_EOL
.$delimiter;
}
@@ -283,7 +310,7 @@ class SshMultiplexingHelper
public static function generateSshStdinCommand(Server $server, string $remoteCommand, bool $disableMultiplexing = false, ?int $commandTimeout = null): string
{
return self::sshCommandPrefix($server, $disableMultiplexing, $commandTimeout)
- .self::escapedUserAtHost($server).' '.escapeshellarg($remoteCommand);
+ .'-- '.self::escapedUserAtHost($server).' '.escapeshellarg($remoteCommand);
}
private static function sshCommandPrefix(Server $server, bool $disableMultiplexing, ?int $commandTimeout): string
@@ -340,11 +367,14 @@ class SshMultiplexingHelper
{
$sshConfig = self::serverSshConfiguration($server);
$muxSocket = $sshConfig['muxFilename'];
- $healthCommand = 'ssh -o ControlMaster=auto -o ControlPath='.escapeshellarg($muxSocket).' ';
+ // A real round trip through the master. If the master is gone, ssh connects directly,
+ // so it needs the same options as every other connection. ControlMaster=no keeps it from becoming a master.
+ $healthCommand = 'ssh -o ControlMaster=no -o ControlPath='.escapeshellarg($muxSocket).' ';
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
$healthCommand .= '-o ProxyCommand="cloudflared access ssh --hostname %h" ';
}
- $healthCommand .= self::escapedUserAtHost($server)." 'echo \"health_check_ok\"'";
+ $healthCommand .= self::getCommonSshOptions($server, $sshConfig['sshKeyLocation'], self::getConnectionTimeout($server), config('constants.ssh.server_interval'));
+ $healthCommand .= '-- '.self::escapedUserAtHost($server)." 'echo \"health_check_ok\"'";
// The health check goes over the network, so a slow link gets at least the server's connection timeout.
$timeout = max((int) config('constants.ssh.mux_health_check_timeout'), self::getConnectionTimeout($server));
@@ -398,7 +428,7 @@ class SshMultiplexingHelper
public static function runMuxSocketCommand(string $muxSocket, string $operation): ?ProcessResult
{
return self::runWithTimeout(
- "ssh -O {$operation} -o ControlPath=".escapeshellarg($muxSocket).' localhost',
+ "ssh -F /dev/null -O {$operation} -o ControlPath=".escapeshellarg($muxSocket).' localhost',
(int) config('constants.ssh.mux_health_check_timeout'),
);
}
@@ -436,12 +466,12 @@ class SshMultiplexingHelper
private static function muxControlCommand(Server $server, string $operation): string
{
- $command = "ssh -O {$operation} -o ControlPath=".escapeshellarg(self::muxSocket($server)).' ';
+ $command = "ssh -F /dev/null -O {$operation} -o ControlPath=".escapeshellarg(self::muxSocket($server)).' ';
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
$command .= '-o ProxyCommand="cloudflared access ssh --hostname %h" ';
}
- return $command.self::escapedUserAtHost($server);
+ return $command.'-- '.self::escapedUserAtHost($server);
}
private static function multiplexingOptions(Server $server): string
@@ -458,6 +488,12 @@ class SshMultiplexingHelper
private static function escapedUserAtHost(Server $server): string
{
+ // ssh and scp read an argument that starts with "-" as an option, also after escaping.
+ // Validation rejects such values; this also stops rows that were saved before it did.
+ if (str_starts_with((string) $server->user, '-') || str_starts_with((string) $server->ip, '-')) {
+ throw new \RuntimeException('The SSH user and the server IP must not start with "-".');
+ }
+
return escapeshellarg($server->user).'@'.escapeshellarg($server->ip);
}
@@ -499,15 +535,18 @@ class SshMultiplexingHelper
]);
}
}
+ self::giveToSshDirectoryOwner($keyLocation, $keyLocation.'.lock');
}
private static function getCommonSshOptions(Server $server, string $sshKeyLocation, int $connectionTimeout, int $serverInterval, bool $isScp = false): string
{
$options = '-i '.escapeshellarg($sshKeyLocation).' '
- .'-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null '
+ .self::hostKeyOptions($server)
.'-o PasswordAuthentication=no '
."-o ConnectTimeout=$connectionTimeout "
."-o ServerAliveInterval=$serverInterval "
+ .'-F /dev/null -o IdentitiesOnly=yes '
+ .'-o ClearAllForwardings=yes -o ForwardAgent=no '
.'-o RequestTTY=no '
.'-o LogLevel=ERROR ';
@@ -517,4 +556,348 @@ class SshMultiplexingHelper
return $options.'-p '.escapeshellarg((string) $server->port).' ';
}
+
+ /**
+ * warn: connect also when the host key changed, and report it. enforce: refuse a changed host key. off: no check.
+ */
+ public static function hostKeyCheckingMode(Server $server): string
+ {
+ $mode = config('constants.ssh.host_key_checking');
+ if (! in_array($mode, ['warn', 'enforce', 'off'], true)) {
+ self::warnAboutUnknownHostKeyCheckingMode($mode);
+ }
+ if ($mode === 'off' || data_get($server, 'settings.is_ssh_host_key_check_disabled')) {
+ return 'off';
+ }
+
+ return $mode === 'enforce' ? 'enforce' : 'warn';
+ }
+
+ /**
+ * A mistyped SSH_HOST_KEY_CHECKING value falls back to warn. Log it once a day, not for every command.
+ */
+ private static function warnAboutUnknownHostKeyCheckingMode(mixed $mode): void
+ {
+ if (self::$checkedUnknownHostKeyCheckingMode === $mode) {
+ return;
+ }
+ self::$checkedUnknownHostKeyCheckingMode = $mode;
+ if (Cache::add('ssh_host_key_checking_unknown_warned_'.hash('sha256', (string) $mode), true, now()->addDay())) {
+ Log::warning('Unknown SSH_HOST_KEY_CHECKING value, Coolify uses warn. Use warn, enforce, or off.', ['value' => $mode]);
+ }
+ }
+
+ /**
+ * True when ssh reports that the server sent a host key that does not match the pinned key.
+ */
+ public static function isHostKeyMismatch(string $errorOutput): bool
+ {
+ return str_contains($errorOutput, 'REMOTE HOST IDENTIFICATION HAS CHANGED')
+ || str_contains($errorOutput, 'Host key verification failed');
+ }
+
+ /**
+ * When an SSH process reports a changed host key, check the connection once. The check saves the new key
+ * as pending and notifies the team, also for a server that the scheduled connection check skips (Sentinel).
+ * The message must name the host key alias or the known_hosts file of the server: a program on the server
+ * (git in a deployment) can print the same text for its own SSH connection. Without strict checking (warn
+ * mode), ssh names only the known_hosts file.
+ */
+ public static function noticeHostKeyMismatch(Server $server, ProcessResult $result): void
+ {
+ $errorOutput = $result->errorOutput();
+ $namesThisServer = str_contains($errorOutput, self::hostKeyAlias($server))
+ || str_contains($errorOutput, '/known_hosts_'.$server->uuid.'_');
+ if (! self::isHostKeyMismatch($errorOutput) || ! $namesThisServer
+ || ! Cache::add('ssh_host_key_mismatch_noticed_'.$server->uuid, true, 600)) {
+ return;
+ }
+ ServerConnectionCheckJob::dispatch($server);
+ }
+
+ /**
+ * Remove the changed host key warning of OpenSSH from the start of the stderr of an SSH process, for logs and
+ * saved output. Detect a mismatch with the raw output (isHostKeyMismatch()). A warning later in the output
+ * is from a program on the server (git) and stays.
+ */
+ public static function stripHostKeyChangedWarning(string $output): string
+ {
+ return preg_replace(self::HOST_KEY_CHANGED_WARNING_PATTERN, '', $output, 1) ?? $output;
+ }
+
+ /**
+ * Filter streamed stderr of an SSH process: hold the first chunks while they can still be the changed host key
+ * warning, then return them without it. Start with $held = ''; it becomes null when the start is passed.
+ * Pass $final when the warning must be over (stdout arrived or the process ended).
+ */
+ public static function filterHostKeyChangedWarning(?string &$held, string $chunk, bool $final = false): string
+ {
+ if ($held === null) {
+ return $chunk;
+ }
+ $held .= $chunk;
+ if (! $final && self::mayBeHostKeyChangedWarning($held)) {
+ return '';
+ }
+ $output = self::stripHostKeyChangedWarning($held);
+ $held = null;
+
+ return $output;
+ }
+
+ private static function mayBeHostKeyChangedWarning(string $output): bool
+ {
+ if ($output === '') {
+ return true;
+ }
+ if (! str_starts_with($output, '@') || strlen($output) > self::HOST_KEY_CHANGED_WARNING_MAX_BYTES) {
+ return false;
+ }
+ if (! preg_match(self::HOST_KEY_CHANGED_WARNING_PATTERN, $output, $matches)) {
+ return true;
+ }
+
+ // The rest of the warning can follow until a complete line arrives that is not part of it.
+ return ! preg_match('/\A(?! remove with:).*\n/', substr($output, strlen($matches[0])));
+ }
+
+ /**
+ * Read the host key that the server sends now. The probe has every login method off, so it offers no key
+ * and runs no command: ssh saves the host key during the key exchange, then the login fails by design.
+ */
+ public static function scanHostKey(Server $server): ?string
+ {
+ return self::readHostKey($server)['key'];
+ }
+
+ /**
+ * @return array{key: ?string, error: string}
+ */
+ private static function readHostKey(Server $server): array
+ {
+ $connectTimeout = self::hostKeyScanConnectTimeout($server);
+ $directory = self::knownHostsDirectory();
+ File::ensureDirectoryExists($directory, 0700);
+ self::giveToSshDirectoryOwner($directory);
+ $scanFile = $directory.'/scan_'.$server->uuid.'_'.Str::random(8);
+
+ $command = 'ssh ';
+ if (data_get($server, 'settings.is_cloudflare_tunnel')) {
+ $command .= "-o ProxyCommand='cloudflared access ssh --hostname %h' ";
+ }
+ $command .= '-F /dev/null -o HostKeyAlias='.escapeshellarg(self::hostKeyAlias($server)).' '
+ .'-o UserKnownHostsFile='.escapeshellarg($scanFile).' -o GlobalKnownHostsFile=/dev/null '
+ .'-o StrictHostKeyChecking=accept-new -o BatchMode=yes '
+ .'-o PubkeyAuthentication=no -o PasswordAuthentication=no -o KbdInteractiveAuthentication=no '
+ .'-o HostbasedAuthentication=no '
+ .'-o ConnectTimeout='.$connectTimeout.' -o LogLevel=ERROR '
+ .'-p '.escapeshellarg((string) $server->port).' '
+ .'-- '.self::escapedUserAtHost($server).' true';
+
+ try {
+ $result = self::runWithTimeout($command, self::hostKeyScanProcessTimeout($server));
+
+ return [
+ 'key' => ServerSetting::normalizeSshHostKeys(@file_get_contents($scanFile) ?: null),
+ 'error' => $result === null ? 'The connection timed out.' : Str::limit(trim($result->errorOutput()), 300),
+ ];
+ } finally {
+ File::delete([$scanFile, $scanFile.'.old']);
+ }
+ }
+
+ /** The connection timeout of the server, like the other SSH commands use, capped for the scan. */
+ private static function hostKeyScanConnectTimeout(Server $server): int
+ {
+ return min(self::getConnectionTimeout($server), self::HOST_KEY_SCAN_MAX_CONNECT_TIMEOUT_SECONDS);
+ }
+
+ private static function hostKeyScanProcessTimeout(Server $server): int
+ {
+ return self::hostKeyScanConnectTimeout($server) + 5;
+ }
+
+ /**
+ * Pin the host key that the server sends on the first connection (trust on first use).
+ * Nothing scans again within HOST_KEY_SCAN_RETRY_SECONDS of a failed scan. Commands also do not scan a server
+ * that is unreachable, so they do not wait for the scan timeout. The connection check passes
+ * $evenIfUnreachable, so the server learns its key when it is back. In enforce mode the check then fails with
+ * the scan error until the next scan.
+ */
+ public static function learnHostKey(Server $server, bool $evenIfUnreachable = false): void
+ {
+ if (self::hostKeyCheckingMode($server) === 'off' || filled($server->settings->ssh_host_key) || ! $server->settings->exists) {
+ return;
+ }
+ if (Cache::has(self::hostKeyScanFailedCacheKey($server)) || (! $evenIfUnreachable && $server->unreachable_count > 0)) {
+ return;
+ }
+
+ $learn = function () use ($server) {
+ if (self::loadPinnedHostKey($server)) {
+ return;
+ }
+ $scan = self::readHostKey($server);
+ if ($scan['key'] === null) {
+ Cache::put(self::hostKeyScanFailedCacheKey($server), $scan['error'], self::HOST_KEY_SCAN_RETRY_SECONDS);
+
+ return;
+ }
+ $pin = [
+ 'ssh_host_key' => $scan['key'],
+ // A server that was never usable is new. A usable server learns its key after the upgrade.
+ 'ssh_host_key_source' => $server->settings->is_usable ? 'upgrade' : 'tofu',
+ 'ssh_host_key_trusted_at' => now(),
+ 'ssh_host_key_pending' => null,
+ ];
+ // Write only the pin, so that unsaved settings changes of the caller stay unsaved.
+ $pinned = ServerSetting::query()->whereKey($server->settings->getKey())->whereNull('ssh_host_key')->update($pin);
+ if ($pinned === 0) {
+ self::loadPinnedHostKey($server);
+
+ return;
+ }
+ $server->settings->forceFill($pin)->syncOriginalAttributes(array_keys($pin));
+ // An open multiplexed connection was made without the pinned key.
+ self::removeMuxFile($server);
+ };
+
+ $lock = Cache::lock('ssh_host_key_learn_'.$server->uuid, self::HOST_KEY_SCAN_MAX_CONNECT_TIMEOUT_SECONDS + 25);
+ try {
+ // Enforce mode cannot connect without the key, so it waits for another process that reads it. Warn mode connects without it.
+ if (self::hostKeyCheckingMode($server) === 'enforce') {
+ $lock->block(self::hostKeyScanProcessTimeout($server), $learn);
+ } else {
+ $lock->get($learn);
+ }
+ } catch (LockTimeoutException) {
+ self::loadPinnedHostKey($server);
+ }
+ }
+
+ /**
+ * Read the pinned host key from the database into the settings in memory, without a refresh() that drops unsaved changes.
+ */
+ private static function loadPinnedHostKey(Server $server): bool
+ {
+ $columns = ['ssh_host_key', 'ssh_host_key_source', 'ssh_host_key_trusted_at', 'ssh_host_key_pending'];
+ $stored = ServerSetting::query()->whereKey($server->settings->getKey())->first($columns);
+ if (blank($stored?->ssh_host_key)) {
+ return false;
+ }
+ $server->settings->forceFill($stored->getAttributes())->syncOriginalAttributes($columns);
+
+ return true;
+ }
+
+ private static function hostKeyScanFailedCacheKey(Server $server): string
+ {
+ return 'ssh_host_key_scan_failed_'.$server->uuid;
+ }
+
+ private static function hostKeyOptions(Server $server): string
+ {
+ self::learnHostKey($server);
+ $mode = self::hostKeyCheckingMode($server);
+
+ if ($mode === 'off') {
+ return '-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ';
+ }
+ if (blank($server->settings->ssh_host_key)) {
+ if ($mode === 'enforce') {
+ $scanError = Cache::get(self::hostKeyScanFailedCacheKey($server));
+ throw new \RuntimeException('SSH connection failed: Coolify could not read the SSH host key of the server'.(filled($scanError) ? ": {$scanError}" : '.'), 255);
+ }
+
+ return '-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ';
+ }
+
+ return '-o HostKeyAlias='.escapeshellarg(self::hostKeyAlias($server)).' '
+ .'-o UserKnownHostsFile='.escapeshellarg(self::syncKnownHostsFile($server)).' '
+ .'-o StrictHostKeyChecking='.($mode === 'enforce' ? 'yes' : 'no').' ';
+ }
+
+ /**
+ * Write the known_hosts file of the server from the database, like validateSshKey() does for the key file.
+ * The file name has a hash of the content, so a process that loaded an older key cannot overwrite the file of the current key.
+ */
+ private static function syncKnownHostsFile(Server $server): string
+ {
+ ['path' => $path, 'content' => $content] = self::knownHostsFile($server->uuid, $server->settings->ssh_host_key);
+
+ if (@file_get_contents($path) !== $content) {
+ File::ensureDirectoryExists(self::knownHostsDirectory(), 0700);
+ $temporaryPath = $path.'.'.Str::random(8);
+ File::put($temporaryPath, $content);
+ chmod($temporaryPath, 0600);
+ rename($temporaryPath, $path);
+ }
+ self::giveToSshDirectoryOwner(self::knownHostsDirectory(), $path);
+
+ return $path;
+ }
+
+ /**
+ * Delete the known_hosts files of old host keys of a server. Pass null to delete every file of the server.
+ */
+ public static function deleteStaleKnownHostsFiles(string $serverUuid, ?string $pinnedKey): void
+ {
+ $keep = filled($pinnedKey) ? self::knownHostsFile($serverUuid, $pinnedKey)['path'] : null;
+ foreach (File::glob(self::knownHostsDirectory().'/known_hosts_'.$serverUuid.'_*') as $file) {
+ if ($file !== $keep && preg_match('/_[0-9a-f]{16}$/', $file) === 1) {
+ File::delete($file);
+ }
+ }
+ }
+
+ /**
+ * @return array{path: string, content: string}
+ */
+ private static function knownHostsFile(string $serverUuid, string $pinnedKey): array
+ {
+ $alias = 'coolify-'.$serverUuid;
+ $content = collect(explode("\n", $pinnedKey))
+ ->filter()
+ ->map(fn (string $key) => "{$alias} {$key}\n")
+ ->implode('');
+
+ return [
+ 'path' => self::knownHostsDirectory().'/known_hosts_'.$serverUuid.'_'.substr(hash('sha256', $content), 0, 16),
+ 'content' => $content,
+ ];
+ }
+
+ private static function knownHostsDirectory(): string
+ {
+ return storage_path('app/ssh/known_hosts');
+ }
+
+ /**
+ * Horizon runs as root and php-fpm runs as www-data. Files that a root process writes under storage/app/ssh
+ * get the owner of that directory, so that the web process can still read and replace them.
+ */
+ public static function giveToSshDirectoryOwner(string ...$paths): void
+ {
+ if (! function_exists('posix_geteuid') || posix_geteuid() !== 0) {
+ return;
+ }
+
+ $owner = @fileowner(storage_path('app/ssh'));
+ $group = @filegroup(storage_path('app/ssh'));
+ if ($owner === false || $owner === 0) {
+ return;
+ }
+
+ foreach ($paths as $path) {
+ if (file_exists($path) && (fileowner($path) !== $owner || filegroup($path) !== $group)) {
+ @chown($path, $owner);
+ @chgrp($path, $group);
+ }
+ }
+ }
+
+ private static function hostKeyAlias(Server $server): string
+ {
+ return 'coolify-'.$server->uuid;
+ }
}
diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php
index d9080ea1df..a7b06ff686 100644
--- a/app/Http/Controllers/Api/ApplicationsController.php
+++ b/app/Http/Controllers/Api/ApplicationsController.php
@@ -80,6 +80,7 @@ class ApplicationsController extends Controller
'is_consistent_container_name_enabled',
'custom_internal_name',
'custom_container_name_prefix',
+ 'is_image_scan_enabled',
];
private const BOOLEAN_APPLICATION_SETTING_FIELDS = [
@@ -104,6 +105,7 @@ class ApplicationsController extends Controller
'is_log_drain_enabled',
'is_gpu_enabled',
'is_consistent_container_name_enabled',
+ 'is_image_scan_enabled',
];
protected function findTaggableResource(string $uuid, int|string $teamId): mixed
@@ -498,6 +500,7 @@ class ApplicationsController extends Controller
'gpu_device_ids' => ['type' => 'string', 'nullable' => true, 'description' => 'Comma-separated GPU device IDs.'],
'gpu_options' => ['type' => 'string', 'nullable' => true, 'description' => 'Additional GPU options.'],
'is_consistent_container_name_enabled' => ['type' => 'boolean', 'description' => 'Use a consistent container name across deployments.'],
+ 'is_image_scan_enabled' => ['type' => 'boolean', 'description' => 'Scan the images of the application for vulnerabilities. Needs image scanning on the server. Default true.'],
'custom_internal_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Custom internal container name. Turns is_consistent_container_name_enabled on when that field is not sent; sending it as false together with a name returns 422.'],
'custom_container_name_prefix' => ['type' => 'string', 'nullable' => true, 'description' => 'Prefix for generated container names (prefix-20260908T141530). Slugified and unique across the instance.'],
'preview_url_template' => ['type' => 'string', 'description' => 'Preview URL template.'],
@@ -711,6 +714,7 @@ class ApplicationsController extends Controller
'gpu_device_ids' => ['type' => 'string', 'nullable' => true, 'description' => 'Comma-separated GPU device IDs.'],
'gpu_options' => ['type' => 'string', 'nullable' => true, 'description' => 'Additional GPU options.'],
'is_consistent_container_name_enabled' => ['type' => 'boolean', 'description' => 'Use a consistent container name across deployments.'],
+ 'is_image_scan_enabled' => ['type' => 'boolean', 'description' => 'Scan the images of the application for vulnerabilities. Needs image scanning on the server. Default true.'],
'custom_internal_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Custom internal container name. Turns is_consistent_container_name_enabled on when that field is not sent; sending it as false together with a name returns 422.'],
'custom_container_name_prefix' => ['type' => 'string', 'nullable' => true, 'description' => 'Prefix for generated container names (prefix-20260908T141530). Slugified and unique across the instance.'],
'preview_url_template' => ['type' => 'string', 'description' => 'Preview URL template.'],
@@ -924,6 +928,7 @@ class ApplicationsController extends Controller
'gpu_device_ids' => ['type' => 'string', 'nullable' => true, 'description' => 'Comma-separated GPU device IDs.'],
'gpu_options' => ['type' => 'string', 'nullable' => true, 'description' => 'Additional GPU options.'],
'is_consistent_container_name_enabled' => ['type' => 'boolean', 'description' => 'Use a consistent container name across deployments.'],
+ 'is_image_scan_enabled' => ['type' => 'boolean', 'description' => 'Scan the images of the application for vulnerabilities. Needs image scanning on the server. Default true.'],
'custom_internal_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Custom internal container name. Turns is_consistent_container_name_enabled on when that field is not sent; sending it as false together with a name returns 422.'],
'custom_container_name_prefix' => ['type' => 'string', 'nullable' => true, 'description' => 'Prefix for generated container names (prefix-20260908T141530). Slugified and unique across the instance.'],
'preview_url_template' => ['type' => 'string', 'description' => 'Preview URL template.'],
@@ -1137,6 +1142,7 @@ class ApplicationsController extends Controller
'gpu_device_ids' => ['type' => 'string', 'nullable' => true, 'description' => 'Comma-separated GPU device IDs.'],
'gpu_options' => ['type' => 'string', 'nullable' => true, 'description' => 'Additional GPU options.'],
'is_consistent_container_name_enabled' => ['type' => 'boolean', 'description' => 'Use a consistent container name across deployments.'],
+ 'is_image_scan_enabled' => ['type' => 'boolean', 'description' => 'Scan the images of the application for vulnerabilities. Needs image scanning on the server. Default true.'],
'custom_internal_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Custom internal container name. Turns is_consistent_container_name_enabled on when that field is not sent; sending it as false together with a name returns 422.'],
'custom_container_name_prefix' => ['type' => 'string', 'nullable' => true, 'description' => 'Prefix for generated container names (prefix-20260908T141530). Slugified and unique across the instance.'],
'preview_url_template' => ['type' => 'string', 'description' => 'Preview URL template.'],
@@ -1320,6 +1326,7 @@ class ApplicationsController extends Controller
'gpu_device_ids' => ['type' => 'string', 'nullable' => true, 'description' => 'Comma-separated GPU device IDs.'],
'gpu_options' => ['type' => 'string', 'nullable' => true, 'description' => 'Additional GPU options.'],
'is_consistent_container_name_enabled' => ['type' => 'boolean', 'description' => 'Use a consistent container name across deployments.'],
+ 'is_image_scan_enabled' => ['type' => 'boolean', 'description' => 'Scan the images of the application for vulnerabilities. Needs image scanning on the server. Default true.'],
'custom_internal_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Custom internal container name. Turns is_consistent_container_name_enabled on when that field is not sent; sending it as false together with a name returns 422.'],
'custom_container_name_prefix' => ['type' => 'string', 'nullable' => true, 'description' => 'Prefix for generated container names (prefix-20260908T141530). Slugified and unique across the instance.'],
'preview_url_template' => ['type' => 'string', 'description' => 'Preview URL template.'],
@@ -1499,6 +1506,7 @@ class ApplicationsController extends Controller
'gpu_device_ids' => ['type' => 'string', 'nullable' => true, 'description' => 'Comma-separated GPU device IDs.'],
'gpu_options' => ['type' => 'string', 'nullable' => true, 'description' => 'Additional GPU options.'],
'is_consistent_container_name_enabled' => ['type' => 'boolean', 'description' => 'Use a consistent container name across deployments.'],
+ 'is_image_scan_enabled' => ['type' => 'boolean', 'description' => 'Scan the images of the application for vulnerabilities. Needs image scanning on the server. Default true.'],
'custom_internal_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Custom internal container name. Turns is_consistent_container_name_enabled on when that field is not sent; sending it as false together with a name returns 422.'],
'custom_container_name_prefix' => ['type' => 'string', 'nullable' => true, 'description' => 'Prefix for generated container names (prefix-20260908T141530). Slugified and unique across the instance.'],
'preview_url_template' => ['type' => 'string', 'description' => 'Preview URL template.'],
@@ -3536,6 +3544,7 @@ class ApplicationsController extends Controller
'gpu_device_ids' => ['type' => 'string', 'nullable' => true, 'description' => 'Comma-separated GPU device IDs.'],
'gpu_options' => ['type' => 'string', 'nullable' => true, 'description' => 'Additional GPU options.'],
'is_consistent_container_name_enabled' => ['type' => 'boolean', 'description' => 'Use a consistent container name across deployments.'],
+ 'is_image_scan_enabled' => ['type' => 'boolean', 'description' => 'Scan the images of the application for vulnerabilities. Needs image scanning on the server. Default true.'],
'custom_internal_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Custom internal container name. Turns is_consistent_container_name_enabled on when that field is not sent; sending it as false together with a name returns 422.'],
'custom_container_name_prefix' => ['type' => 'string', 'nullable' => true, 'description' => 'Prefix for generated container names (prefix-20260908T141530). Slugified and unique across the instance.'],
'preview_url_template' => ['type' => 'string', 'description' => 'Preview URL template.'],
diff --git a/app/Http/Controllers/Api/CloudProviderTokensController.php b/app/Http/Controllers/Api/CloudProviderTokensController.php
index eca3397493..d3b484b342 100644
--- a/app/Http/Controllers/Api/CloudProviderTokensController.php
+++ b/app/Http/Controllers/Api/CloudProviderTokensController.php
@@ -108,6 +108,10 @@ class CloudProviderTokensController extends Controller
response: 401,
ref: '#/components/responses/401',
),
+ new OA\Response(
+ response: 403,
+ description: 'Only team admins and owners can manage cloud provider tokens.',
+ ),
new OA\Response(
response: 400,
ref: '#/components/responses/400',
@@ -120,6 +124,9 @@ class CloudProviderTokensController extends Controller
if (is_null($teamId)) {
return invalidTokenResponse();
}
+ if (! $request->user()?->isAdminOfTeam((int) $teamId)) {
+ return response()->json(['message' => 'This action is unauthorized.'], 403);
+ }
$tokens = CloudProviderToken::whereTeamId($teamId)
->withCount('servers')
@@ -168,6 +175,10 @@ class CloudProviderTokensController extends Controller
response: 401,
ref: '#/components/responses/401',
),
+ new OA\Response(
+ response: 403,
+ description: 'Only team admins and owners can manage cloud provider tokens.',
+ ),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
@@ -238,6 +249,10 @@ class CloudProviderTokensController extends Controller
response: 401,
ref: '#/components/responses/401',
),
+ new OA\Response(
+ response: 403,
+ description: 'Only team admins and owners can manage cloud provider tokens.',
+ ),
new OA\Response(
response: 400,
ref: '#/components/responses/400',
@@ -357,6 +372,10 @@ class CloudProviderTokensController extends Controller
response: 401,
ref: '#/components/responses/401',
),
+ new OA\Response(
+ response: 403,
+ description: 'Only team admins and owners can manage cloud provider tokens.',
+ ),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
@@ -464,6 +483,10 @@ class CloudProviderTokensController extends Controller
response: 401,
ref: '#/components/responses/401',
),
+ new OA\Response(
+ response: 403,
+ description: 'Only team admins and owners can manage cloud provider tokens.',
+ ),
new OA\Response(
response: 400,
ref: '#/components/responses/400',
@@ -543,6 +566,10 @@ class CloudProviderTokensController extends Controller
response: 401,
ref: '#/components/responses/401',
),
+ new OA\Response(
+ response: 403,
+ description: 'Only team admins and owners can manage cloud provider tokens.',
+ ),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
diff --git a/app/Http/Controllers/Api/DeployController.php b/app/Http/Controllers/Api/DeployController.php
index 8c1db41d38..b0f8e67a23 100644
--- a/app/Http/Controllers/Api/DeployController.php
+++ b/app/Http/Controllers/Api/DeployController.php
@@ -8,6 +8,7 @@ use App\Actions\Service\StartService;
use App\Enums\ApplicationDeploymentStatus;
use App\Enums\CancelPreviousDeployments;
use App\Http\Controllers\Controller;
+use App\Jobs\SyncGithubDeploymentJob;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\ApplicationPreview;
@@ -278,6 +279,13 @@ class DeployController extends Controller
$deployment->status = ApplicationDeploymentStatus::CANCELLED_BY_USER->value;
$cancelled = true;
+ try {
+ // The query update above fires no model event.
+ SyncGithubDeploymentJob::dispatchFor($deployment);
+ } catch (\Throwable $e) {
+ \Log::warning("Failed to queue the GitHub deployment sync for cancelled deployment {$deployment->id}: {$e->getMessage()}");
+ }
+
try {
$deployment->addLogEntry('Deployment cancelled by user via API.', 'stderr');
CleanupCancelledDeployment::run($deployment, (int) $teamId);
diff --git a/app/Http/Controllers/Api/GithubController.php b/app/Http/Controllers/Api/GithubController.php
index 3847c56021..87bd25a5f8 100644
--- a/app/Http/Controllers/Api/GithubController.php
+++ b/app/Http/Controllers/Api/GithubController.php
@@ -474,6 +474,18 @@ class GithubController extends Controller
if (is_null($teamId)) {
return invalidTokenResponse();
}
+ $errors = [];
+ foreach (['owner' => $owner, 'repo' => $repo] as $field => $value) {
+ if (! preg_match('/^[A-Za-z0-9_.-]+$/', (string) $value) || in_array($value, ['.', '..'], true)) {
+ $errors[$field] = ["The {$field} is invalid."];
+ }
+ }
+ if ($errors) {
+ return response()->json([
+ 'message' => 'Validation failed.',
+ 'errors' => $errors,
+ ], 422);
+ }
try {
$githubApp = GithubApp::where('id', $github_app_id)
@@ -485,7 +497,7 @@ class GithubController extends Controller
$response = Http::GitHub($githubApp->api_url, $token)
->timeout(20)
->retry(3, 200, throw: false)
- ->get("/repos/{$owner}/{$repo}/branches");
+ ->get('/repos/'.rawurlencode($owner).'/'.rawurlencode($repo).'/branches');
if ($response->status() !== 200) {
return response()->json([
diff --git a/app/Http/Controllers/Api/InstanceEmailSettingsController.php b/app/Http/Controllers/Api/InstanceEmailSettingsController.php
index 7dd1ad53c9..11bfc28ba0 100644
--- a/app/Http/Controllers/Api/InstanceEmailSettingsController.php
+++ b/app/Http/Controllers/Api/InstanceEmailSettingsController.php
@@ -17,6 +17,7 @@ class InstanceEmailSettingsController extends Controller
'notification_from_address', 'notification_from_name', 'smtp_host',
'smtp_port', 'smtp_encryption', 'smtp_username', 'smtp_password',
'smtp_timeout', 'smtp_ehlo_domain', 'resend_enabled', 'resend_api_key',
+ 'resend_from_address', 'resend_from_name',
];
#[OA\Get(
@@ -70,6 +71,8 @@ class InstanceEmailSettingsController extends Controller
'smtp_ehlo_domain' => ['sometimes', 'nullable', 'string', 'max:255', new ValidHostname],
'resend_enabled' => 'sometimes|boolean',
'resend_api_key' => 'sometimes|nullable|string|max:255',
+ 'resend_from_address' => 'sometimes|nullable|email',
+ 'resend_from_name' => 'sometimes|nullable|string|max:255',
]);
if ($validator->fails()) {
diff --git a/app/Http/Controllers/Api/NotificationsController.php b/app/Http/Controllers/Api/NotificationsController.php
index fe38dbd1d5..dc88d2eb61 100644
--- a/app/Http/Controllers/Api/NotificationsController.php
+++ b/app/Http/Controllers/Api/NotificationsController.php
@@ -10,6 +10,7 @@ use App\Models\SlackNotificationSettings;
use App\Models\Team;
use App\Models\TelegramNotificationSettings;
use App\Models\WebhookNotificationSettings;
+use App\Rules\SafeSmtpHost;
use App\Rules\SafeWebhookUrl;
use App\Rules\ValidHostname;
use Illuminate\Database\Eloquent\Model;
@@ -33,7 +34,7 @@ class NotificationsController extends Controller
'smtp_from_address' => 'sometimes|nullable|email',
'smtp_from_name' => 'sometimes|nullable|string|max:255',
'smtp_recipients' => 'sometimes|nullable|string|max:1000',
- 'smtp_host' => 'sometimes|nullable|string|max:255',
+ 'smtp_host' => ['sometimes', 'nullable', 'string', 'max:255', new SafeSmtpHost],
'smtp_port' => 'sometimes|nullable|integer|min:1|max:65535',
'smtp_encryption' => 'sometimes|nullable|string|in:starttls,tls,none',
'smtp_username' => 'sometimes|nullable|string|max:255',
@@ -42,6 +43,8 @@ class NotificationsController extends Controller
'smtp_ehlo_domain' => ['sometimes', 'nullable', 'string', 'max:255', new ValidHostname],
'resend_enabled' => 'sometimes|boolean',
'resend_api_key' => 'sometimes|nullable|string|max:255',
+ 'resend_from_address' => 'sometimes|nullable|email',
+ 'resend_from_name' => 'sometimes|nullable|string|max:255',
'use_instance_email_settings' => 'sometimes|boolean',
'deployment_success_email_notifications' => 'sometimes|boolean',
'deployment_failure_email_notifications' => 'sometimes|boolean',
@@ -59,6 +62,7 @@ class NotificationsController extends Controller
'server_unreachable_email_notifications' => 'sometimes|boolean',
'server_patch_email_notifications' => 'sometimes|boolean',
'traefik_outdated_email_notifications' => 'sometimes|boolean',
+ 'image_vulnerabilities_email_notifications' => 'sometimes|boolean',
],
],
'discord' => [
@@ -81,6 +85,7 @@ class NotificationsController extends Controller
'server_unreachable_discord_notifications' => 'sometimes|boolean',
'server_patch_discord_notifications' => 'sometimes|boolean',
'traefik_outdated_discord_notifications' => 'sometimes|boolean',
+ 'image_vulnerabilities_discord_notifications' => 'sometimes|boolean',
'discord_ping_enabled' => 'sometimes|boolean',
],
],
@@ -104,6 +109,7 @@ class NotificationsController extends Controller
'server_unreachable_slack_notifications' => 'sometimes|boolean',
'server_patch_slack_notifications' => 'sometimes|boolean',
'traefik_outdated_slack_notifications' => 'sometimes|boolean',
+ 'image_vulnerabilities_slack_notifications' => 'sometimes|boolean',
],
],
'telegram' => [
@@ -127,6 +133,7 @@ class NotificationsController extends Controller
'server_unreachable_telegram_notifications' => 'sometimes|boolean',
'server_patch_telegram_notifications' => 'sometimes|boolean',
'traefik_outdated_telegram_notifications' => 'sometimes|boolean',
+ 'image_vulnerabilities_telegram_notifications' => 'sometimes|boolean',
'telegram_notifications_deployment_success_thread_id' => 'sometimes|nullable|string|max:255',
'telegram_notifications_deployment_failure_thread_id' => 'sometimes|nullable|string|max:255',
'telegram_notifications_status_change_thread_id' => 'sometimes|nullable|string|max:255',
@@ -142,6 +149,7 @@ class NotificationsController extends Controller
'telegram_notifications_server_unreachable_thread_id' => 'sometimes|nullable|string|max:255',
'telegram_notifications_server_patch_thread_id' => 'sometimes|nullable|string|max:255',
'telegram_notifications_traefik_outdated_thread_id' => 'sometimes|nullable|string|max:255',
+ 'telegram_notifications_image_vulnerabilities_thread_id' => 'sometimes|nullable|string|max:255',
],
],
'pushover' => [
@@ -165,6 +173,7 @@ class NotificationsController extends Controller
'server_unreachable_pushover_notifications' => 'sometimes|boolean',
'server_patch_pushover_notifications' => 'sometimes|boolean',
'traefik_outdated_pushover_notifications' => 'sometimes|boolean',
+ 'image_vulnerabilities_pushover_notifications' => 'sometimes|boolean',
],
],
'webhook' => [
@@ -187,6 +196,7 @@ class NotificationsController extends Controller
'server_unreachable_webhook_notifications' => 'sometimes|boolean',
'server_patch_webhook_notifications' => 'sometimes|boolean',
'traefik_outdated_webhook_notifications' => 'sometimes|boolean',
+ 'image_vulnerabilities_webhook_notifications' => 'sometimes|boolean',
],
],
default => throw new \InvalidArgumentException("Unknown notification channel [{$channel}]."),
diff --git a/app/Http/Controllers/Api/OpenApi.php b/app/Http/Controllers/Api/OpenApi.php
index 4af7e503bd..aa75fe540e 100644
--- a/app/Http/Controllers/Api/OpenApi.php
+++ b/app/Http/Controllers/Api/OpenApi.php
@@ -132,6 +132,79 @@ use OpenApi\Attributes as OA;
new OA\Property(property: 'deleted_at', type: 'string', format: 'date-time', nullable: true),
],
),
+ new OA\Schema(
+ schema: 'SecurityStatus',
+ description: 'Security status of a resource from image scanning.',
+ properties: [
+ new OA\Property(property: 'status', type: 'string', enum: ['update_now', 'update_available', 'no_fix', 'check_needed', 'ok', 'not_checked']),
+ new OA\Property(property: 'label', type: 'string'),
+ new OA\Property(property: 'action', type: 'string', nullable: true, description: 'What to do, as the UI shows it.'),
+ new OA\Property(property: 'check_reason', type: 'string', nullable: true),
+ new OA\Property(property: 'problem_count', type: 'integer'),
+ new OA\Property(property: 'exploited_count', type: 'integer'),
+ new OA\Property(property: 'any_exploited', type: 'boolean'),
+ new OA\Property(property: 'critical_count', type: 'integer'),
+ new OA\Property(property: 'high_count', type: 'integer'),
+ new OA\Property(property: 'medium_count', type: 'integer'),
+ new OA\Property(property: 'eol', type: 'object', nullable: true, description: 'The end-of-life OS of the image.'),
+ new OA\Property(property: 'checked_at', type: 'string', format: 'date-time', nullable: true),
+ new OA\Property(property: 'actions', type: 'array', items: new OA\Items(properties: [
+ new OA\Property(property: 'type', type: 'string'),
+ new OA\Property(property: 'sentence', type: 'string'),
+ new OA\Property(property: 'target', type: 'string', nullable: true),
+ new OA\Property(property: 'copy', type: 'string', nullable: true),
+ new OA\Property(property: 'image', type: 'string', nullable: true),
+ new OA\Property(property: 'ref', type: 'string', nullable: true),
+ ], type: 'object')),
+ ],
+ type: 'object',
+ ),
+ new OA\Schema(
+ schema: 'ResourceVulnerabilities',
+ description: 'Image scan results of a resource.',
+ properties: [
+ new OA\Property(property: 'uuid', type: 'string'),
+ new OA\Property(property: 'type', type: 'string', enum: ['application', 'service', 'database']),
+ new OA\Property(property: 'off_reason', type: 'string', nullable: true, enum: ['instance', 'server', 'application'], description: 'Why the resource is not scanned. Null when it is scanned.'),
+ new OA\Property(property: 'security', ref: '#/components/schemas/SecurityStatus', nullable: true),
+ new OA\Property(property: 'images', type: 'array', items: new OA\Items(properties: [
+ new OA\Property(property: 'scan_uuid', type: 'string'),
+ new OA\Property(property: 'owner_uuid', type: 'string', description: 'The application, database, or service application or database that runs the image.'),
+ new OA\Property(property: 'image_ref', type: 'string'),
+ new OA\Property(property: 'scan_status', type: 'string', enum: ['queued', 'running', 'completed', 'failed']),
+ new OA\Property(property: 'error_code', type: 'string', nullable: true),
+ new OA\Property(property: 'scanned_at', type: 'string', format: 'date-time', nullable: true),
+ new OA\Property(property: 'truncated', type: 'boolean'),
+ new OA\Property(property: 'security_status', type: 'string'),
+ ], type: 'object')),
+ new OA\Property(property: 'findings', type: 'array', items: new OA\Items(properties: [
+ new OA\Property(property: 'id', type: 'string'),
+ new OA\Property(property: 'display_id', type: 'string'),
+ new OA\Property(property: 'aliases', type: 'array', items: new OA\Items(type: 'string')),
+ new OA\Property(property: 'title', type: 'string'),
+ new OA\Property(property: 'severity', type: 'string', enum: ['critical', 'high', 'medium', 'low', 'unknown']),
+ new OA\Property(property: 'package', type: 'string'),
+ new OA\Property(property: 'version', type: 'string'),
+ new OA\Property(property: 'package_type', type: 'string'),
+ new OA\Property(property: 'fixed_in', type: 'string', nullable: true),
+ new OA\Property(property: 'state', type: 'string', enum: ['fixed', 'no_fix', 'ignored'], description: 'fixed: a fix is available upstream.'),
+ new OA\Property(property: 'ignore', type: 'object', nullable: true),
+ new OA\Property(property: 'kev', type: 'boolean', description: 'Listed in the CISA Known Exploited Vulnerabilities catalog.'),
+ new OA\Property(property: 'epss', type: 'number', nullable: true),
+ new OA\Property(property: 'risk', type: 'number', nullable: true),
+ new OA\Property(property: 'cvss_score', type: 'number', nullable: true),
+ new OA\Property(property: 'advisory_url', type: 'string', nullable: true),
+ new OA\Property(property: 'image_ref', type: 'string'),
+ new OA\Property(property: 'owner_uuid', type: 'string'),
+ ], type: 'object')),
+ new OA\Property(property: 'pagination', properties: [
+ new OA\Property(property: 'page', type: 'integer'),
+ new OA\Property(property: 'per_page', type: 'integer'),
+ new OA\Property(property: 'total', type: 'integer'),
+ ], type: 'object'),
+ ],
+ type: 'object',
+ ),
],
responses: [
new OA\Response(
diff --git a/app/Http/Controllers/Api/S3StoragesController.php b/app/Http/Controllers/Api/S3StoragesController.php
index 4a8f699ac8..b213e8d382 100644
--- a/app/Http/Controllers/Api/S3StoragesController.php
+++ b/app/Http/Controllers/Api/S3StoragesController.php
@@ -2,13 +2,14 @@
namespace App\Http\Controllers\Api;
+use App\Enums\BackupStorageType;
use App\Http\Controllers\Controller;
+use App\Models\PrivateKey;
use App\Models\S3Storage;
-use App\Rules\SafeWebhookUrl;
-use App\Rules\ValidS3BucketName;
use App\Support\ValidationPatterns;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
+use Illuminate\Validation\Rule;
use OpenApi\Attributes as OA;
class S3StoragesController extends Controller
@@ -17,7 +18,12 @@ class S3StoragesController extends Controller
{
$storage->makeHidden([
'id',
+ 'private_key_id',
+ 'privateKey',
]);
+ if ($storage->type === BackupStorageType::Sftp) {
+ $storage->setAttribute('private_key_uuid', $storage->privateKey?->uuid);
+ }
if (request()->attributes->get('can_read_sensitive', false) === true) {
$storage->makeVisible([
@@ -29,13 +35,79 @@ class S3StoragesController extends Controller
return serializeApiResponse($storage);
}
+ /**
+ * The request fields of a storage type. S3 has a bucket, a region, and a URL style; the other types have a URL,
+ * a username (key), and a password (secret).
+ *
+ * @return array
+ */
+ private function connectionFields(BackupStorageType $type): array
+ {
+ return match ($type) {
+ BackupStorageType::S3 => ['endpoint', 'bucket', 'region', 'key', 'secret', 'use_path_style_endpoint', 'skip_tls_verification'],
+ BackupStorageType::Sftp => ['endpoint', 'key', 'secret', 'private_key_uuid'],
+ default => ['endpoint', 'key', 'secret'],
+ };
+ }
+
+ /**
+ * connectionRules() with the private key as a UUID of the team, as the API sends it.
+ *
+ * @return array
+ */
+ private function connectionRules(BackupStorageType $type, int $teamId, bool $partial = false, array $trustedInternalHosts = [], ?string $savedEndpoint = null): array
+ {
+ $rules = S3Storage::connectionRules($type, $partial, $trustedInternalHosts, $teamId, $savedEndpoint);
+ if (array_key_exists('private_key_id', $rules)) {
+ unset($rules['private_key_id']);
+ $rules['private_key_uuid'] = ['nullable', 'string', Rule::exists('private_keys', 'uuid')->where('team_id', $teamId)->where(fn ($query) => $query->where('is_git_related', false))];
+ if (! $partial) {
+ $rules['secret'] = ['required_without:private_key_uuid', 'nullable', 'string', 'max:255'];
+ }
+ }
+
+ return $rules;
+ }
+
+ /**
+ * The login columns of an SFTP storage from the request: a private key UUID logs in with that key, a password
+ * logs in with the password. Empty when the request changes neither.
+ *
+ * @param array $body
+ * @return array{private_key_id?: int|null, secret?: string}|JsonResponse
+ */
+ private function sftpLoginAttributes(array $body, int $teamId, ?string $currentSecret = null): array|JsonResponse
+ {
+ if (filled($body['private_key_uuid'] ?? null)) {
+ return [
+ 'private_key_id' => PrivateKey::query()->where('team_id', $teamId)->where('is_git_related', false)->where('uuid', $body['private_key_uuid'])->value('id'),
+ 'secret' => '',
+ ];
+ }
+ if (filled($body['secret'] ?? null)) {
+ return ['private_key_id' => null, 'secret' => $body['secret']];
+ }
+ if (array_key_exists('private_key_uuid', $body)) {
+ if (blank($currentSecret)) {
+ return response()->json([
+ 'message' => 'Validation failed.',
+ 'errors' => ['secret' => ['Send a password (secret) to log in without a private key.']],
+ ], 422);
+ }
+
+ return ['private_key_id' => null];
+ }
+
+ return [];
+ }
+
/**
* @return array{valid: bool, error: string|null}
*/
private function validateStorageConnection(S3Storage $storage): array
{
try {
- $storage->testConnection(shouldSave: true);
+ $storage->testConnection(shouldSave: true, acceptNewHostKey: true);
return ['valid' => true, 'error' => null];
} catch (\Throwable $e) {
@@ -72,7 +144,7 @@ class S3StoragesController extends Controller
#[OA\Get(
summary: 'List S3 Storages',
- description: 'List all S3 storages for the authenticated team.',
+ description: 'List all backup storages (S3, SFTP, FTPS, and WebDAV) for the authenticated team.',
path: '/s3-storages',
operationId: 'list-s3-storages',
security: [
@@ -92,13 +164,16 @@ class S3StoragesController extends Controller
type: 'object',
properties: [
'uuid' => ['type' => 'string'],
+ 'type' => ['type' => 'string', 'enum' => ['s3', 'sftp', 'ftps', 'webdav']],
'name' => ['type' => 'string'],
'description' => ['type' => 'string', 'nullable' => true],
'endpoint' => ['type' => 'string'],
- 'bucket' => ['type' => 'string'],
- 'region' => ['type' => 'string'],
+ 'bucket' => ['type' => 'string', 'nullable' => true, 'description' => 'S3 only. Null for the other types.'],
+ 'region' => ['type' => 'string', 'description' => 'S3 only. The other types have a placeholder value.'],
'use_path_style_endpoint' => ['type' => 'boolean'],
'skip_tls_verification' => ['type' => 'boolean'],
+ 'private_key_uuid' => ['type' => 'string', 'nullable' => true, 'description' => 'SFTP only: the UUID of the private key used to log in. Null with a password login.'],
+ 'sftp_host_key' => ['type' => 'string', 'nullable' => true, 'description' => 'SFTP only: the pinned host key of the server, as " ". Saved by the first successful connection test.'],
'is_usable' => ['type' => 'boolean'],
'team_id' => ['type' => 'integer'],
'created_at' => ['type' => 'string'],
@@ -126,6 +201,7 @@ class S3StoragesController extends Controller
}
$storages = S3Storage::ownedByCurrentTeamAPI($teamId)
+ ->with('privateKey:id,uuid')
->get()
->map(function ($storage) {
return $this->removeSensitiveData($storage);
@@ -157,13 +233,16 @@ class S3StoragesController extends Controller
type: 'object',
properties: [
'uuid' => ['type' => 'string'],
+ 'type' => ['type' => 'string', 'enum' => ['s3', 'sftp', 'ftps', 'webdav']],
'name' => ['type' => 'string'],
'description' => ['type' => 'string', 'nullable' => true],
'endpoint' => ['type' => 'string'],
- 'bucket' => ['type' => 'string'],
- 'region' => ['type' => 'string'],
+ 'bucket' => ['type' => 'string', 'nullable' => true, 'description' => 'S3 only. Null for the other types.'],
+ 'region' => ['type' => 'string', 'description' => 'S3 only. The other types have a placeholder value.'],
'use_path_style_endpoint' => ['type' => 'boolean'],
'skip_tls_verification' => ['type' => 'boolean'],
+ 'private_key_uuid' => ['type' => 'string', 'nullable' => true, 'description' => 'SFTP only: the UUID of the private key used to log in. Null with a password login.'],
+ 'sftp_host_key' => ['type' => 'string', 'nullable' => true, 'description' => 'SFTP only: the pinned host key of the server, as " ". Saved by the first successful connection test.'],
'is_usable' => ['type' => 'boolean'],
'team_id' => ['type' => 'integer'],
'created_at' => ['type' => 'string'],
@@ -217,17 +296,19 @@ class S3StoragesController extends Controller
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
- required: ['name', 'endpoint', 'bucket', 'region', 'key', 'secret'],
+ required: ['name', 'endpoint', 'key'],
properties: [
+ 'type' => ['type' => 'string', 'enum' => ['s3', 'sftp', 'ftps', 'webdav'], 'default' => 's3', 'description' => 'The storage protocol. It cannot change later.'],
'name' => ['type' => 'string', 'example' => 'My S3 Storage', 'description' => 'A friendly name for the storage.'],
'description' => ['type' => 'string', 'nullable' => true, 'description' => 'Optional description.'],
- 'endpoint' => ['type' => 'string', 'example' => 'https://s3.us-east-1.amazonaws.com', 'description' => 'S3-compatible endpoint URL.'],
- 'bucket' => ['type' => 'string', 'example' => 'my-bucket', 'description' => 'S3 bucket name.'],
- 'region' => ['type' => 'string', 'example' => 'us-east-1', 'description' => 'S3 region.'],
+ 'endpoint' => ['type' => 'string', 'example' => 'https://s3.us-east-1.amazonaws.com', 'description' => 'S3: the S3-compatible endpoint URL. SFTP and FTPS: sftp://host:port/directory or ftps://host:port/directory (explicit TLS); the directory is an absolute path, and without one the login directory is used. WebDAV: the https URL of the backup directory.'],
+ 'bucket' => ['type' => 'string', 'example' => 'my-bucket', 'description' => 'S3 bucket name. Required for S3, not allowed for the other types.'],
+ 'region' => ['type' => 'string', 'example' => 'us-east-1', 'description' => 'S3 region. Required for S3, not allowed for the other types.'],
'use_path_style_endpoint' => ['type' => 'boolean', 'description' => 'Use path-style URLs (endpoint/bucket). Set to false for providers that only accept virtual-hosted-style URLs (bucket.endpoint). Default: false for Alibaba Cloud OSS, Tencent Cloud COS, and Volcengine TOS endpoints, true for all other endpoints.'],
- 'skip_tls_verification' => ['type' => 'boolean', 'description' => 'Do not check the TLS certificate of the endpoint. Use only for endpoints with a self-signed or private CA certificate. Default: false.'],
- 'key' => ['type' => 'string', 'description' => 'Access key.'],
- 'secret' => ['type' => 'string', 'description' => 'Secret key.'],
+ 'skip_tls_verification' => ['type' => 'boolean', 'description' => 'S3 only. Do not check the TLS certificate of the endpoint. Use only for endpoints with a self-signed or private CA certificate. Default: false.'],
+ 'key' => ['type' => 'string', 'description' => 'S3: the access key. Other types: the username.'],
+ 'secret' => ['type' => 'string', 'description' => 'S3: the secret key. Other types: the password. SFTP: not needed with private_key_uuid.'],
+ 'private_key_uuid' => ['type' => 'string', 'nullable' => true, 'description' => 'SFTP only: the UUID of a private key of your team to log in with instead of a password.'],
'is_usable' => ['type' => 'boolean', 'description' => 'Whether the storage is marked usable.'],
],
),
@@ -264,7 +345,6 @@ class S3StoragesController extends Controller
)]
public function store(Request $request)
{
- $allowedFields = ['name', 'description', 'endpoint', 'bucket', 'region', 'key', 'secret', 'use_path_style_endpoint', 'skip_tls_verification', 'is_usable'];
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@@ -278,15 +358,16 @@ class S3StoragesController extends Controller
}
$body = $request->json()->all();
+ $rawType = $body['type'] ?? 's3';
+ $type = is_string($rawType) ? BackupStorageType::tryFrom($rawType) : null;
+ $isS3 = $type === BackupStorageType::S3;
+ $connectionFields = $this->connectionFields($type ?? BackupStorageType::S3);
- $validationError = $this->validateBody($body, $allowedFields, [
+ $validationError = $this->validateBody($body, ['type', 'name', 'description', 'is_usable', ...$connectionFields], [
+ 'type' => ['sometimes', Rule::enum(BackupStorageType::class)],
'name' => ValidationPatterns::nameRules(),
'description' => ValidationPatterns::descriptionRules(),
- 'endpoint' => ['required', 'string', 'max:255', new SafeWebhookUrl],
- 'bucket' => ['required', new ValidS3BucketName],
- 'region' => 'required|string|max:255',
- 'key' => 'required|string|max:255',
- 'secret' => 'required|string|max:255',
+ ...$this->connectionRules($type ?? BackupStorageType::S3, $teamId),
'use_path_style_endpoint' => 'sometimes|boolean',
'skip_tls_verification' => 'sometimes|boolean',
'is_usable' => 'sometimes|boolean',
@@ -294,18 +375,24 @@ class S3StoragesController extends Controller
if ($validationError instanceof JsonResponse) {
return $validationError;
}
+ $login = $type === BackupStorageType::Sftp ? $this->sftpLoginAttributes($body, $teamId) : [];
+ if ($login instanceof JsonResponse) {
+ return $login;
+ }
$storage = S3Storage::create([
'team_id' => $teamId,
+ 'type' => $type,
'name' => $body['name'],
'description' => $body['description'] ?? null,
'endpoint' => $body['endpoint'],
- 'bucket' => $body['bucket'],
- 'region' => $body['region'],
+ 'bucket' => $isS3 ? $body['bucket'] : null,
+ 'region' => $isS3 ? $body['region'] : 'us-east-1',
'key' => $body['key'],
- 'secret' => $body['secret'],
- 'use_path_style_endpoint' => $body['use_path_style_endpoint'] ?? S3Storage::detectPathStyleEndpoint($body['endpoint']),
- 'skip_tls_verification' => $body['skip_tls_verification'] ?? false,
+ 'secret' => $body['secret'] ?? '',
+ ...$login,
+ 'use_path_style_endpoint' => $isS3 ? ($body['use_path_style_endpoint'] ?? S3Storage::detectPathStyleEndpoint($body['endpoint'])) : true,
+ 'skip_tls_verification' => $isS3 && ($body['skip_tls_verification'] ?? false),
'is_usable' => $body['is_usable'] ?? false,
]);
@@ -322,7 +409,7 @@ class S3StoragesController extends Controller
#[OA\Patch(
summary: 'Update S3 Storage',
- description: 'Update S3 storage by UUID.',
+ description: 'Update S3 storage by UUID. The type cannot change, and only the fields of the storage type are accepted.',
path: '/s3-storages/{uuid}',
operationId: 'update-s3-storage-by-uuid',
security: [
@@ -342,13 +429,14 @@ class S3StoragesController extends Controller
properties: [
'name' => ['type' => 'string', 'description' => 'A friendly name for the storage.'],
'description' => ['type' => 'string', 'nullable' => true, 'description' => 'Optional description.'],
- 'endpoint' => ['type' => 'string', 'description' => 'S3-compatible endpoint URL.'],
- 'bucket' => ['type' => 'string', 'description' => 'S3 bucket name.'],
- 'region' => ['type' => 'string', 'description' => 'S3 region.'],
+ 'endpoint' => ['type' => 'string', 'description' => 'The endpoint URL, in the format of the storage type (see Create S3 Storage).'],
+ 'bucket' => ['type' => 'string', 'description' => 'S3 bucket name. Only for S3 storages.'],
+ 'region' => ['type' => 'string', 'description' => 'S3 region. Only for S3 storages.'],
'use_path_style_endpoint' => ['type' => 'boolean', 'description' => 'Use path-style URLs (endpoint/bucket). Set to false for providers that only accept virtual-hosted-style URLs (bucket.endpoint), such as Alibaba Cloud OSS.'],
- 'skip_tls_verification' => ['type' => 'boolean', 'description' => 'Do not check the TLS certificate of the endpoint. Use only for endpoints with a self-signed or private CA certificate. Default: false.'],
- 'key' => ['type' => 'string', 'description' => 'Access key.'],
- 'secret' => ['type' => 'string', 'description' => 'Secret key.'],
+ 'skip_tls_verification' => ['type' => 'boolean', 'description' => 'S3 only. Do not check the TLS certificate of the endpoint. Use only for endpoints with a self-signed or private CA certificate. Default: false.'],
+ 'key' => ['type' => 'string', 'description' => 'S3: the access key. Other types: the username.'],
+ 'secret' => ['type' => 'string', 'description' => 'S3: the secret key. Other types: the password. SFTP: sending a password switches the login to the password.'],
+ 'private_key_uuid' => ['type' => 'string', 'nullable' => true, 'description' => 'SFTP only: the UUID of a private key of your team to log in with. Null switches the login to the saved password.'],
'is_usable' => ['type' => 'boolean', 'description' => 'Whether the storage is marked usable.'],
],
),
@@ -385,8 +473,6 @@ class S3StoragesController extends Controller
)]
public function update(Request $request)
{
- $allowedFields = ['name', 'description', 'endpoint', 'bucket', 'region', 'key', 'secret', 'use_path_style_endpoint', 'skip_tls_verification', 'is_usable'];
-
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
@@ -399,14 +485,17 @@ class S3StoragesController extends Controller
$body = $request->json()->all();
+ $storage = S3Storage::ownedByCurrentTeamAPI($teamId)->whereUuid($request->route('uuid'))->first();
+ if (! $storage) {
+ return response()->json(['message' => 'S3 storage not found.'], 404);
+ }
+ $this->authorize('update', $storage);
+
+ $allowedFields = ['name', 'description', 'is_usable', ...$this->connectionFields($storage->type)];
$validationError = $this->validateBody($body, $allowedFields, [
'name' => ValidationPatterns::nameRules(required: false),
'description' => ValidationPatterns::descriptionRules(),
- 'endpoint' => ['sometimes', 'string', 'max:255', new SafeWebhookUrl],
- 'bucket' => ['sometimes', new ValidS3BucketName],
- 'region' => 'sometimes|string|max:255',
- 'key' => 'sometimes|string|max:255',
- 'secret' => 'sometimes|string|max:255',
+ ...$this->connectionRules($storage->type, $teamId, partial: true, trustedInternalHosts: $storage->trustedInternalHosts(), savedEndpoint: $storage->endpoint),
'use_path_style_endpoint' => 'sometimes|boolean',
'skip_tls_verification' => 'sometimes|boolean',
'is_usable' => 'sometimes|boolean',
@@ -414,14 +503,17 @@ class S3StoragesController extends Controller
if ($validationError instanceof JsonResponse) {
return $validationError;
}
-
- $storage = S3Storage::ownedByCurrentTeamAPI($teamId)->whereUuid($request->route('uuid'))->first();
- if (! $storage) {
- return response()->json(['message' => 'S3 storage not found.'], 404);
+ $login = $storage->type === BackupStorageType::Sftp ? $this->sftpLoginAttributes($body, $teamId, $storage->secret) : [];
+ if ($login instanceof JsonResponse) {
+ return $login;
}
- $this->authorize('update', $storage);
- $storage->update(array_intersect_key($body, array_flip($allowedFields)));
+ $attributes = array_intersect_key($body, array_flip($allowedFields));
+ unset($attributes['private_key_uuid']);
+ if ($storage->type === BackupStorageType::Sftp && blank($attributes['secret'] ?? null)) {
+ unset($attributes['secret']);
+ }
+ $storage->update([...$attributes, ...$login]);
auditLog('api.s3_storage.updated', [
'team_id' => $teamId,
@@ -513,7 +605,7 @@ class S3StoragesController extends Controller
#[OA\Post(
summary: 'Validate S3 Storage',
- description: 'Validate an S3 storage connection using ListObjectsV2.',
+ description: 'Validate a storage connection. S3: lists the bucket (ListObjectsV2). SFTP, FTPS, and WebDAV: lists the backup directory; for SFTP, the first successful validation pins the host key of the server.',
path: '/s3-storages/{uuid}/validate',
operationId: 'validate-s3-storage-by-uuid',
security: [
@@ -574,7 +666,7 @@ class S3StoragesController extends Controller
return response()->json([
'valid' => $validation['valid'],
- 'message' => $validation['valid'] ? 'S3 storage connection is valid.' : $validation['error'],
+ 'message' => $validation['valid'] ? $storage->storageType()->label().' storage connection is valid.' : $validation['error'],
]);
}
}
diff --git a/app/Http/Controllers/Api/SecurityController.php b/app/Http/Controllers/Api/SecurityController.php
index cd334f17a1..c759b79245 100644
--- a/app/Http/Controllers/Api/SecurityController.php
+++ b/app/Http/Controllers/Api/SecurityController.php
@@ -7,6 +7,7 @@ use App\Models\PrivateKey;
use App\Support\ValidationPatterns;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
+use Illuminate\Validation\ValidationException;
use OpenApi\Attributes as OA;
class SecurityController extends Controller
@@ -208,21 +209,16 @@ class SecurityController extends Controller
$request->offsetSet('description', 'Created by Coolify via API');
}
+ // A key that is not PEM text can be sent base64 encoded. Other text is validated as it is.
$isPrivateKeyString = str_starts_with($request->private_key, '-----BEGIN');
- if (! $isPrivateKeyString) {
- try {
- $base64PrivateKey = base64_decode($request->private_key);
- $request->offsetSet('private_key', $base64PrivateKey);
- } catch (\Exception $e) {
- return response()->json([
- 'message' => 'Invalid private key.',
- ], 422);
- }
+ $base64PrivateKey = $isPrivateKeyString ? false : base64_decode($request->private_key, true);
+ if ($base64PrivateKey !== false) {
+ $request->offsetSet('private_key', $base64PrivateKey);
}
- $isPrivateKeyValid = PrivateKey::validatePrivateKey($request->private_key);
- if (! $isPrivateKeyValid) {
+ $privateKeyError = PrivateKey::validationError($request->private_key);
+ if ($privateKeyError) {
return response()->json([
- 'message' => 'Invalid private key.',
+ 'message' => $privateKeyError,
], 422);
}
$fingerPrint = PrivateKey::generateFingerprint($request->private_key);
@@ -352,7 +348,14 @@ class SecurityController extends Controller
], 404);
}
$this->authorize('update', $foundKey);
- $foundKey->update($request->only($allowedFields));
+ try {
+ $foundKey->update($request->only($allowedFields));
+ } catch (ValidationException $e) {
+ return response()->json([
+ 'message' => $e->getMessage(),
+ 'errors' => $e->errors(),
+ ], 422);
+ }
auditLog('api.private_key.updated', [
'team_id' => $teamId,
diff --git a/app/Http/Controllers/Api/SentinelController.php b/app/Http/Controllers/Api/SentinelController.php
index 4150fe17c3..f4193e89eb 100644
--- a/app/Http/Controllers/Api/SentinelController.php
+++ b/app/Http/Controllers/Api/SentinelController.php
@@ -34,7 +34,12 @@ class SentinelController extends Controller
}
$naked_token = str_replace('Bearer ', '', $token);
try {
- $decrypted = decrypt($naked_token);
+ // Tokens are encrypt(json) values, so the payload is a serialized string.
+ // Unserialize it without classes: the endpoint has no login yet.
+ $decrypted = unserialize(decrypt($naked_token, false), ['allowed_classes' => false]);
+ if (! is_string($decrypted)) {
+ throw new Exception('Sentinel token payload is not a string.');
+ }
$decrypted_token = json_decode($decrypted, true);
} catch (Exception $e) {
auditLogWebhookFailure('sentinel', 'decrypt_failed');
@@ -83,7 +88,7 @@ class SentinelController extends Controller
return response()->json(['message' => 'Server is not functional'], 401, ['Retry-After' => 300]);
}
- if ($server->settings->sentinel_token !== $naked_token) {
+ if (! hash_equals((string) $server->settings->sentinel_token, (string) $naked_token)) {
auditLogWebhookFailure('sentinel', 'token_mismatch', [
'server_uuid' => $server->uuid,
'team_id' => $server->team_id,
diff --git a/app/Http/Controllers/Api/ServerImageScanningController.php b/app/Http/Controllers/Api/ServerImageScanningController.php
new file mode 100644
index 0000000000..40b7fbaca0
--- /dev/null
+++ b/app/Http/Controllers/Api/ServerImageScanningController.php
@@ -0,0 +1,176 @@
+ (bool) InstanceSettings::get()->is_image_scanning_allowed,
+ 'is_image_scan_enabled' => (bool) $server->settings->is_image_scan_enabled,
+ 'scanner_state' => is_string($state) ? $state : null,
+ 'scanner_failed_step' => is_string($failedStep) ? $failedStep : null,
+ 'scanner_built_at' => is_string($builtAt) ? CarbonImmutable::parse($builtAt)->toIso8601String() : null,
+ 'scanner_disk_used_bytes' => is_int($diskUsed) ? $diskUsed : null,
+ ];
+ }
+
+ private function findServer(Request $request): ?Server
+ {
+ $teamId = getTeamIdFromToken();
+
+ return is_null($teamId) ? null : Server::whereTeamId($teamId)->whereUuid($request->route('uuid'))->first();
+ }
+
+ #[OA\Get(
+ summary: 'Get image scanning settings',
+ description: 'Get the image vulnerability scanning settings of a server owned by the authenticated team, and the state of the scanner on the server. Needs a team admin or owner.',
+ path: '/servers/{uuid}/image-scanning',
+ operationId: 'get-server-image-scanning',
+ security: [['bearerAuth' => []]],
+ tags: ['Servers'],
+ parameters: [
+ new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')),
+ ],
+ responses: [
+ new OA\Response(
+ response: 200,
+ description: 'Image scanning settings.',
+ content: new OA\JsonContent(
+ properties: [
+ new OA\Property(property: 'is_image_scanning_allowed', type: 'boolean', readOnly: true, description: 'Whether the Coolify instance allows image scanning.'),
+ new OA\Property(property: 'is_image_scan_enabled', type: 'boolean', description: 'Whether the server scans the images of its resources.'),
+ new OA\Property(property: 'scanner_state', type: 'string', nullable: true, enum: ['pulling', 'updating_database', 'ready', 'failed'], description: 'State of the scanner on the server. Null when unknown.'),
+ new OA\Property(property: 'scanner_failed_step', type: 'string', nullable: true, description: 'The step that failed when scanner_state is failed.'),
+ new OA\Property(property: 'scanner_built_at', type: 'string', format: 'date-time', nullable: true, description: 'Time of the vulnerability database on the server.'),
+ new OA\Property(property: 'scanner_disk_used_bytes', type: 'integer', nullable: true, description: 'Disk space that the scanner uses on the server.'),
+ ],
+ type: 'object',
+ ),
+ ),
+ new OA\Response(response: 401, ref: '#/components/responses/401'),
+ new OA\Response(response: 400, ref: '#/components/responses/400'),
+ new OA\Response(response: 403, description: 'Needs a team admin or owner.'),
+ new OA\Response(response: 404, ref: '#/components/responses/404'),
+ ],
+ )]
+ public function show(Request $request): JsonResponse
+ {
+ if (is_null(getTeamIdFromToken())) {
+ return invalidTokenResponse();
+ }
+
+ $server = $this->findServer($request);
+ if (! $server) {
+ return response()->json(['message' => 'Server not found.'], 404);
+ }
+
+ $this->authorize('viewSecurity', $server);
+
+ return response()->json($this->transform($server));
+ }
+
+ #[OA\Patch(
+ summary: 'Update image scanning settings',
+ description: 'Turn image vulnerability scanning of a server owned by the authenticated team on or off. Turning it on gets the scanner on the server in the background.',
+ path: '/servers/{uuid}/image-scanning',
+ operationId: 'update-server-image-scanning',
+ security: [['bearerAuth' => []]],
+ tags: ['Servers'],
+ parameters: [
+ new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')),
+ ],
+ requestBody: new OA\RequestBody(
+ required: true,
+ content: new OA\JsonContent(
+ required: ['is_image_scan_enabled'],
+ properties: [
+ new OA\Property(property: 'is_image_scan_enabled', type: 'boolean'),
+ ],
+ type: 'object',
+ ),
+ ),
+ responses: [
+ new OA\Response(response: 200, description: 'Updated image scanning settings.'),
+ new OA\Response(response: 401, ref: '#/components/responses/401'),
+ new OA\Response(response: 400, ref: '#/components/responses/400'),
+ new OA\Response(response: 403, description: 'Needs a team admin or owner.'),
+ new OA\Response(response: 404, ref: '#/components/responses/404'),
+ new OA\Response(response: 409, description: 'The instance does not allow image scanning, or another Coolify instance manages the server.'),
+ new OA\Response(response: 422, ref: '#/components/responses/422'),
+ ],
+ )]
+ public function update(Request $request): JsonResponse
+ {
+ $teamId = getTeamIdFromToken();
+ if (is_null($teamId)) {
+ return invalidTokenResponse();
+ }
+
+ $return = validateIncomingRequest($request);
+ if ($return instanceof JsonResponse) {
+ return $return;
+ }
+
+ $server = $this->findServer($request);
+ if (! $server) {
+ return response()->json(['message' => 'Server not found.'], 404);
+ }
+
+ $this->authorize('update', $server);
+
+ $validator = customApiValidator($request->all(), [
+ 'is_image_scan_enabled' => 'required|boolean',
+ ]);
+ $extraFields = array_diff(array_keys($request->all()), self::ALLOWED_FIELDS);
+ if ($validator->fails() || ! empty($extraFields)) {
+ $errors = $validator->errors();
+ foreach ($extraFields as $field) {
+ $errors->add($field, 'This field is not allowed.');
+ }
+
+ return response()->json([
+ 'message' => 'Validation failed.',
+ 'errors' => $errors,
+ ], 422);
+ }
+
+ $enabled = $request->boolean('is_image_scan_enabled');
+ try {
+ SetServerImageScanning::run($server, $enabled);
+ } catch (ImageScanningRefused $e) {
+ return response()->json(['message' => $e->getMessage()], 409);
+ }
+
+ auditLog($enabled ? 'api.server.image_scanning_enabled' : 'api.server.image_scanning_disabled', [
+ 'team_id' => $teamId,
+ 'server_uuid' => $server->uuid,
+ 'server_name' => $server->name,
+ ]);
+
+ return response()->json($this->transform($server->refresh()));
+ }
+}
diff --git a/app/Http/Controllers/Api/ServersController.php b/app/Http/Controllers/Api/ServersController.php
index 761050a34a..df405b81dd 100644
--- a/app/Http/Controllers/Api/ServersController.php
+++ b/app/Http/Controllers/Api/ServersController.php
@@ -2,20 +2,24 @@
namespace App\Http\Controllers\Api;
+use App\Actions\Server\AuthorizedKeys;
use App\Actions\Server\DeleteServer;
use App\Actions\Server\ValidateServer;
use App\Enums\ProxyStatus;
use App\Enums\ProxyTypes;
use App\Enums\ServerRole;
+use App\Helpers\SshMultiplexingHelper;
use App\Http\Controllers\Api\Concerns\RequiresDeployAbility;
use App\Http\Controllers\Controller;
use App\Jobs\DeleteResourceJob;
+use App\Jobs\ServerConnectionCheckJob;
use App\Jobs\ValidateAndInstallServerJob;
use App\Models\Application;
use App\Models\CloudProviderToken;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server as ModelsServer;
+use App\Models\ServerSetting;
use App\Models\Team;
use App\Rules\ValidServerIp;
use App\Services\ServerTransfer\ServerTransferClaimer;
@@ -50,7 +54,9 @@ class ServersController extends Controller
}
return serializeApiResponse($settings)
- ->put('is_build_server', $settings->effectiveServerRole() === ServerRole::BUILD);
+ ->put('is_build_server', $settings->effectiveServerRole() === ServerRole::BUILD)
+ ->put('ssh_host_key_fingerprints', ServerSetting::sshHostKeyFingerprints($settings->ssh_host_key))
+ ->put('ssh_host_key_pending_fingerprints', ServerSetting::sshHostKeyFingerprints($settings->ssh_host_key_pending));
}
/**
@@ -499,6 +505,7 @@ class ServersController extends Controller
'is_build_server' => ['type' => 'boolean', 'deprecated' => true, 'description' => 'Deprecated: use server_role instead. true sets server_role to build, false sets it to both. Must not conflict with server_role.'],
'instant_validate' => ['type' => 'boolean', 'example' => false, 'description' => 'Instant validate.'],
'proxy_type' => ['type' => 'string', 'enum' => ['traefik', 'caddy', 'none'], 'example' => 'traefik', 'description' => 'The proxy type.'],
+ 'ssh_host_key' => ['type' => 'string', 'example' => 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA...', 'description' => 'The SSH host key(s) of the server, as public key lines (for example the content of /etc/ssh/ssh_host_ed25519_key.pub) or known_hosts lines. Without it, Coolify trusts the key that the server sends on the first connection.'],
],
),
),
@@ -538,7 +545,7 @@ class ServersController extends Controller
)]
public function create_server(Request $request)
{
- $allowedFields = ['name', 'description', 'ip', 'port', 'user', 'private_key_uuid', 'server_role', 'is_build_server', 'instant_validate', 'proxy_type'];
+ $allowedFields = ['name', 'description', 'ip', 'port', 'user', 'private_key_uuid', 'server_role', 'is_build_server', 'instant_validate', 'proxy_type', 'ssh_host_key'];
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@@ -561,6 +568,7 @@ class ServersController extends Controller
'is_build_server' => 'boolean|nullable',
'instant_validate' => 'boolean|nullable',
'proxy_type' => 'string|nullable',
+ 'ssh_host_key' => 'string|nullable|max:16384',
], [
...ValidationPatterns::serverUsernameMessages(),
...ValidationPatterns::combinedMessages(),
@@ -568,8 +576,13 @@ class ServersController extends Controller
]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
- if ($validator->fails() || ! empty($extraFields)) {
+ $rawSshHostKey = $request->input('ssh_host_key');
+ $sshHostKey = is_string($rawSshHostKey) ? ServerSetting::normalizeSshHostKeys($rawSshHostKey) : null;
+ if ($validator->fails() || ! empty($extraFields) || ($request->filled('ssh_host_key') && $sshHostKey === null)) {
$errors = $validator->errors();
+ if ($request->filled('ssh_host_key') && $sshHostKey === null && ! $errors->has('ssh_host_key')) {
+ $errors->add('ssh_host_key', 'The SSH host key must contain public key lines ("type base64") or known_hosts lines.');
+ }
if (! empty($extraFields)) {
foreach ($extraFields as $field) {
$errors->add($field, 'This field is not allowed.');
@@ -651,6 +664,13 @@ class ServersController extends Controller
'server_role' => $serverRole,
'is_build_server' => $serverRole === ServerRole::BUILD,
]);
+ if ($sshHostKey !== null) {
+ $server->settings->refresh()->forceFill([
+ 'ssh_host_key' => $sshHostKey,
+ 'ssh_host_key_source' => 'manual',
+ 'ssh_host_key_trusted_at' => now(),
+ ])->save();
+ }
if ($request->instant_validate) {
ValidateServer::dispatch($server);
}
@@ -965,6 +985,13 @@ class ServersController extends Controller
required: false,
schema: new OA\Schema(type: 'boolean', default: false)
),
+ new OA\Parameter(
+ name: 'remove_ssh_key',
+ in: 'query',
+ description: 'Remove the public key of the server\'s SSH key from ~/.ssh/authorized_keys on the server before the server is deleted. The server must have no resources. If the key cannot be removed, the server is not deleted.',
+ required: false,
+ schema: new OA\Schema(type: 'boolean', default: false)
+ ),
],
responses: [
new OA\Response(response: 403, description: 'The token needs the deploy permission to delete the resources or the cloud server.'),
@@ -1022,11 +1049,15 @@ class ServersController extends Controller
if (is_null($deleteFromProvider)) {
return response()->json(['message' => 'delete_from_provider must be a boolean.'], 422);
}
+ $removeSshKey = filter_var($request->query('remove_ssh_key', false), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
+ if (is_null($removeSshKey)) {
+ return response()->json(['message' => 'remove_ssh_key must be a boolean.'], 422);
+ }
// Another Coolify instance manages a transferred server. This instance deletes only its own records:
// never the cloud server, the containers, or the DNS records that the other instance uses.
if ($server->isTransferredAway()) {
- if ($deleteFromProvider) {
- return response()->json(['message' => 'This server is managed by another Coolify instance. Delete its cloud server from that instance.'], 422);
+ if ($deleteFromProvider || $removeSshKey) {
+ return response()->json(['message' => 'This server is managed by another Coolify instance. Delete its cloud server or SSH key from that instance.'], 422);
}
app(ServerTransferClaimer::class)->deleteArchive($server);
auditLog('api.server.deleted', ['team_id' => $teamId, 'server_uuid' => $server->uuid, 'server_name' => $server->name, 'ip' => $server->ip, 'archive' => true]);
@@ -1062,6 +1093,22 @@ class ServersController extends Controller
return $response;
}
+ // The key is removed before the server record, while the connection still works.
+ if ($removeSshKey && ! $deleteFromProvider) {
+ if ($server->definedResources()->count() > 0) {
+ return response()->json(['message' => 'Delete the resources of the server first, then delete the server with remove_ssh_key.'], 422);
+ }
+ $context = ['team_id' => $teamId, 'server_uuid' => $server->uuid, 'server_name' => $server->name, 'private_key_uuid' => $server->privateKey?->uuid];
+ try {
+ $removed = AuthorizedKeys::remove($server, $server->privateKey);
+ } catch (\Throwable $e) {
+ auditLog('api.server.ssh_key_removed', [...$context, 'result' => 'failed'], 'warning');
+
+ return response()->json(['message' => 'The SSH key was not removed from the server, so the server was not deleted: '.remove_iip($e->getMessage())], 422);
+ }
+ auditLog('api.server.ssh_key_removed', [...$context, 'result' => $removed ? 'removed' : 'kept_for_other_server']);
+ }
+
// Applications that use the server only as an additional destination keep running on their other servers.
$server->detachAdditionalApplications();
if ($force) {
@@ -1093,6 +1140,7 @@ class ServersController extends Controller
'ip' => $deletedIp,
'force' => $force,
'delete_from_provider' => $deleteFromProvider,
+ 'remove_ssh_key' => $removeSshKey,
]);
return response()->json(['message' => 'Server deleted.']);
@@ -1213,6 +1261,127 @@ class ServersController extends Controller
return response()->json(['message' => $message], 201);
}
+ #[OA\Post(
+ summary: 'Reset host key',
+ description: 'Remove the trusted SSH host key of the server, or trust the given key. Without ssh_host_key, Coolify trusts the key that the server sends on the next connection.',
+ path: '/servers/{uuid}/host-key/reset',
+ operationId: 'reset-server-host-key-by-uuid',
+ security: [
+ ['bearerAuth' => []],
+ ],
+ tags: ['Servers'],
+ parameters: [
+ new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')),
+ ],
+ requestBody: new OA\RequestBody(
+ required: false,
+ content: new OA\JsonContent(
+ properties: [
+ new OA\Property(
+ property: 'ssh_host_key',
+ description: 'The SSH host key(s) to trust, as public key lines or known_hosts lines.',
+ type: 'string',
+ ),
+ ],
+ ),
+ ),
+ responses: [
+ new OA\Response(
+ response: 200,
+ description: 'Host key reset.',
+ content: [
+ new OA\MediaType(
+ mediaType: 'application/json',
+ schema: new OA\Schema(
+ type: 'object',
+ properties: [
+ 'message' => ['type' => 'string', 'example' => 'Host key reset.'],
+ 'ssh_host_key_fingerprints' => ['type' => 'array', 'items' => ['type' => 'string']],
+ ]
+ )
+ ),
+ ]),
+ new OA\Response(
+ response: 401,
+ ref: '#/components/responses/401',
+ ),
+ new OA\Response(
+ response: 403,
+ description: 'Only team admins and owners can reset the host key.',
+ ),
+ new OA\Response(
+ response: 400,
+ ref: '#/components/responses/400',
+ ),
+ new OA\Response(
+ response: 404,
+ ref: '#/components/responses/404',
+ ),
+ new OA\Response(
+ response: 422,
+ ref: '#/components/responses/422',
+ ),
+ ]
+ )]
+ public function reset_host_key(Request $request)
+ {
+ $teamId = getTeamIdFromToken();
+ if (is_null($teamId)) {
+ return invalidTokenResponse();
+ }
+ $server = ModelsServer::whereTeamId($teamId)->whereUuid($request->uuid)->first();
+ if (! $server) {
+ return response()->json(['message' => 'Server not found.'], 404);
+ }
+ $this->authorize('update', $server);
+ if (! $server->canBeValidated()) {
+ return response()->json([
+ 'message' => 'This server was transferred to another Coolify instance.',
+ ], 422);
+ }
+
+ $validator = customApiValidator($request->all(), [
+ 'ssh_host_key' => 'string|nullable|max:16384',
+ ]);
+ $rawSshHostKey = $request->input('ssh_host_key');
+ $sshHostKey = is_string($rawSshHostKey) ? ServerSetting::normalizeSshHostKeys($rawSshHostKey) : null;
+ if ($validator->fails() || ($request->filled('ssh_host_key') && $sshHostKey === null)) {
+ $errors = $validator->errors();
+ if (! $errors->has('ssh_host_key')) {
+ $errors->add('ssh_host_key', 'The SSH host key must contain public key lines ("type base64") or known_hosts lines.');
+ }
+
+ return response()->json([
+ 'message' => 'Validation failed.',
+ 'errors' => $errors,
+ ], 422);
+ }
+
+ $settings = $server->settings;
+ $previousFingerprints = ServerSetting::sshHostKeyFingerprints($settings->ssh_host_key);
+ $settings->forceFill([
+ 'ssh_host_key' => $sshHostKey,
+ 'ssh_host_key_source' => $sshHostKey ? 'manual' : null,
+ 'ssh_host_key_trusted_at' => $sshHostKey ? now() : null,
+ 'ssh_host_key_pending' => null,
+ ])->save();
+ SshMultiplexingHelper::removeMuxFile($server);
+ ServerConnectionCheckJob::dispatch($server);
+
+ auditLog('api.server.ssh_host_key.reset', [
+ 'team_id' => $teamId,
+ 'server_uuid' => $server->uuid,
+ 'server_name' => $server->name,
+ 'previous_fingerprints' => $previousFingerprints,
+ 'fingerprints' => ServerSetting::sshHostKeyFingerprints($sshHostKey),
+ ]);
+
+ return response()->json([
+ 'message' => 'Host key reset.',
+ 'ssh_host_key_fingerprints' => ServerSetting::sshHostKeyFingerprints($sshHostKey),
+ ]);
+ }
+
/**
* The host of a domain without the port: "[2a01:4f8::1]:8080" gives "[2a01:4f8::1]", "example.com:3000" gives "example.com".
*/
diff --git a/app/Http/Controllers/Api/TeamController.php b/app/Http/Controllers/Api/TeamController.php
index 98274e2d0d..ece5b4c2f8 100644
--- a/app/Http/Controllers/Api/TeamController.php
+++ b/app/Http/Controllers/Api/TeamController.php
@@ -238,12 +238,16 @@ class TeamController extends Controller
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
- required: ['is_build_server_fallback_enabled'],
+ minProperties: 1,
properties: [
'is_build_server_fallback_enabled' => [
'type' => 'boolean',
'description' => 'Whether deployments can fall back to the deployment server when no usable dedicated build server is available.',
],
+ 'scan_images_on_new_servers' => [
+ 'type' => 'boolean',
+ 'description' => 'Whether image scanning is turned on for servers added to the team from now on.',
+ ],
],
),
),
@@ -269,9 +273,10 @@ class TeamController extends Controller
$this->authorize('update', $team);
- $allowedFields = ['is_build_server_fallback_enabled'];
+ $allowedFields = ['is_build_server_fallback_enabled', 'scan_images_on_new_servers'];
$validator = customApiValidator($request->all(), [
- 'is_build_server_fallback_enabled' => ['required', 'boolean'],
+ 'is_build_server_fallback_enabled' => ['required_without:scan_images_on_new_servers', 'boolean'],
+ 'scan_images_on_new_servers' => ['required_without:is_build_server_fallback_enabled', 'boolean'],
]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
if ($validator->fails() || ! empty($extraFields)) {
diff --git a/app/Http/Controllers/Api/VulnerabilitiesController.php b/app/Http/Controllers/Api/VulnerabilitiesController.php
new file mode 100644
index 0000000000..0a5e06238b
--- /dev/null
+++ b/app/Http/Controllers/Api/VulnerabilitiesController.php
@@ -0,0 +1,398 @@
+ 4, 'high' => 3, 'medium' => 2, 'low' => 1, 'unknown' => 0];
+
+ private const FINDING_STATES = ['fixed', 'no_fix', 'ignored'];
+
+ #[OA\Get(
+ summary: 'List vulnerabilities',
+ description: 'List the applications, services, and databases of the authenticated team with their stored security status from image scanning.',
+ path: '/vulnerabilities',
+ operationId: 'list-vulnerabilities',
+ security: [['bearerAuth' => []]],
+ tags: ['Vulnerabilities'],
+ parameters: [
+ new OA\Parameter(name: 'status', in: 'query', required: false, description: 'Comma-separated statuses to return.', schema: new OA\Schema(type: 'string', example: 'update_now,update_available')),
+ ],
+ responses: [
+ new OA\Response(
+ response: 200,
+ description: 'Resources with their security status, worst first.',
+ content: new OA\JsonContent(
+ properties: [
+ new OA\Property(property: 'is_image_scanning_allowed', type: 'boolean'),
+ new OA\Property(property: 'resources', type: 'array', items: new OA\Items(
+ properties: [
+ new OA\Property(property: 'uuid', type: 'string'),
+ new OA\Property(property: 'name', type: 'string'),
+ new OA\Property(property: 'type', type: 'string', enum: ['application', 'service', 'database']),
+ new OA\Property(property: 'project_uuid', type: 'string', nullable: true),
+ new OA\Property(property: 'environment_uuid', type: 'string', nullable: true),
+ new OA\Property(property: 'server_uuid', type: 'string', nullable: true),
+ new OA\Property(property: 'images', properties: [
+ new OA\Property(property: 'update', type: 'integer'),
+ new OA\Property(property: 'total', type: 'integer'),
+ ], type: 'object'),
+ new OA\Property(property: 'security', ref: '#/components/schemas/SecurityStatus'),
+ ],
+ type: 'object',
+ )),
+ ],
+ type: 'object',
+ ),
+ ),
+ new OA\Response(response: 401, ref: '#/components/responses/401'),
+ new OA\Response(response: 400, ref: '#/components/responses/400'),
+ ],
+ )]
+ public function index(Request $request): JsonResponse
+ {
+ $teamId = getTeamIdFromToken();
+ if (is_null($teamId)) {
+ return invalidTokenResponse();
+ }
+
+ $statuses = $this->listParameter($request, 'status', TeamVulnerabilities::STATUS_ORDER);
+ if ($statuses instanceof JsonResponse) {
+ return $statuses;
+ }
+
+ $rank = array_flip(TeamVulnerabilities::STATUS_ORDER);
+ $resources = TeamVulnerabilities::load((int) $teamId)->entries
+ ->filter(fn (array $entry): bool => $statuses === [] || in_array($entry['security']->status->value, $statuses, true))
+ ->sortBy(fn (array $entry): array => [$rank[$entry['security']->status->value] ?? PHP_INT_MAX, (string) $entry['resource']->getAttribute('name')])
+ ->map(fn (array $entry): array => [
+ 'uuid' => (string) $entry['resource']->getAttribute('uuid'),
+ 'name' => (string) $entry['resource']->getAttribute('name'),
+ 'type' => $this->resourceType($entry['resource']),
+ 'project_uuid' => $entry['project']?->uuid,
+ 'environment_uuid' => $entry['environment']?->uuid,
+ 'server_uuid' => $entry['server']?->uuid,
+ 'images' => $entry['images'],
+ 'security' => $this->security($entry['security']),
+ ])
+ ->values();
+
+ return response()->json([
+ 'is_image_scanning_allowed' => (bool) instanceSettings()->is_image_scanning_allowed,
+ 'resources' => $resources,
+ ]);
+ }
+
+ #[OA\Get(
+ summary: 'Get application vulnerabilities',
+ description: 'Get the security status, the scanned images, and the vulnerabilities of an application.',
+ path: '/applications/{uuid}/vulnerabilities',
+ operationId: 'get-application-vulnerabilities',
+ security: [['bearerAuth' => []]],
+ tags: ['Vulnerabilities'],
+ parameters: [
+ new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Application UUID', schema: new OA\Schema(type: 'string')),
+ new OA\Parameter(ref: '#/components/parameters/VulnerabilityState'),
+ new OA\Parameter(ref: '#/components/parameters/VulnerabilitySeverity'),
+ new OA\Parameter(ref: '#/components/parameters/VulnerabilityImage'),
+ new OA\Parameter(ref: '#/components/parameters/VulnerabilitySearch'),
+ new OA\Parameter(ref: '#/components/parameters/VulnerabilityPage'),
+ new OA\Parameter(ref: '#/components/parameters/VulnerabilityPerPage'),
+ ],
+ responses: [
+ new OA\Response(response: 200, description: 'Scan results.', content: new OA\JsonContent(ref: '#/components/schemas/ResourceVulnerabilities')),
+ new OA\Response(response: 401, ref: '#/components/responses/401'),
+ new OA\Response(response: 400, ref: '#/components/responses/400'),
+ new OA\Response(response: 404, ref: '#/components/responses/404'),
+ new OA\Response(response: 422, ref: '#/components/responses/422'),
+ ],
+ )]
+ public function application(Request $request): JsonResponse
+ {
+ return $this->show($request, 'application');
+ }
+
+ #[OA\Get(
+ summary: 'Get service vulnerabilities',
+ description: 'Get the security status, the scanned images, and the vulnerabilities of the applications and databases of a service.',
+ path: '/services/{uuid}/vulnerabilities',
+ operationId: 'get-service-vulnerabilities',
+ security: [['bearerAuth' => []]],
+ tags: ['Vulnerabilities'],
+ parameters: [
+ new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Service UUID', schema: new OA\Schema(type: 'string')),
+ new OA\Parameter(ref: '#/components/parameters/VulnerabilityState'),
+ new OA\Parameter(ref: '#/components/parameters/VulnerabilitySeverity'),
+ new OA\Parameter(ref: '#/components/parameters/VulnerabilityImage'),
+ new OA\Parameter(ref: '#/components/parameters/VulnerabilitySearch'),
+ new OA\Parameter(ref: '#/components/parameters/VulnerabilityPage'),
+ new OA\Parameter(ref: '#/components/parameters/VulnerabilityPerPage'),
+ ],
+ responses: [
+ new OA\Response(response: 200, description: 'Scan results.', content: new OA\JsonContent(ref: '#/components/schemas/ResourceVulnerabilities')),
+ new OA\Response(response: 401, ref: '#/components/responses/401'),
+ new OA\Response(response: 400, ref: '#/components/responses/400'),
+ new OA\Response(response: 404, ref: '#/components/responses/404'),
+ new OA\Response(response: 422, ref: '#/components/responses/422'),
+ ],
+ )]
+ public function service(Request $request): JsonResponse
+ {
+ return $this->show($request, 'service');
+ }
+
+ #[OA\Get(
+ summary: 'Get database vulnerabilities',
+ description: 'Get the security status, the scanned image, and the vulnerabilities of a standalone database.',
+ path: '/databases/{uuid}/vulnerabilities',
+ operationId: 'get-database-vulnerabilities',
+ security: [['bearerAuth' => []]],
+ tags: ['Vulnerabilities'],
+ parameters: [
+ new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Database UUID', schema: new OA\Schema(type: 'string')),
+ new OA\Parameter(ref: '#/components/parameters/VulnerabilityState'),
+ new OA\Parameter(ref: '#/components/parameters/VulnerabilitySeverity'),
+ new OA\Parameter(ref: '#/components/parameters/VulnerabilityImage'),
+ new OA\Parameter(ref: '#/components/parameters/VulnerabilitySearch'),
+ new OA\Parameter(ref: '#/components/parameters/VulnerabilityPage'),
+ new OA\Parameter(ref: '#/components/parameters/VulnerabilityPerPage'),
+ ],
+ responses: [
+ new OA\Response(response: 200, description: 'Scan results.', content: new OA\JsonContent(ref: '#/components/schemas/ResourceVulnerabilities')),
+ new OA\Response(response: 401, ref: '#/components/responses/401'),
+ new OA\Response(response: 400, ref: '#/components/responses/400'),
+ new OA\Response(response: 404, ref: '#/components/responses/404'),
+ new OA\Response(response: 422, ref: '#/components/responses/422'),
+ ],
+ )]
+ public function database(Request $request): JsonResponse
+ {
+ return $this->show($request, 'database');
+ }
+
+ private function show(Request $request, string $type): JsonResponse
+ {
+ $teamId = getTeamIdFromToken();
+ if (is_null($teamId)) {
+ return invalidTokenResponse();
+ }
+
+ $validator = customApiValidator($request->query(), [
+ 'state' => 'nullable|string',
+ 'severity' => 'nullable|string',
+ 'image' => 'nullable|string|max:1000',
+ 'search' => 'nullable|string|max:200',
+ 'page' => 'nullable|integer|min:1',
+ 'per_page' => 'nullable|integer|min:1|max:200',
+ ]);
+ if ($validator->fails()) {
+ return response()->json(['message' => 'Validation failed.', 'errors' => $validator->errors()], 422);
+ }
+ $states = $this->listParameter($request, 'state', self::FINDING_STATES);
+ $severities = $this->listParameter($request, 'severity', array_keys(self::SEVERITY_RANK));
+ foreach ([$states, $severities] as $parameter) {
+ if ($parameter instanceof JsonResponse) {
+ return $parameter;
+ }
+ }
+
+ $resource = ResourceScans::find($type, (string) $request->route('uuid'), [(int) $teamId]);
+ if (! $resource instanceof Model) {
+ return response()->json(['message' => ucfirst($type).' not found.'], 404);
+ }
+ $this->authorize('view', $resource);
+
+ $offState = ImageScanPolicyResolver::offState($resource, ImageScanPolicyResolver::for($resource)->scanServer);
+ $scans = $offState === null ? ResourceScans::current($resource) : collect();
+ $rules = [];
+ $rulesFor = function (Model $owner) use (&$rules): RelevanceRules {
+ return $rules[$owner->getMorphClass().':'.$owner->getKey()] ??= RelevanceRules::forResource($owner);
+ };
+
+ $imageStatuses = $scans->mapWithKeys(fn (ImageScan $scan): array => [(string) $scan->uuid => SecurityStatus::forScan($scan, $rulesFor($scan->resource))])->all();
+ $findings = $this->findings($scans, $rulesFor);
+
+ $image = trim((string) $request->query('image', ''));
+ $search = mb_strtolower(trim((string) $request->query('search', '')));
+ $findings = array_values(array_filter($findings, fn (array $row): bool => ($states === [] || in_array($row['state'], $states, true))
+ && ($severities === [] || in_array($row['severity'], $severities, true))
+ && ($image === '' || $row['image_ref'] === $image)
+ && ($search === '' || str_contains(mb_strtolower(implode(' ', [$row['id'], ...$row['aliases'], $row['package'], $row['version']])), $search))));
+
+ $perPage = (int) $request->query('per_page', 50);
+ $page = (int) $request->query('page', 1);
+
+ return response()->json([
+ 'uuid' => (string) $resource->getAttribute('uuid'),
+ 'type' => $type,
+ 'off_reason' => $offState,
+ 'security' => $scans->isEmpty() ? null : $this->security(SecurityStatus::forResource($resource, $scans, $imageStatuses)),
+ 'images' => $scans->map(fn (ImageScan $scan): array => [
+ 'scan_uuid' => (string) $scan->uuid,
+ 'owner_uuid' => (string) $scan->resource?->getAttribute('uuid'),
+ 'image_ref' => (string) $scan->image_ref,
+ 'scan_status' => $scan->status?->value,
+ 'error_code' => $scan->error_code?->value,
+ 'scanned_at' => $scan->scanned_at?->toIso8601String(),
+ 'truncated' => (bool) data_get($scan->summary, 'truncated', false),
+ 'security_status' => $imageStatuses[(string) $scan->uuid]->status->value,
+ ])->values(),
+ 'findings' => array_slice($findings, ($page - 1) * $perPage, $perPage),
+ 'pagination' => ['page' => $page, 'per_page' => $perPage, 'total' => count($findings)],
+ ]);
+ }
+
+ /**
+ * The findings of the current scans, worst first, in the same fix states as the UI.
+ *
+ * @param Collection $scans
+ * @param Closure(Model): RelevanceRules $rulesFor
+ * @return list>
+ */
+ private function findings(Collection $scans, Closure $rulesFor): array
+ {
+ $rows = [];
+ foreach ($scans as $scan) {
+ $owner = $scan->resource;
+ $rules = $rulesFor($owner);
+ $classify = SecurityActions::fixStates($scan, $rules);
+
+ foreach (ResourceScans::findings($scan) as $finding) {
+ $ignore = $rules->ignores->matches($finding);
+ $rows[] = $this->finding($finding, $ignore === null ? $classify($finding)->value : 'ignored', $ignore) + [
+ 'image_ref' => (string) $scan->image_ref,
+ 'owner_uuid' => (string) $owner->getAttribute('uuid'),
+ ];
+ }
+ }
+
+ usort($rows, fn (array $a, array $b): int => [$b['kev'], self::SEVERITY_RANK[$b['severity']], $b['risk'] ?? 0.0]
+ <=> [$a['kev'], self::SEVERITY_RANK[$a['severity']], $a['risk'] ?? 0.0]);
+
+ return $rows;
+ }
+
+ /**
+ * @return array
+ */
+ private function finding(Finding $finding, string $state, ?ImageScanIgnore $ignore): array
+ {
+ return [
+ 'id' => $finding->id,
+ 'display_id' => $finding->displayId,
+ 'aliases' => $finding->aliases,
+ 'title' => $finding->title,
+ 'severity' => TeamVulnerabilities::severityOf($finding),
+ 'package' => $finding->package,
+ 'version' => $finding->version,
+ 'package_type' => $finding->type,
+ 'fixed_in' => $finding->fixedIn,
+ 'state' => $state,
+ 'ignore' => $ignore === null ? null : [
+ 'reason' => $ignore->reason,
+ 'expires_at' => $ignore->expires_at?->toIso8601String(),
+ 'team_wide' => $ignore->resource_type === null,
+ ],
+ 'kev' => $finding->kev,
+ 'epss' => $finding->epss,
+ 'risk' => $finding->risk,
+ 'cvss_score' => $finding->cvssScore,
+ 'advisory_url' => ResourceScans::advisoryUrl($finding->displayId),
+ ];
+ }
+
+ /**
+ * The security status without the internal facts and record IDs.
+ *
+ * @return array
+ */
+ private function security(ResourceSecurity $security): array
+ {
+ return [
+ 'status' => $security->status->value,
+ 'label' => TeamVulnerabilities::statusLabel($security->status),
+ 'action' => TeamVulnerabilities::action($security)['text'] ?? null,
+ 'check_reason' => $security->checkReason,
+ 'problem_count' => $security->problemCount,
+ 'exploited_count' => $security->exploitedCount,
+ 'any_exploited' => $security->anyExploited,
+ 'critical_count' => $security->criticalCount,
+ 'high_count' => $security->highCount,
+ 'medium_count' => $security->mediumCount,
+ 'eol' => $security->eol,
+ 'checked_at' => $security->checkedAt?->toIso8601String(),
+ 'actions' => array_map(fn (SecurityAction $action): array => [
+ 'type' => $action->type->value,
+ 'sentence' => $action->sentence,
+ 'target' => $action->target,
+ 'copy' => $action->copy,
+ 'image' => $action->image,
+ 'ref' => $action->ref,
+ ], $security->actions),
+ ];
+ }
+
+ private function resourceType(Model $resource): string
+ {
+ return match (true) {
+ $resource instanceof Application => 'application',
+ $resource instanceof Service => 'service',
+ default => 'database',
+ };
+ }
+
+ /**
+ * A comma-separated query parameter, checked against the allowed values. Empty when not sent.
+ *
+ * @param list $allowed
+ * @return list|JsonResponse
+ */
+ private function listParameter(Request $request, string $name, array $allowed): array|JsonResponse
+ {
+ $raw = $request->query($name);
+ if ($raw === null || $raw === '') {
+ return [];
+ }
+ if (! is_string($raw)) {
+ return response()->json(['message' => 'Validation failed.', 'errors' => [$name => ["The {$name} must be a comma-separated string."]]], 422);
+ }
+
+ $values = array_values(array_unique(array_filter(array_map('trim', explode(',', strtolower($raw))))));
+ $unknown = array_diff($values, $allowed);
+ if ($unknown !== []) {
+ return response()->json(['message' => 'Validation failed.', 'errors' => [$name => ['Allowed values: '.implode(', ', $allowed).'.']]], 422);
+ }
+
+ return $values;
+ }
+}
diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php
index 4a5e4af141..37a1afdb32 100644
--- a/app/Http/Controllers/Controller.php
+++ b/app/Http/Controllers/Controller.php
@@ -139,7 +139,12 @@ class Controller extends BaseController
$user->forceFill([
'password' => Hash::make(Str::random(64)),
- ])->save();
+ ]);
+ // Only an emailed token proves the address. A copied link can be used by the inviting admin.
+ if ($invitation->via === 'email' && ! $user->hasVerifiedEmail()) {
+ $user->forceFill(['email_verified_at' => now()]);
+ }
+ $user->save();
$invitation->delete();
return [$user, $team, $invitation, $alreadyMember];
@@ -224,6 +229,11 @@ class Controller extends BaseController
abort(400, 'You are not allowed to accept this invitation.');
}
+ // A magic link session accepts only the invitation of its own link, until the user sets a password.
+ if ($user->force_password_reset) {
+ abort(400, 'Set your password before you accept another invitation.');
+ }
+
if (! $invitation->isValid()) {
abort(400, 'Invitation expired.');
}
@@ -249,6 +259,11 @@ class Controller extends BaseController
abort(400, 'You are not allowed to accept this invitation.');
}
+ // A magic link session accepts only the invitation of its own link, until the user sets a password.
+ if ($user->force_password_reset) {
+ abort(400, 'Set your password before you accept another invitation.');
+ }
+
if (! $invitation->isValid()) {
abort(400, 'Invitation expired.');
}
diff --git a/app/Http/Controllers/ImageScanSbomController.php b/app/Http/Controllers/ImageScanSbomController.php
new file mode 100644
index 0000000000..3e701518b3
--- /dev/null
+++ b/app/Http/Controllers/ImageScanSbomController.php
@@ -0,0 +1,47 @@
+route('scan_uuid');
+ $format = (string) $request->route('format');
+ abort_unless(in_array($format, SbomDocument::FORMATS, true), 404);
+
+ [$type, $uuid] = match (true) {
+ $request->route('application_uuid') !== null => ['application', (string) $request->route('application_uuid')],
+ $request->route('service_uuid') !== null => ['service', (string) $request->route('service_uuid')],
+ default => ['database', (string) $request->route('database_uuid')],
+ };
+
+ $user = $request->user();
+ $resource = ResourceScans::find($type, $uuid, $user->teams->pluck('id')->all());
+ abort_unless($resource !== null && data_get($resource, 'environment.uuid') === $request->route('environment_uuid'), 404);
+ Gate::authorize('view', $resource);
+
+ $scan = ResourceScans::current($resource)->first(fn (ImageScan $scan): bool => $scan->uuid === $scanUuid);
+ $sbom = $scan?->sbom;
+ abort_unless($scan !== null && $sbom !== null && $sbom->server_id === $scan->server_id, 404);
+
+ $document = $format === 'cyclonedx' ? SbomDocument::cycloneDx($scan, $sbom) : SbomDocument::spdx($scan, $sbom);
+ $name = Str::slug((string) $resource->getAttribute('name').'-'.$scan->image_ref) ?: 'image';
+ $extension = $format === 'cyclonedx' ? 'cdx.json' : 'spdx.json';
+
+ return response()->json($document, 200, [
+ 'Content-Disposition' => 'attachment; filename="'.$name.'-sbom.'.$extension.'"',
+ ], JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
+ }
+}
diff --git a/app/Http/Controllers/Webhook/Gitea.php b/app/Http/Controllers/Webhook/Gitea.php
index bb34413b43..a62461bf7f 100644
--- a/app/Http/Controllers/Webhook/Gitea.php
+++ b/app/Http/Controllers/Webhook/Gitea.php
@@ -48,7 +48,7 @@ class Gitea extends Controller
// Only users who may administer the source can complete OAuth and store tokens.
if (! $request->user()->can('update', $giteaApp)) {
- return redirect()->route('source.all')->with('error', 'You are not authorized to connect this Gitea App.');
+ return redirect()->route('source.all')->with('error', 'You are not authorized to connect this Gitea / Forgejo App.');
}
$baseUrl = rtrim($giteaApp->html_url, '/');
@@ -85,7 +85,7 @@ class Gitea extends Controller
{
try {
$return_payloads = collect([]);
- $x_gitea_event = Str::lower((string) $request->header('X-Gitea-Event'));
+ $x_gitea_event = Str::lower((string) ($request->header('X-Gitea-Event') ?? $request->header('X-Forgejo-Event')));
if ($x_gitea_event === 'ping') {
return response('pong');
@@ -98,7 +98,7 @@ class Gitea extends Controller
]);
}
- $signature = (string) $request->header('X-Gitea-Signature');
+ $signature = (string) ($request->header('X-Gitea-Signature') ?? $request->header('X-Forgejo-Signature'));
if ($signature === '') {
$signature = Str::after((string) $request->header('X-Hub-Signature-256'), 'sha256=');
}
@@ -365,8 +365,8 @@ class Gitea extends Controller
{
try {
$return_payloads = collect([]);
- $x_gitea_delivery = request()->header('X-Gitea-Delivery');
- $x_gitea_event = Str::lower((string) $request->header('X-Gitea-Event'));
+ $x_gitea_delivery = $request->header('X-Gitea-Delivery') ?? $request->header('X-Forgejo-Delivery');
+ $x_gitea_event = Str::lower((string) ($request->header('X-Gitea-Event') ?? $request->header('X-Forgejo-Event')));
$x_hub_signature_256 = Str::after((string) $request->header('X-Hub-Signature-256'), 'sha256=');
$content_type = $request->header('Content-Type');
$payload = $request->collect();
diff --git a/app/Http/Controllers/Webhook/Github.php b/app/Http/Controllers/Webhook/Github.php
index 3915b2ed95..18ce7448b6 100644
--- a/app/Http/Controllers/Webhook/Github.php
+++ b/app/Http/Controllers/Webhook/Github.php
@@ -878,6 +878,7 @@ class Github extends Controller
$github_app = GithubApp::whereKey(data_get($payload, 'github_app_id'))
->where('team_id', $team_id)
->firstOrFail();
+ abort_unless($request->user()->can('update', $github_app), 403);
if ($request->user()->currentTeam()?->id !== $team_id) {
refreshSession($github_app->team);
diff --git a/app/Http/Middleware/ApiAbility.php b/app/Http/Middleware/ApiAbility.php
index 3a48bc6b1d..76fddfe274 100644
--- a/app/Http/Middleware/ApiAbility.php
+++ b/app/Http/Middleware/ApiAbility.php
@@ -22,6 +22,7 @@ class ApiAbility extends CheckForAnyAbility
'deployments',
'resources',
'version',
+ 'vulnerabilities',
];
public const SCOPED_ACCESS_DENIED_MESSAGE = 'This API token is limited to selected projects, environments, or resources, so it cannot use this endpoint.';
diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php
index b0498794bf..cb29a80bd8 100644
--- a/app/Jobs/ApplicationDeploymentJob.php
+++ b/app/Jobs/ApplicationDeploymentJob.php
@@ -24,6 +24,7 @@ use App\Models\SwarmDocker;
use App\Notifications\Application\DeploymentFailed;
use App\Notifications\Application\DeploymentSuccess;
use App\Services\DeploymentConfiguration\ApplicationConfigurationSnapshot;
+use App\Services\ImageScan\DeploymentImageScan;
use App\Support\RemoteSecretReferences;
use App\Support\RemoteSecretValueFormatter;
use App\Support\ValidationPatterns;
@@ -43,6 +44,7 @@ use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Sleep;
use Illuminate\Support\Str;
+use InvalidArgumentException;
use JsonException;
use Spatie\Url\Url;
use Symfony\Component\Yaml\Yaml;
@@ -54,6 +56,11 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
public const BUILD_TIME_ENV_PATH = '/artifacts/build-time.env';
+ /**
+ * Logged when a git command of Coolify fails because of the SSH host key. The deployment page looks for this text.
+ */
+ public const GIT_HOST_KEY_CHANGED_HINT = 'The Git host sent an SSH host key that is not the saved or published key. This can be an attack. Ask the administrator of the Git host if the key changed. Then open Source > SSH host keys, click Fetch current host key, and accept the key only if its fingerprint is the same as the fingerprint from the administrator. For GitHub and Bitbucket Cloud, Fetch current host key also reads the keys that they publish. For GitLab.com, compare the fingerprint with the keys on docs.gitlab.com.';
+
private const BUILD_FINGERPRINT_LABEL = 'coolify.build-fingerprint';
public const BUILD_TIME_SHELL_ENV_PATH = '/artifacts/build-time-shell.env';
@@ -121,6 +128,9 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
public $timeout = 3600;
+ /** Unix time when the worker started handle(); the timeout counts from it. */
+ private ?int $handleStartedAt = null;
+
public static int $batch_counter = 0;
private ApplicationDeploymentQueue $application_deployment_queue;
@@ -271,6 +281,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private Collection|string $build_secrets;
+ private ?DeploymentImageScan $imageScan = null;
+
/**
* Build-time variables that the helper container has in its environment for build secrets.
*
@@ -436,6 +448,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
public function handle(): void
{
+ $this->handleStartedAt = now()->getTimestamp();
+
// Check if deployment was cancelled before we even started
$this->application_deployment_queue->refresh();
if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::CANCELLED_BY_USER->value) {
@@ -709,6 +723,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
// Mark deployment as complete FIRST, before any other operations
// This ensures the deployment status is FINISHED even if subsequent operations fail
$this->completeDeployment();
+ $this->imageScan()->linkReusedScans();
// Then handle side effects - these should not fail the deployment
try {
@@ -742,6 +757,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
}
}
+ $this->imageScan()->dispatchScanAfterStart();
+ $this->imageScan()->refreshSecurity();
}
private function deploy_simple_dockerfile()
@@ -773,6 +790,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$this->generate_build_env_variables();
$this->add_build_env_variables_to_dockerfile();
$this->build_image();
+ $this->imageScan()->rememberDockerfileBaseImage();
// Save runtime environment variables AFTER the build
// This overwrites the build-time .env with ALL variables (build-time + runtime)
@@ -917,6 +935,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$this->generate_build_env_variables();
$this->loadComposeFileForDeployment();
+ $this->imageScan()->forgetStaleComposeImageOverrides();
$buildEnvironment = null;
if ($this->application->settings->is_raw_compose_deployment_enabled) {
$this->application->oldRawParser();
@@ -970,6 +989,9 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$this->save_runtime_environment_variables();
$this->pull_docker_compose_images();
+ $this->imageScan()->applyComposeStartPins();
+
+ $this->imageScan()->checkComposeGate();
$this->startDeployPhase();
$this->stop_running_container(force: true);
@@ -1396,6 +1418,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$this->generate_build_env_variables();
$this->add_build_env_variables_to_dockerfile();
$this->build_image();
+ $this->imageScan()->rememberDockerfileBaseImage();
// Save runtime environment variables AFTER the build
// This overwrites the build-time .env with ALL variables (build-time + runtime)
@@ -1724,6 +1747,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$this->should_skip_build();
$this->completeDeployment();
+ $this->imageScan()->linkReusedScans();
}
private function should_skip_build()
@@ -2633,11 +2657,11 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
try {
if (! ValidationPatterns::isValidEnvironmentVariableKey($key)) {
- throw new \InvalidArgumentException('Invalid build-time environment variable key.');
+ throw new InvalidArgumentException('Invalid build-time environment variable key.');
}
return $key;
- } catch (\InvalidArgumentException $exception) {
+ } catch (InvalidArgumentException $exception) {
$this->logInvalidBuildtimeEnvironmentVariableKey($key, $origin);
// The log already explains the problem and how to fix it.
@@ -2705,6 +2729,10 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
*/
private function ensureRegistryImageIsNotShared(): void
{
+ if ($this->restart_only || $this->is_this_additional_server) {
+ return;
+ }
+
$otherApplication = $this->application->otherApplicationBuildingSameImage();
if ($otherApplication) {
throw new DeploymentException($this->application->sharedImageNameMessage($otherApplication));
@@ -3011,6 +3039,9 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private function rolling_update()
{
+ $this->imageScan()->applyStartPin();
+ $this->imageScan()->checkGate($this->production_image_name);
+
try {
$this->startDeployPhase();
$this->write_compose_file_to_workdir();
@@ -3065,6 +3096,33 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
}
}
+ /**
+ * The image scan steps of this deployment (gate, start pins, scan after the start), created on first use. It reads
+ * job properties when a step needs them, because the job changes servers and the Compose file during the deploy.
+ */
+ private function imageScan(): DeploymentImageScan
+ {
+ return $this->imageScan ??= new DeploymentImageScan(
+ $this->application,
+ $this->application_deployment_queue,
+ read: fn (string $property): mixed => match ($property) {
+ 'server', 'mainServer', 'use_build_server', 'deployment_uuid', 'pull_request_id', 'commit', 'rollback',
+ 'skip_build', 'is_this_additional_server', 'container_name', 'docker_compose', 'generated_docker_compose',
+ 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'workdir',
+ 'docker_compose_location', 'saved_outputs', 'timeout' => $this->{$property},
+ // Unix time when the worker started the job; null when handle() did not run.
+ 'started_at' => $this->handleStartedAt,
+ // Not set before the build server is chosen or the image is named.
+ 'build_server', 'production_image_name' => $this->{$property} ?? null,
+ },
+ write: fn (string $property, string $value): string => match ($property) {
+ 'docker_compose', 'generated_docker_compose' => $this->{$property} = $value,
+ },
+ run: fn (array ...$commands) => $this->execute_remote_command(...$commands),
+ checkForCancellation: fn () => $this->checkForCancellation(),
+ );
+ }
+
/**
* The proxy adds the new container some seconds after it is healthy. Stopping the old
* container before that sends requests to a closed port (502), see #8627.
@@ -3256,6 +3314,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$this->add_build_env_variables_to_dockerfile();
}
$this->build_pull_request_image();
+ $this->imageScan()->rememberDockerfileBaseImage();
// This overwrites the build-time .env with ALL variables (build-time + runtime)
$this->save_runtime_environment_variables();
@@ -3376,6 +3435,10 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
[
'command' => executeInDocker($this->deployment_uuid, "mkdir -p {$this->basedir}"),
],
+ [
+ 'command' => executeInDocker($this->deployment_uuid, gitSshSetupCommand($this->deployment_uuid, $this->application->git_ssh_known_hosts)),
+ 'hidden' => true,
+ ],
);
$this->run_pre_deployment_command();
}
@@ -3546,7 +3609,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private function gitLsRemoteCommand(string $lsRemoteRef, ?string $identityFile = null): string
{
- $sshCommand = gitSshCommand($this->customPort, $identityFile);
+ $sshCommand = gitSshCommand($this->deployment_uuid, $this->customPort, $identityFile);
$gitConfigOption = $this->application->gitHttpsCredentialConfigOption();
$gitCommand = $gitConfigOption ? "git {$gitConfigOption}" : 'git';
@@ -3590,36 +3653,39 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
}
}
$private_key = data_get($this->application, 'private_key.private_key');
- if ($private_key) {
- $private_key = base64_encode($private_key);
- $customSshKeyLocation = "/root/.ssh/id_rsa_coolify_{$this->deployment_uuid}";
- $this->execute_remote_command(
- [
- executeInDocker($this->deployment_uuid, 'mkdir -p /root/.ssh'),
- ],
- [
- executeInDocker($this->deployment_uuid, "echo '{$private_key}' | base64 -d | tee {$customSshKeyLocation} > /dev/null"),
- 'hidden' => true,
- 'skip_command_log' => true,
- ],
- [
- executeInDocker($this->deployment_uuid, "chmod 600 {$customSshKeyLocation}"),
- ],
- [
- executeInDocker($this->deployment_uuid, $this->gitLsRemoteCommand($lsRemoteRef, $customSshKeyLocation)),
- 'hidden' => true,
- 'save' => 'git_commit_sha',
- ]
- );
- } else {
- $this->execute_remote_command(
- [
- executeInDocker($this->deployment_uuid, $this->gitLsRemoteCommand($lsRemoteRef)),
- 'hidden' => true,
- 'save' => 'git_commit_sha',
- ],
- );
- }
+ $this->withGitHostKeyHint(function () use ($private_key, $lsRemoteRef): void {
+ if ($private_key) {
+ $private_key = base64_encode($private_key);
+ $customSshKeyLocation = "/root/.ssh/id_rsa_coolify_{$this->deployment_uuid}";
+ $this->execute_remote_command(
+ [
+ executeInDocker($this->deployment_uuid, 'mkdir -p /root/.ssh'),
+ ],
+ [
+ executeInDocker($this->deployment_uuid, "echo '{$private_key}' | base64 -d | tee {$customSshKeyLocation} > /dev/null"),
+ 'hidden' => true,
+ 'skip_command_log' => true,
+ ],
+ [
+ executeInDocker($this->deployment_uuid, "chmod 600 {$customSshKeyLocation}"),
+ ],
+ [
+ executeInDocker($this->deployment_uuid, $this->gitLsRemoteCommand($lsRemoteRef, $customSshKeyLocation)),
+ 'hidden' => true,
+ 'save' => 'git_commit_sha',
+ ]
+ );
+ } else {
+ $this->execute_remote_command(
+ [
+ executeInDocker($this->deployment_uuid, $this->gitLsRemoteCommand($lsRemoteRef)),
+ 'hidden' => true,
+ 'save' => 'git_commit_sha',
+ ],
+ );
+ }
+ });
+ $this->save_git_ssh_known_hosts();
if ($this->saved_outputs->get('git_commit_sha') && ! $this->rollback && $this->shouldResolveBranchHeadCommit()) {
// Extract commit SHA from git ls-remote output, handling multi-line output (e.g., redirect warnings)
// Expected format: "commit_sha\trefs/heads/branch" possibly preceded by warning lines
@@ -3719,7 +3785,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
if ($this->pull_request_id !== 0) {
$this->application_deployment_queue->addLogEntry("Checking out tag pull/{$this->pull_request_id}/head.");
}
- $this->import_repository();
+ $this->withGitHostKeyHint(fn () => $this->import_repository());
+ $this->save_git_ssh_known_hosts();
$this->create_workdir();
$this->execute_remote_command(
[
@@ -3737,6 +3804,79 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
}
}
+ /**
+ * Logs the host key hint when a git command of Coolify fails because of the SSH host key. The hint is logged
+ * here and not in failed(), because a git command in the build (for example a Dockerfile RUN step) can fail the same way.
+ */
+ private function withGitHostKeyHint(callable $runGitCommands): void
+ {
+ try {
+ $runGitCommands();
+ } catch (Throwable $e) {
+ if (str_contains($e->getMessage(), 'Host key verification failed')) {
+ $this->application_deployment_queue->addLogEntry(self::GIT_HOST_KEY_CHANGED_HINT, 'stderr');
+ }
+
+ throw $e;
+ }
+ }
+
+ /**
+ * Saves the host keys that SSH accepted on first use, so later deployments reject a changed key.
+ */
+ private function save_git_ssh_known_hosts(): void
+ {
+ $this->saved_outputs->forget('git_ssh_known_hosts');
+ $this->execute_remote_command([
+ executeInDocker($this->deployment_uuid, 'cat '.gitKnownHostsFile($this->deployment_uuid)),
+ 'hidden' => true,
+ 'ignore_errors' => true,
+ 'save' => 'git_ssh_known_hosts',
+ ]);
+ // Two first deployments can accept different keys at the same time; keep the key that was saved first.
+ $hostAndKeyType = fn (string $line): string => implode(' ', array_slice(explode(' ', $line), 0, 2));
+ $savedHostsAndKeyTypes = array_map($hostAndKeyType, gitKnownHostsLines($this->application->git_ssh_known_hosts));
+ // The file also has the built-in keys (gitProviderHostKeys()); they are not saved for the application.
+ $newLines = array_values(array_filter(
+ array_diff(gitKnownHostsLines((string) $this->saved_outputs->get('git_ssh_known_hosts')), gitProviderHostKeys()),
+ fn (string $line) => ! in_array($hostAndKeyType($line), $savedHostsAndKeyTypes, true),
+ ));
+ if ($newLines === []) {
+ return;
+ }
+
+ $addedLines = [];
+ $conflictingLines = [];
+ $knownHosts = $this->application->getConnection()->transaction(function () use ($newLines, $hostAndKeyType, &$addedLines, &$conflictingLines): string {
+ $currentLines = gitKnownHostsLines(
+ Application::query()->withoutGlobalScope('withRelations')->whereKey($this->application->id)->lockForUpdate()->value('git_ssh_known_hosts')
+ );
+ $savedHostsAndKeyTypes = array_map($hostAndKeyType, $currentLines);
+ foreach (array_diff($newLines, $currentLines) as $line) {
+ if (in_array($hostAndKeyType($line), $savedHostsAndKeyTypes, true)) {
+ $conflictingLines[] = $line;
+ } else {
+ $addedLines[] = $line;
+ }
+ }
+ $merged = implode("\n", [...$currentLines, ...$addedLines]);
+ if ($addedLines !== []) {
+ Application::query()->whereKey($this->application->id)->update(['git_ssh_known_hosts' => $merged]);
+ }
+
+ return $merged;
+ });
+ $this->application->forceFill(['git_ssh_known_hosts' => $knownHosts])->syncOriginalAttribute('git_ssh_known_hosts');
+ foreach ($addedLines as $line) {
+ [$host, $keyType] = explode(' ', $line);
+ $this->application_deployment_queue->addLogEntry("Saved the SSH host key of {$host} ({$keyType}). Later deployments accept only this key. You can see or remove it under Source.");
+ }
+ foreach ($conflictingLines as $line) {
+ [$host, $keyType] = explode(' ', $line);
+ $this->application_deployment_queue->addLogEntry("Coolify did not save the SSH host key of {$host} ({$keyType}). Another deployment saved a different key of this type at the same time. Later deployments accept only the saved key.", 'stderr');
+ }
+ }
+
/**
* SSH exits with 255 when the connection drops, which can happen during a long clone. Git exits with
* 128 for its own errors, so only 255 is retried. The interrupted clone can still run in the helper
@@ -5522,15 +5662,23 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
);
if ($this->application->build_pack === 'dockerimage') {
- $this->application_deployment_queue->addLogEntry('Pulling latest images from the registry.');
- $this->execute_remote_command(
- [executeInDocker($this->deployment_uuid, "docker compose --project-name {$this->composeProjectName()} --project-directory {$this->workdir} pull"), 'hidden' => true],
- [executeInDocker($this->deployment_uuid, "{$this->coolify_variables} docker compose --project-name {$this->composeProjectName()} --project-directory {$this->workdir} up --build -d"), 'hidden' => true],
- );
+ $up = [executeInDocker($this->deployment_uuid, "{$this->coolify_variables} docker compose --project-name {$this->composeProjectName()} --project-directory {$this->workdir} up --build -d"), 'hidden' => true];
+ if ($this->imageScan()->pinnedImage() !== null || $this->imageScan()->startPinned()) {
+ // The gate pulled and scanned the pinned image.
+ $this->execute_remote_command($up);
+ } else {
+ $this->application_deployment_queue->addLogEntry('Pulling latest images from the registry.');
+ $this->execute_remote_command(
+ [executeInDocker($this->deployment_uuid, "docker compose --project-name {$this->composeProjectName()} --project-directory {$this->workdir} pull"), 'hidden' => true],
+ $up,
+ );
+ }
} else {
if ($this->use_build_server) {
+ // A pinned digest cannot change in the registry, so Compose pulls it only when it is missing.
+ $pull = $this->imageScan()->pinnedImage() !== null ? '' : '--pull always ';
$this->execute_remote_command(
- [serverDockerConfigPrefix($this->server)."{$this->coolify_variables} docker compose --project-name {$this->composeProjectName()} --project-directory {$this->configuration_dir} -f {$this->configuration_dir}{$this->docker_compose_location} up --pull always --build -d", 'hidden' => true],
+ [serverDockerConfigPrefix($this->server)."{$this->coolify_variables} docker compose --project-name {$this->composeProjectName()} --project-directory {$this->configuration_dir} -f {$this->configuration_dir}{$this->docker_compose_location} up {$pull}--build -d", 'hidden' => true],
);
} else {
$this->execute_remote_command(
@@ -6712,6 +6860,12 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
public function failed(Throwable $exception): void
{
$this->failDeployment();
+ // A failed remote command can store the failed status with a query update, which fires no model event.
+ try {
+ SyncGithubDeploymentJob::dispatchFor($this->application_deployment_queue->refresh());
+ } catch (Throwable $e) {
+ $this->logStatusTransitionSideEffectFailure('Queueing the GitHub deployment sync failed', $e);
+ }
// failed() runs on a fresh job instance, so remote secrets fetched during the deployment are not loaded here.
// Every entry still passes through ApplicationDeploymentQueue::redactLogText(); raw stack traces are not written
diff --git a/app/Jobs/ApplicationPullRequestUpdateJob.php b/app/Jobs/ApplicationPullRequestUpdateJob.php
index 8a7766123f..e303393b2b 100755
--- a/app/Jobs/ApplicationPullRequestUpdateJob.php
+++ b/app/Jobs/ApplicationPullRequestUpdateJob.php
@@ -4,6 +4,7 @@ namespace App\Jobs;
use App\Enums\ProcessStatus;
use App\Models\Application;
+use App\Models\ApplicationDeploymentQueue;
use App\Models\ApplicationPreview;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
@@ -44,20 +45,7 @@ class ApplicationPullRequestUpdateJob implements ShouldBeEncrypted, ShouldQueue
return;
}
- match ($this->status) {
- ProcessStatus::QUEUED => $this->body = "The preview deployment for **{$serviceName}** is queued. ⏳\n\n",
- ProcessStatus::IN_PROGRESS => $this->body = "The preview deployment for **{$serviceName}** is in progress. 🟡\n\n",
- ProcessStatus::FINISHED => $this->body = "The preview deployment for **{$serviceName}** is ready. 🟢\n\n".$this->getPreviewLinks(),
- ProcessStatus::ERROR => $this->body = "The preview deployment for **{$serviceName}** failed. 🔴\n\n",
- ProcessStatus::KILLED => $this->body = "The preview deployment for **{$serviceName}** was killed. ⚫\n\n",
- ProcessStatus::CANCELLED => $this->body = "The preview deployment for **{$serviceName}** was cancelled. 🚫\n\n",
- ProcessStatus::CLOSED => '', // Already handled above, but included for completeness
- };
- $this->build_logs_url = base_url()."/project/{$this->application->environment->project->uuid}/environment/{$this->application->environment->uuid}/application/{$this->application->uuid}/deployment/{$this->deployment_uuid}";
- $application_logs_url = base_url()."/project/{$this->application->environment->project->uuid}/environment/{$this->application->environment->uuid}/application/{$this->application->uuid}/logs";
-
- $this->body .= '[Open Build Logs]('.$this->build_logs_url.') | [Open Application Logs]('.$application_logs_url.")\n\n\n";
- $this->body .= 'Last updated at: '.now()->toDateTimeString().' CET';
+ $this->body = $this->commentBody($serviceName);
if ($this->preview->pull_request_issue_comment_id) {
$this->update_comment();
} else {
@@ -68,6 +56,55 @@ class ApplicationPullRequestUpdateJob implements ShouldBeEncrypted, ShouldQueue
}
}
+ private function commentBody(string $serviceName): string
+ {
+ $body = match ($this->status) {
+ ProcessStatus::QUEUED => "The preview deployment for **{$serviceName}** is queued. ⏳\n\n",
+ ProcessStatus::IN_PROGRESS => "The preview deployment for **{$serviceName}** is in progress. 🟡\n\n",
+ ProcessStatus::FINISHED => "The preview deployment for **{$serviceName}** is ready. 🟢\n\n".$this->getPreviewLinks(),
+ ProcessStatus::ERROR => "The preview deployment for **{$serviceName}** failed. 🔴\n\n",
+ ProcessStatus::KILLED => "The preview deployment for **{$serviceName}** was killed. ⚫\n\n",
+ ProcessStatus::CANCELLED => "The preview deployment for **{$serviceName}** was cancelled. 🚫\n\n",
+ ProcessStatus::CLOSED => '', // Already handled in handle(), but included for completeness
+ };
+ $this->build_logs_url = base_url()."/project/{$this->application->environment->project->uuid}/environment/{$this->application->environment->uuid}/application/{$this->application->uuid}/deployment/{$this->deployment_uuid}";
+ $application_logs_url = base_url()."/project/{$this->application->environment->project->uuid}/environment/{$this->application->environment->uuid}/application/{$this->application->uuid}/logs";
+
+ $body .= $this->imageScanLine();
+ $body .= '[Open Build Logs]('.$this->build_logs_url.') | [Open Application Logs]('.$application_logs_url.")\n\n\n";
+ $body .= 'Last updated at: '.now()->toDateTimeString().' CET';
+
+ return $body;
+ }
+
+ /**
+ * "Image scan: passed" or "Image scan: blocked" with a link to the deployment. PR comments can be
+ * public, so it never shows counts or vulnerability IDs. Empty when the gate did not scan.
+ */
+ private function imageScanLine(): string
+ {
+ if (! $this->deployment_uuid) {
+ return '';
+ }
+
+ $deployment = ApplicationDeploymentQueue::query()
+ ->where('deployment_uuid', $this->deployment_uuid)
+ ->where('application_id', $this->application->id)
+ ->first(['id', 'scan_gate_result']);
+ if (! $deployment || ! is_array($deployment->scan_gate_result)) {
+ return '';
+ }
+
+ if ($deployment->isImageScanBlocked()) {
+ return 'Image scan: blocked. [Open in Coolify]('.$this->build_logs_url.")\n\n";
+ }
+ if (data_get($deployment->scan_gate_result, 'scan_ran') === true) {
+ return 'Image scan: passed. [Open in Coolify]('.$this->build_logs_url.")\n\n";
+ }
+
+ return '';
+ }
+
private function update_comment()
{
['data' => $data] = githubApi(source: $this->application->source, endpoint: '/repos/'.gitRepositorySlug($this->application->git_repository)."/issues/comments/{$this->preview->pull_request_issue_comment_id}", method: 'patch', data: [
diff --git a/app/Jobs/CheckAndStartSentinelJob.php b/app/Jobs/CheckAndStartSentinelJob.php
index 183063f09e..c5c23b6850 100644
--- a/app/Jobs/CheckAndStartSentinelJob.php
+++ b/app/Jobs/CheckAndStartSentinelJob.php
@@ -65,9 +65,9 @@ class CheckAndStartSentinelJob implements ShouldBeEncrypted, ShouldBeUnique, Sho
}
// One SSH call reads the version and the last push result. Sentinel versions without
- // /api/push-status give an empty second line.
+ // /api/push-status give an empty second line. printf is a shell builtin, so the token is not a process argument.
$output = instant_remote_process_with_timeout([
- "docker exec coolify-sentinel sh -c 'curl -s http://127.0.0.1:8888/api/version; echo; curl -sf -H \"Authorization: Bearer \$TOKEN\" http://127.0.0.1:8888/api/push-status'",
+ "docker exec coolify-sentinel sh -c 'curl -s http://127.0.0.1:8888/api/version; echo; printf \"Authorization: Bearer %s\\n\" \"\$TOKEN\" | curl -sf -H @- http://127.0.0.1:8888/api/push-status'",
], $this->server, false);
[$runningVersion, $pushStatus] = array_pad(explode("\n", trim((string) $output), 2), 2, null);
$this->rememberPushError($pushStatus);
diff --git a/app/Jobs/CheckForUpdatesJob.php b/app/Jobs/CheckForUpdatesJob.php
index a98b400b08..986dfcbf29 100644
--- a/app/Jobs/CheckForUpdatesJob.php
+++ b/app/Jobs/CheckForUpdatesJob.php
@@ -2,6 +2,7 @@
namespace App\Jobs;
+use App\Services\AutoUpdateNotifier;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
@@ -31,6 +32,7 @@ class CheckForUpdatesJob implements ShouldBeEncrypted, ShouldQueue
if (isDev() || isCloud()) {
return false;
}
+ AutoUpdateNotifier::checkResult();
$versions = refresh_versions_file($this->cdnTries);
if ($versions === null) {
return false;
diff --git a/app/Jobs/CleanupHelperContainersJob.php b/app/Jobs/CleanupHelperContainersJob.php
index 9da13aed70..b04ef2e4d9 100644
--- a/app/Jobs/CleanupHelperContainersJob.php
+++ b/app/Jobs/CleanupHelperContainersJob.php
@@ -3,9 +3,13 @@
namespace App\Jobs;
use App\Enums\ApplicationDeploymentStatus;
+use App\Enums\ProcessStatus;
use App\Models\ApplicationDeploymentQueue;
use App\Models\ScheduledDatabaseBackupExecution;
+use App\Models\ScheduledVolumeBackupExecution;
use App\Models\Server;
+use App\Services\RcloneTransfer;
+use App\Support\ResourceStartActivity;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldBeUnique;
@@ -13,7 +17,10 @@ use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
+use Illuminate\Support\Carbon;
+use Illuminate\Support\Collection;
use Illuminate\Support\Str;
+use Spatie\Activitylog\Models\Activity;
class CleanupHelperContainersJob implements ShouldBeEncrypted, ShouldBeUnique, ShouldQueue
{
@@ -34,6 +41,12 @@ class CleanupHelperContainersJob implements ShouldBeEncrypted, ShouldBeUnique, S
return $this->server->uuid;
}
+ /**
+ * The job that starts an rclone container creates its execution or activity before it, except a database
+ * import, which creates its activity right after it. Younger containers are kept.
+ */
+ private const RCLONE_MIN_AGE_SECONDS = 900;
+
private static function helperContainersCommand(): string
{
return 'docker container ps --format \'{{json .}}\' | jq -s \'map(select(.Image|test("(^|/)coollabsio/coolify-helper(:|@)")))\'';
@@ -47,48 +60,114 @@ class CleanupHelperContainersJob implements ShouldBeEncrypted, ShouldBeUnique, S
public function handle(): void
{
try {
- $containers = instant_remote_process_with_timeout([self::helperContainersCommand()], $this->server, false);
- $helperContainers = collect(json_decode((string) $containers, true) ?: [])
- ->filter(fn (mixed $container): bool => is_array($container) && filled(data_get($container, 'ID')) && filled(data_get($container, 'Names')));
-
- if ($helperContainers->isEmpty()) {
- return;
- }
-
- $finishedDeploymentUuids = ApplicationDeploymentQueue::query()
- ->whereIn('deployment_uuid', $helperContainers->pluck('Names')->all())
- ->whereNotIn('status', [
- ApplicationDeploymentStatus::IN_PROGRESS->value,
- ApplicationDeploymentStatus::QUEUED->value,
- ])
- ->pluck('deployment_uuid')
- ->all();
-
- $runningBackupUploadNames = ScheduledDatabaseBackupExecution::query()
- ->whereIn('uuid', $helperContainers->pluck('Names')->map(fn (string $name): string => Str::after($name, 'backup-of-'))->all())
- ->where('status', 'running')
- ->pluck('uuid')
- ->map(fn (string $uuid): string => "backup-of-{$uuid}")
- ->all();
-
- foreach ($helperContainers as $container) {
- $isFinishedDeployment = in_array($container['Names'], $finishedDeploymentUuids, true);
- // A worker that is killed during the upload never removes its upload container.
- $isOrphanedBackupUpload = str_starts_with($container['Names'], 'backup-of-')
- && ! in_array($container['Names'], $runningBackupUploadNames, true);
- if (! $isFinishedDeployment && ! $isOrphanedBackupUpload) {
- continue;
- }
-
- \Log::info('CleanupHelperContainersJob - Removing orphaned helper container', [
- 'container' => $container['Names'],
- 'id' => $container['ID'],
- ]);
-
- instant_remote_process_with_timeout(['docker container rm -f '.escapeshellarg($container['ID'])], $this->server, false);
- }
+ $activeTransfers = $this->activeTransferOwners();
+ $this->removeOrphanedHelperContainers($activeTransfers);
+ $this->removeOrphanedRcloneContainers($activeTransfers);
} catch (\Throwable $e) {
send_internal_notification('CleanupHelperContainersJob failed with error: '.$e->getMessage());
}
}
+
+ /**
+ * @param Collection $activeTransfers
+ */
+ private function removeOrphanedHelperContainers(Collection $activeTransfers): void
+ {
+ $containers = instant_remote_process_with_timeout([self::helperContainersCommand()], $this->server, false);
+ $helperContainers = collect(json_decode((string) $containers, true) ?: [])
+ ->filter(fn (mixed $container): bool => is_array($container) && filled(data_get($container, 'ID')) && filled(data_get($container, 'Names')));
+
+ if ($helperContainers->isEmpty()) {
+ return;
+ }
+
+ $finishedDeploymentUuids = ApplicationDeploymentQueue::query()
+ ->whereIn('deployment_uuid', $helperContainers->pluck('Names')->all())
+ ->whereNotIn('status', [
+ ApplicationDeploymentStatus::IN_PROGRESS->value,
+ ApplicationDeploymentStatus::QUEUED->value,
+ ])
+ ->pluck('deployment_uuid')
+ ->all();
+
+ foreach ($helperContainers as $container) {
+ $isFinishedDeployment = in_array($container['Names'], $finishedDeploymentUuids, true);
+ // A worker that is killed during the upload never removes its upload container.
+ $isOrphanedBackupUpload = str_starts_with($container['Names'], 'backup-of-')
+ && ! $activeTransfers->contains(Str::after($container['Names'], 'backup-of-'));
+ if (! $isFinishedDeployment && ! $isOrphanedBackupUpload) {
+ continue;
+ }
+
+ \Log::info('CleanupHelperContainersJob - Removing orphaned helper container', [
+ 'container' => $container['Names'],
+ 'id' => $container['ID'],
+ ]);
+
+ instant_remote_process_with_timeout(['docker container rm -f '.escapeshellarg($container['ID'])], $this->server, false);
+ }
+ }
+
+ /**
+ * An rclone container has the storage config with its password or SSH key. It stays until the end of its sleep
+ * when a worker timeout kills the backup or import job before the job removes it.
+ *
+ * @param Collection $activeTransfers
+ */
+ private function removeOrphanedRcloneContainers(Collection $activeTransfers): void
+ {
+ $output = instant_remote_process_with_timeout([
+ 'docker container ps --filter label='.RcloneTransfer::LABEL." --format '{{json .}}'",
+ ], $this->server, false);
+ $containers = collect(explode("\n", (string) $output))
+ ->map(fn (string $line): mixed => json_decode(trim($line), true))
+ ->filter(fn (mixed $container): bool => is_array($container) && filled($container['ID'] ?? null));
+ if ($containers->isEmpty()) {
+ return;
+ }
+
+ foreach ($containers as $container) {
+ $name = (string) ($container['Names'] ?? '');
+ $createdAt = Carbon::createFromFormat('Y-m-d H:i:s O', substr((string) ($container['CreatedAt'] ?? ''), 0, 25));
+ if (! $createdAt || $createdAt->gt(now()->subSeconds(self::RCLONE_MIN_AGE_SECONDS))) {
+ continue;
+ }
+ if (self::belongsToActiveTransfer($name, $activeTransfers)) {
+ continue;
+ }
+
+ \Log::info('CleanupHelperContainersJob - Removing orphaned rclone container', ['container' => $name]);
+ instant_remote_process_with_timeout(['docker container rm -f '.escapeshellarg((string) $container['ID'])], $this->server, false);
+ }
+ }
+
+ /**
+ * @param Collection $activeTransfers
+ */
+ private static function belongsToActiveTransfer(string $containerName, Collection $activeTransfers): bool
+ {
+ return preg_match('/^(backup-of|volume-backup|volume-upload|s3-restore|backup-decompress)-(.+)$/', $containerName, $matches) === 1
+ && $activeTransfers->contains($matches[2]);
+ }
+
+ /**
+ * UUIDs of the running backups and database imports. Their upload and restore containers (helper or rclone) are
+ * named "backup-of-", "volume-backup-", "volume-upload-", "s3-restore-", or "backup-decompress-" and the UUID.
+ *
+ * @return Collection
+ */
+ private function activeTransferOwners(): Collection
+ {
+ $imports = Activity::query()
+ ->where('properties->operation', ResourceStartActivity::DATABASE_IMPORT_OPERATION)
+ ->whereIn('properties->status', [ProcessStatus::QUEUED->value, ProcessStatus::IN_PROGRESS->value])
+ ->get()
+ ->map(fn (Activity $activity): string => (string) data_get($activity, 'properties.operation_uuid'));
+
+ return ScheduledDatabaseBackupExecution::query()->where('status', 'running')->pluck('uuid')
+ ->merge(ScheduledVolumeBackupExecution::query()->where('status', 'running')->pluck('uuid'))
+ ->merge($imports)
+ ->filter()
+ ->values();
+ }
}
diff --git a/app/Jobs/CleanupUnusedSshKeys.php b/app/Jobs/CleanupUnusedSshKeys.php
new file mode 100644
index 0000000000..5a3f54b680
--- /dev/null
+++ b/app/Jobs/CleanupUnusedSshKeys.php
@@ -0,0 +1,82 @@
+select(['id', 'uuid'])->get()->keyBy('uuid');
+ $rotationStates = $keys->isEmpty() ? [] : Cache::many($keys->map(fn (PrivateKey $key) => RotatePrivateKeyJob::cacheKey($key))->values()->all());
+ $rotatingIds = [];
+ foreach ($keys as $key) {
+ $state = $rotationStates[RotatePrivateKeyJob::cacheKey($key)] ?? null;
+ if (is_array($state) && in_array($state['status'] ?? null, ['queued', 'running'], true)) {
+ $rotatingIds[] = $key->id;
+ $rotatingIds[] = $state['new_private_key_id'] ?? null;
+ }
+ }
+ $keyIdsInUse = null;
+
+ $disk = Storage::disk('ssh-keys');
+ foreach ($disk->files() as $filename) {
+ if (! preg_match('/\Assh_key@([a-zA-Z0-9_-]+)\z/', $filename, $matches)) {
+ continue;
+ }
+
+ // Share the writer's local lock; leave the lock file in place.
+ $lock = fopen($disk->path($filename).'.lock', 'c');
+ if ($lock === false) {
+ continue;
+ }
+ try {
+ if (! flock($lock, LOCK_EX | LOCK_NB)) {
+ continue;
+ }
+ clearstatcache(true, $disk->path($filename));
+ if (! $disk->exists($filename) || $disk->lastModified($filename) >= now()->subDay()->timestamp) {
+ continue;
+ }
+ $key = $keys->get($matches[1]);
+ $keyIdsInUse ??= $this->keyIdsInUse();
+ if ($key && (in_array($key->id, $rotatingIds) || isset($keyIdsInUse[$key->id]))) {
+ continue;
+ }
+ $disk->delete($filename);
+ } finally {
+ flock($lock, LOCK_UN);
+ fclose($lock);
+ }
+ }
+ }
+
+ /**
+ * The ids of the keys that PrivateKey::isInUse() reports as used, as array keys.
+ *
+ * @return array
+ */
+ private function keyIdsInUse(): array
+ {
+ return collect([
+ Server::query(),
+ Application::query()->withoutGlobalScope('withRelations'),
+ GithubApp::query(),
+ GitlabApp::query(),
+ GiteaApp::query(),
+ ])
+ ->flatMap(fn (Builder $query) => $query->whereNotNull('private_key_id')->distinct()->pluck('private_key_id'))
+ ->mapWithKeys(fn (int|string $id) => [(int) $id => true])
+ ->all();
+ }
+}
diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php
index 8e50b32571..946655c4de 100644
--- a/app/Jobs/DatabaseBackupJob.php
+++ b/app/Jobs/DatabaseBackupJob.php
@@ -22,6 +22,7 @@ use App\Notifications\Database\BackupFailed;
use App\Notifications\Database\BackupSuccess;
use App\Notifications\Database\BackupSuccessWithS3Warning;
use App\Notifications\ScheduledJobRecovered;
+use App\Services\RcloneTransfer;
use App\Services\ScheduledJobDeliveryService;
use App\Services\Security\SensitiveDataRedactor;
use App\Support\BackupCompression;
@@ -519,8 +520,8 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
if ($s3UploadError) {
$message = $message
- ? $message."\n\nWarning: S3 upload failed: ".$s3UploadError
- : 'Warning: S3 upload failed: '.$s3UploadError;
+ ? $message."\n\nWarning: Storage upload failed: ".$s3UploadError
+ : 'Warning: Storage upload failed: '.$s3UploadError;
}
$this->backup_log->update([
@@ -904,7 +905,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
's3_storage_id' => null,
]);
- throw new \Exception('S3 storage configuration is missing or has been deleted (S3 storage ID: '.($previousS3StorageId ?? 'null').'). S3 backup has been disabled for this schedule.');
+ throw new \Exception('Backup storage configuration is missing or has been deleted (storage ID: '.($previousS3StorageId ?? 'null').'). Uploads to a backup storage are disabled for this schedule.');
}
$failures = [];
@@ -947,6 +948,11 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
} else {
$network = $this->database->destination->network;
}
+ if (! $s3->isS3()) {
+ $this->uploadWithRclone($s3, $network);
+
+ return;
+ }
$safeNetwork = escapeshellarg($network);
$fullImageName = $this->getFullImageName();
@@ -981,6 +987,16 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
}
}
+ /**
+ * Uploads the backup file to an SFTP, FTPS, or WebDAV storage. The caller removes the container.
+ */
+ private function uploadWithRclone(S3Storage $storage, string $network): void
+ {
+ $transfer = new RcloneTransfer($storage, $this->server, "backup-of-{$this->backup_log_uuid}");
+ $transfer->start([$this->backupMountSource().':'.$this->backup_location.':ro'], $network, $this->commandTimeout());
+ $transfer->upload($this->backup_location, $this->backup_location, $this->commandTimeout());
+ }
+
/**
* Sets the `temporary` mc alias in a helper container. The S3 keys go to mc through the stdin of the
* SSH process, so they are not in the arguments of any process on Coolify or on the server.
diff --git a/app/Jobs/GithubAppPermissionJob.php b/app/Jobs/GithubAppPermissionJob.php
index 95968868ce..75dcb3c778 100644
--- a/app/Jobs/GithubAppPermissionJob.php
+++ b/app/Jobs/GithubAppPermissionJob.php
@@ -7,6 +7,7 @@ use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Http\Client\PendingRequest;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
@@ -29,10 +30,11 @@ class GithubAppPermissionJob implements ShouldBeEncrypted, ShouldQueue
try {
$github_access_token = generateGithubJwt($this->github_app);
- $response = Http::GitSource($this->github_app->api_url)->withHeaders([
+ $github = Http::GitSource($this->github_app->api_url)->withHeaders([
'Authorization' => "Bearer $github_access_token",
'Accept' => 'application/vnd.github+json',
- ])->get("{$this->github_app->api_url}/app");
+ ]);
+ $response = $github->get("{$this->github_app->api_url}/app");
if (! $response->successful()) {
assertGithubClockInSync($response);
@@ -45,6 +47,7 @@ class GithubAppPermissionJob implements ShouldBeEncrypted, ShouldQueue
$this->github_app->contents = data_get($permissions, 'contents');
$this->github_app->metadata = data_get($permissions, 'metadata');
$this->github_app->pull_requests = data_get($permissions, 'pull_requests');
+ $this->github_app->deployments = $this->approvedInstallationPermission($github, 'deployments');
$this->github_app->administration = data_get($permissions, 'administration');
$this->github_app->organization_self_hosted_runners = data_get($permissions, 'organization_self_hosted_runners');
$this->github_app->actions = data_get($permissions, 'actions');
@@ -58,4 +61,22 @@ class GithubAppPermissionJob implements ShouldBeEncrypted, ShouldQueue
throw $e;
}
}
+
+ /**
+ * GET /app returns the permissions the App requests. A new permission
+ * counts only after the installation approves it.
+ */
+ private function approvedInstallationPermission(PendingRequest $github, string $permission): ?string
+ {
+ if (! $this->github_app->installation_id) {
+ return null;
+ }
+
+ $response = $github->get("{$this->github_app->api_url}/app/installations/{$this->github_app->installation_id}");
+ if (! $response->successful()) {
+ throw new \RuntimeException('Failed to fetch GitHub app installation permissions: '.$response->body());
+ }
+
+ return data_get($response->json(), "permissions.{$permission}");
+ }
}
diff --git a/app/Jobs/ImageRescanJob.php b/app/Jobs/ImageRescanJob.php
new file mode 100644
index 0000000000..5c1e70292d
--- /dev/null
+++ b/app/Jobs/ImageRescanJob.php
@@ -0,0 +1,640 @@
+onQueue(isCloud() ? maintenance_queue() : 'default');
+ }
+
+ public function uniqueId(): string
+ {
+ return (string) $this->server->id;
+ }
+
+ /**
+ * Scanning is on for the server and allowed on the instance, and this instance can reach the server.
+ */
+ public static function canRunOn(Server $server): bool
+ {
+ return (bool) $server->settings?->is_image_scan_enabled
+ && (bool) InstanceSettings::query()->whereKey(0)->value('is_image_scanning_allowed')
+ && ! $server->isTransferredAway()
+ && $server->isFunctional();
+ }
+
+ /**
+ * Counts the days in a row on which the rescan of the server failed, for the digest (spec 8). A failed day is
+ * one on which the running containers could not be listed or no image could be scanned. Success resets it.
+ */
+ public static function recordRescanResult(Server $server, bool $succeeded): void
+ {
+ $key = self::failuresCacheKey($server);
+ if ($succeeded) {
+ Cache::forget($key);
+
+ return;
+ }
+
+ $state = Cache::get($key);
+ $today = now()->toDateString();
+ $state = match (true) {
+ ! is_array($state) => ['days' => 1, 'last' => $today, 'notified' => false],
+ ($state['last'] ?? null) === $today => $state,
+ ($state['last'] ?? null) === now()->subDay()->toDateString() => [...$state, 'days' => (int) ($state['days'] ?? 0) + 1, 'last' => $today],
+ default => ['days' => 1, 'last' => $today, 'notified' => false],
+ };
+ Cache::put($key, $state, now()->addDays(self::FAILURE_STATE_DAYS));
+ }
+
+ /**
+ * Days in a row on which the rescan of the server failed, 0 after a success.
+ */
+ public static function failedDays(Server $server): int
+ {
+ $state = Cache::get(self::failuresCacheKey($server));
+
+ return is_array($state) ? (int) ($state['days'] ?? 0) : 0;
+ }
+
+ public static function isFailureNotified(Server $server): bool
+ {
+ return (bool) data_get(Cache::get(self::failuresCacheKey($server)), 'notified', false);
+ }
+
+ public static function markFailureNotified(Server $server): void
+ {
+ $state = Cache::get(self::failuresCacheKey($server));
+ if (is_array($state)) {
+ Cache::put(self::failuresCacheKey($server), [...$state, 'notified' => true], now()->addDays(self::FAILURE_STATE_DAYS));
+ }
+ }
+
+ private static function failuresCacheKey(Server $server): string
+ {
+ return "image-rescan:failures:{$server->id}";
+ }
+
+ public function handle(): void
+ {
+ $server = $this->server->fresh(['settings']);
+ if (! $server instanceof Server || ! self::canRunOn($server)) {
+ return;
+ }
+
+ $commands = new ScannerCommands($server, Str::lower(Str::random(24)));
+ // Never sleeps on the scan lock, so a deploy gate gets it first. A busy lock runs the job again later and it
+ // goes on where it stopped; the last attempt runs without the busy steps.
+ $lastAttempt = $this->attempts() >= $this->tries;
+ $progress = $this->progress();
+
+ if (! $progress['database']) {
+ if (! $this->updateDatabase($server, $commands) && ! $lastAttempt) {
+ $this->retryLater($progress);
+
+ return;
+ }
+ $progress['database'] = true;
+ }
+
+ $listing = $this->runningContainers($server, $commands);
+ if ($listing === null) {
+ self::recordRescanResult($server, false);
+ $this->forgetProgress();
+
+ return;
+ }
+
+ [$targets, $runningRefs] = $this->targets($server, $listing['containers']);
+ foreach ($targets as $imageId => $imageTargets) {
+ if (in_array($imageId, $progress['done'], true)) {
+ continue;
+ }
+
+ try {
+ $scans = ScanImage::make()->rescan($server, $imageId, $imageTargets, 0, retryWhenBusy: ! $lastAttempt);
+ if (! $lastAttempt && $scans !== [] && collect($scans)->every(fn (ImageScan $scan): bool => ScanImage::lockWasBusy($scan))) {
+ $this->retryLater($progress);
+
+ return;
+ }
+ $progress['scanned'] += count($scans);
+ $progress['completed'] += count(array_filter($scans, fn (ImageScan $scan): bool => $scan->status === ImageScanStatus::Completed));
+ $this->refreshCandidates($scans);
+ } catch (Throwable $e) {
+ $progress['errors']++;
+ report($e);
+ }
+ $progress['done'][] = $imageId;
+ }
+ self::recordRescanResult($server, $progress['completed'] > 0 || ($progress['scanned'] === 0 && $progress['errors'] === 0));
+ $this->forgetProgress();
+ $this->refreshCountsAfterEndedIgnores($server);
+
+ // A cut list would make running images look stopped.
+ if (! $listing['truncated']) {
+ $this->cleanUp($server, $commands, $targets, $runningRefs);
+ }
+ }
+
+ /**
+ * An ignore that ended counts its findings again: the stored counts of the resources it covered are written again,
+ * also for images that no longer run. Once per team and day, for ignores that ended in the last two days: by their
+ * end date, or by the 90 days after which an ignore no longer covers a problem with an exploit signal.
+ */
+ private function refreshCountsAfterEndedIgnores(Server $server): void
+ {
+ try {
+ $signalLimit = now()->subDays(IgnoreMatcher::EXPLOIT_SIGNAL_IGNORE_DAYS);
+ $ended = ImageScanIgnore::query()
+ ->where('team_id', $server->team_id)
+ ->where(fn ($query) => $query
+ ->where(fn ($query) => $query
+ ->whereNotNull('expires_at')
+ ->where('expires_at', '<=', now())
+ ->where('expires_at', '>', now()->subDays(2)))
+ ->orWhere(fn ($query) => $query
+ ->where('created_at', '<=', $signalLimit)
+ ->where('created_at', '>', $signalLimit->copy()->subDays(2))
+ ->where(fn ($query) => $query->whereNull('expires_at')->orWhere('expires_at', '>', now()->subDays(2)))))
+ ->get();
+ if ($ended->isEmpty() || ! Cache::add("image-scan:ended-ignores:{$server->team_id}:".now()->toDateString(), true, now()->addDay())) {
+ return;
+ }
+
+ if ($ended->contains(fn (ImageScanIgnore $ignore): bool => $ignore->resource_id === null)) {
+ RefreshImageScanSummariesJob::dispatch((int) $server->team_id);
+
+ return;
+ }
+
+ $ended->map(fn (ImageScanIgnore $ignore) => $ignore->resource)
+ ->filter(fn ($resource): bool => $resource instanceof Model)
+ ->unique(fn (Model $resource): string => $resource->getMorphClass().':'.$resource->getKey())
+ ->each(fn (Model $resource) => ResourceScans::refreshSummaries(ResourceScans::currentFor($resource)));
+ } catch (Throwable $e) {
+ report($e);
+ }
+ }
+
+ /**
+ * What earlier attempts of this job did: database updated, image IDs rescanned, and the counts of the result.
+ *
+ * @return array{database: bool, done: list, scanned: int, completed: int, errors: int}
+ */
+ private function progress(): array
+ {
+ $empty = ['database' => false, 'done' => [], 'scanned' => 0, 'completed' => 0, 'errors' => 0];
+ $key = $this->progressCacheKey();
+ $stored = $key === null ? null : Cache::get($key);
+
+ return is_array($stored) ? [...$empty, ...$stored] : $empty;
+ }
+
+ /**
+ * @param array{database: bool, done: list, scanned: int, completed: int, errors: int} $progress
+ */
+ private function retryLater(array $progress): void
+ {
+ $key = $this->progressCacheKey();
+ if ($key !== null) {
+ Cache::put($key, $progress, now()->addHours(12));
+ }
+ $this->release(ScanResourceImagesJob::lockRetryDelay($this->attempts()));
+ }
+
+ private function forgetProgress(): void
+ {
+ $key = $this->progressCacheKey();
+ if ($key !== null) {
+ Cache::forget($key);
+ }
+ }
+
+ /**
+ * A released job runs again from its stored payload, which keeps its UUID.
+ */
+ private function progressCacheKey(): ?string
+ {
+ $uuid = $this->job?->uuid();
+
+ return $uuid === null ? null : "image-rescan:progress:{$uuid}";
+ }
+
+ /**
+ * Looks for newer versions of each rescanned image that has a relevant finding (spec 4.5, 15.4).
+ * The rescan released the lock; each candidate scan takes it again. Runs here, one image after the other, under
+ * the unique lock of RefreshCandidatesJob: a queued or running job of the same scan does the check instead. When
+ * the scan lock is busy, the check moves to a queued job that retries later.
+ *
+ * @param list $scans
+ */
+ private function refreshCandidates(array $scans): void
+ {
+ $uniqueLock = new UniqueLock(Cache::store());
+
+ foreach ($scans as $scan) {
+ try {
+ if (! RefreshCandidates::shouldRefresh($scan)) {
+ continue;
+ }
+ $job = new RefreshCandidatesJob($scan);
+ if (! $uniqueLock->acquire($job)) {
+ continue;
+ }
+ $busy = false;
+ try {
+ RefreshCandidates::run($scan, 0, retryWhenBusy: true);
+ } catch (ScanLockBusy) {
+ $busy = true;
+ } finally {
+ $uniqueLock->release($job);
+ }
+ if ($busy) {
+ RefreshCandidatesJob::queueCheck($scan);
+ }
+ } catch (Throwable $e) {
+ report($e);
+ }
+ }
+ }
+
+ /**
+ * Retention and SBOM deletion under the scan lock, so a scan that starts meanwhile keeps its SBOM and rows. When
+ * the lock is busy they skip a day.
+ *
+ * @param array> $targets
+ * @param array> $runningRefs
+ */
+ private function cleanUp(Server $server, ScannerCommands $commands, array $targets, array $runningRefs): void
+ {
+ $lock = ScanImage::serverLock($server);
+ if (! ScanImage::acquireServerLock($lock, $server, 0)) {
+ return;
+ }
+
+ try {
+ $this->applyRetention($server, $targets, $runningRefs);
+ $this->deleteUnusedSboms($server, $commands);
+ } catch (Throwable $e) {
+ report($e);
+ } finally {
+ $lock->release();
+ }
+ }
+
+ /**
+ * Updates the database under the scan lock, so no scan reads it while it changes. A failed update keeps the
+ * existing database; the scans report when it is too old. False when the lock is busy.
+ */
+ private function updateDatabase(Server $server, ScannerCommands $commands): bool
+ {
+ $lock = ScanImage::serverLock($server);
+ if (! ScanImage::acquireServerLock($lock, $server, 0)) {
+ return false;
+ }
+
+ $url = InstanceSettings::get()->image_scan_db_url;
+
+ try {
+ $output = instant_remote_process($commands->dbUpdate(is_string($url) && trim($url) !== '' ? trim($url) : null), $server, throwError: false, timeout: self::TIMEOUT_DB_UPDATE);
+ if ((ScannerCommands::parseOutput($output)['exit_codes']['db-update'] ?? null) === 0) {
+ PrepareImageScannerJob::storeDetails($server, $commands);
+ }
+ } catch (Throwable $e) {
+ report($e);
+ } finally {
+ $lock->release();
+ }
+
+ return true;
+ }
+
+ /**
+ * The running containers, or null when they could not be listed. `truncated`: the list was cut at the output cap.
+ *
+ * @return array{containers: list}>, truncated: bool}|null
+ */
+ private function runningContainers(Server $server, ScannerCommands $commands): ?array
+ {
+ try {
+ $parsed = ScannerCommands::parseOutput(instant_remote_process($commands->runningContainers(), $server, throwError: false, timeout: self::TIMEOUT_DEFAULT));
+ } catch (Throwable $e) {
+ report($e);
+
+ return null;
+ }
+ if (($parsed['exit_codes']['containers'] ?? null) !== 0) {
+ return null;
+ }
+
+ $payload = $parsed['payload'];
+ $truncated = strlen($payload) > ScannerCommands::OUTPUT_LIMIT;
+ if ($truncated) {
+ $payload = substr($payload, 0, (int) strrpos(substr($payload, 0, ScannerCommands::OUTPUT_LIMIT), "\n"));
+ }
+
+ $containers = [];
+ foreach (explode("\n", $payload) as $line) {
+ $container = json_decode(trim($line), true);
+ if (! is_array($container)
+ || ! is_string($container['id'] ?? null)
+ || preg_match('/\Asha256:[a-f0-9]{64}\z/', $container['id']) !== 1) {
+ continue;
+ }
+
+ $containers[] = [
+ 'id' => $container['id'],
+ 'ref' => is_string($container['ref'] ?? null) ? $container['ref'] : '',
+ 'labels' => collect(is_array($container['labels'] ?? null) ? $container['labels'] : [])
+ ->filter(fn ($value, $key) => is_string($key) && is_scalar($value))
+ ->map(fn ($value) => (string) $value),
+ ];
+ }
+
+ return ['containers' => $containers, 'truncated' => $truncated];
+ }
+
+ /**
+ * The resources of this server that run each image ID, with the image ref of their container, and the image
+ * refs each resource runs. Containers of resources on other servers, of previews that no longer exist, and of
+ * no resource are skipped.
+ *
+ * @param list}> $containers
+ * @return array{0: array>, 1: array>}
+ */
+ private function targets(Server $server, array $containers): array
+ {
+ $applications = $server->applications();
+ $databases = $server->databases();
+ $services = $server->services()->with(['applications', 'databases'])->get();
+ $targets = [];
+ $runningRefs = [];
+
+ foreach ($containers as $container) {
+ $labels = $container['labels'];
+ if (filter_var($labels->get('com.docker.compose.oneoff'), FILTER_VALIDATE_BOOLEAN)) {
+ continue;
+ }
+
+ $resource = match (true) {
+ isContainerOfType($labels, 'application') => $this->applicationOwner($applications, $labels),
+ isContainerOfType($labels, 'service') => resolveServiceContainerOwner($services, $labels)[2],
+ isContainerOfType($labels, 'database') => resolveContainerOwner($databases, $labels, 'database'),
+ default => null,
+ };
+ if (! $resource instanceof Model) {
+ continue;
+ }
+
+ $key = $resource->getMorphClass().':'.$resource->getKey();
+ $targets[$container['id']][$key] ??= [$resource, $container['ref'] !== '' ? $container['ref'] : $container['id']];
+ $runningRefs[$key][] = $container['ref'];
+ }
+
+ return [array_map('array_values', $targets), $runningRefs];
+ }
+
+ /**
+ * @param Collection $applications
+ * @param Collection $labels
+ */
+ private function applicationOwner(Collection $applications, Collection $labels): ?Application
+ {
+ $application = resolveContainerOwner($applications, $labels, 'application');
+ $pullRequestId = (int) $labels->get('coolify.pullRequestId', 0);
+ if (! $application instanceof Application || $pullRequestId === 0) {
+ return $application instanceof Application ? $application : null;
+ }
+
+ $previewExists = ApplicationPreview::query()
+ ->where('application_id', $application->id)
+ ->where('pull_request_id', $pullRequestId)
+ ->exists();
+
+ return $previewExists ? $application : null;
+ }
+
+ /**
+ * Keeps the scans of running images, of each application's rollback list, and the latest scans per resource;
+ * deletes scans of previews that no longer exist (spec 4.3). Only rows of this server.
+ *
+ * @param array> $targets
+ * @param array> $runningRefs
+ */
+ private function applyRetention(Server $server, array $targets, array $runningRefs): void
+ {
+ $running = [];
+ foreach ($targets as $imageId => $imageTargets) {
+ foreach ($imageTargets as [$resource]) {
+ $running[$resource->getMorphClass().':'.$resource->getKey().':'.$imageId] = true;
+ }
+ }
+
+ ImageScan::query()
+ ->where('server_id', $server->id)
+ ->select(['resource_type', 'resource_id'])
+ ->distinct()
+ ->get()
+ ->each(function (ImageScan $group) use ($server, $running, $runningRefs) {
+ $scans = ImageScan::query()
+ ->where('server_id', $server->id)
+ ->where('resource_type', $group->resource_type)
+ ->where('resource_id', $group->resource_id)
+ ->with('deploymentQueue:id,pull_request_id')
+ ->orderByRaw('coalesce(scanned_at, updated_at) desc')
+ ->orderByDesc('id')
+ ->get(['id', 'resource_type', 'resource_id', 'image_id', 'image_ref', 'deployment_queue_id', 'scanned_at', 'updated_at']);
+
+ $application = $group->resource_type === (new Application)->getMorphClass()
+ ? Application::withTrashed()->with('settings')->find($group->resource_id)
+ : null;
+ $rollback = $application !== null
+ ? $this->rollbackList($server, $application, $runningRefs[$group->resource_type.':'.$group->resource_id] ?? [])
+ : ['deployments' => [], 'refs' => []];
+ $previews = $application !== null ? $this->existingPreviews($application) : [];
+
+ $delete = [];
+ foreach ($scans as $index => $scan) {
+ $pullRequestId = $this->previewPullRequestId($scan, $application);
+ $previewGone = $pullRequestId !== null && ! in_array($pullRequestId, $previews, true);
+ $isRunning = isset($running[$scan->resource_type.':'.$scan->resource_id.':'.$scan->image_id]);
+ $keep = $index < self::KEEP_LATEST_SCANS
+ || in_array((int) $scan->deployment_queue_id, $rollback['deployments'], true)
+ || in_array($scan->image_ref, $rollback['refs'], true);
+ // A row is per image, so a preview and production can share it: a running image always stays.
+ if (! $isRunning && ($previewGone || ! $keep)) {
+ $delete[] = $scan->id;
+ }
+ }
+
+ if ($delete !== []) {
+ ImageScan::query()->where('server_id', $server->id)->whereKey($delete)->delete();
+ }
+ });
+ }
+
+ /**
+ * The deployments and image refs of the images that CleanupDocker keeps for rollbacks besides the running ones:
+ * the latest `docker_images_to_keep` finished deployments whose image is not running, none when the server
+ * turned off image retention.
+ *
+ * @param list $runningRefs
+ * @return array{deployments: list, refs: list}
+ */
+ private function rollbackList(Server $server, Application $application, array $runningRefs): array
+ {
+ $keep = $server->settings?->disable_application_image_retention ? 0 : max(0, (int) ($application->settings?->docker_images_to_keep ?? 2));
+ if ($keep === 0) {
+ return ['deployments' => [], 'refs' => []];
+ }
+
+ $deployments = ApplicationDeploymentQueue::query()
+ ->where('application_id', (string) $application->id)
+ ->where('pull_request_id', 0)
+ ->where('status', ApplicationDeploymentStatus::FINISHED->value)
+ ->orderByDesc('created_at')
+ ->orderByDesc('id')
+ ->get(['id', 'commit']);
+
+ $repository = $application->docker_registry_image_name ?: $application->uuid;
+ $commits = $deployments->pluck('commit')
+ ->filter(fn ($commit) => filled($commit))
+ ->unique()
+ ->reject(fn (string $commit) => in_array($repository.':'.substr($commit, 0, 128), $runningRefs, true))
+ ->take($keep)
+ ->values();
+
+ return [
+ 'deployments' => $deployments->whereIn('commit', $commits->all())->pluck('id')->map(fn ($id) => (int) $id)->values()->all(),
+ 'refs' => $commits->map(fn (string $commit) => $repository.':'.substr($commit, 0, 128))->all(),
+ ];
+ }
+
+ /**
+ * @return list
+ */
+ private function existingPreviews(Application $application): array
+ {
+ return ApplicationPreview::query()
+ ->where('application_id', $application->id)
+ ->pluck('pull_request_id')
+ ->map(fn ($id) => (int) $id)
+ ->all();
+ }
+
+ /**
+ * The pull request of a preview scan: from its deployment, else from the preview tag of its image
+ * (`{image}:pr-{id}-{commit}`). Null for a scan of the application itself.
+ */
+ private function previewPullRequestId(ImageScan $scan, ?Application $application): ?int
+ {
+ if ($application === null) {
+ return null;
+ }
+
+ $fromDeployment = (int) ($scan->deploymentQueue?->pull_request_id ?? 0);
+ if ($fromDeployment > 0) {
+ return $fromDeployment;
+ }
+
+ $repository = preg_quote($application->docker_registry_image_name ?: $application->uuid, '/');
+ if (preg_match('/\A'.$repository.':pr-(\d+)-/', (string) $scan->image_ref, $match) === 1) {
+ return (int) $match[1];
+ }
+
+ return null;
+ }
+
+ /**
+ * Deletes SBOMs of this server that no scan uses and that were not used for 30 days, files first.
+ */
+ private function deleteUnusedSboms(Server $server, ScannerCommands $commands): void
+ {
+ $sboms = ImageSbom::query()
+ ->where('server_id', $server->id)
+ ->where('last_used_at', '<', now()->subDays(self::SBOM_UNUSED_DAYS))
+ ->whereDoesntHave('scans')
+ ->orderBy('id')
+ ->limit(self::MAX_SBOM_DELETES)
+ ->get(['id', 'image_id'])
+ ->filter(fn (ImageSbom $sbom) => preg_match('/\Asha256:[a-f0-9]{64}\z/', (string) $sbom->image_id) === 1);
+ if ($sboms->isEmpty()) {
+ return;
+ }
+
+ $output = instant_remote_process($commands->deleteSboms($sboms->pluck('image_id')->values()->all()), $server, throwError: false, timeout: self::TIMEOUT_DEFAULT);
+ if ((ScannerCommands::parseOutput($output)['exit_codes']['sbom-delete'] ?? null) !== 0) {
+ return;
+ }
+
+ ImageSbom::query()
+ ->where('server_id', $server->id)
+ ->whereKey($sboms->pluck('id')->all())
+ ->whereDoesntHave('scans')
+ ->delete();
+ }
+}
diff --git a/app/Jobs/ImageScanAlertCheckJob.php b/app/Jobs/ImageScanAlertCheckJob.php
new file mode 100644
index 0000000000..cf51ba5ef4
--- /dev/null
+++ b/app/Jobs/ImageScanAlertCheckJob.php
@@ -0,0 +1,55 @@
+onQueue('high');
+ }
+
+ public function uniqueId(): string
+ {
+ return $this->scanId.':'.$this->scannedAt;
+ }
+
+ /**
+ * @return list
+ */
+ public function backoff(): array
+ {
+ return [60, 300];
+ }
+
+ public function handle(): void
+ {
+ $scan = ImageScan::query()->find($this->scanId);
+ if ($scan instanceof ImageScan) {
+ // Errors fail the run, so it is tried again.
+ ScanNotifications::afterScan($scan, candidateCheck: true);
+ }
+ }
+}
diff --git a/app/Jobs/ImageScanDigestJob.php b/app/Jobs/ImageScanDigestJob.php
new file mode 100644
index 0000000000..866644026b
--- /dev/null
+++ b/app/Jobs/ImageScanDigestJob.php
@@ -0,0 +1,251 @@
+onQueue(isCloud() ? maintenance_queue() : 'default');
+ }
+
+ public function uniqueId(): string
+ {
+ return (string) $this->team->id;
+ }
+
+ public function handle(): void
+ {
+ if (! instanceSettings()->is_image_scanning_allowed) {
+ return;
+ }
+
+ /** @var list> $marks */
+ $marks = [];
+ $resources = [];
+ foreach (TeamVulnerabilities::load($this->team->id)->entries as $entry) {
+ if (! $entry['scanned']) {
+ continue;
+ }
+
+ try {
+ $resource = $this->resourceDigest($entry['resource'], $entry['scans'], $entry['security'], $marks);
+ } catch (Throwable $e) {
+ report($e);
+
+ continue;
+ }
+ if ($resource !== null) {
+ $resources[] = $resource;
+ }
+ }
+
+ $ignores = $this->endingIgnores();
+ $servers = $this->failingServers();
+
+ if ($resources !== [] || $ignores->isNotEmpty() || $servers->isNotEmpty()) {
+ $order = array_flip(TeamVulnerabilities::STATUS_ORDER);
+ usort($resources, fn (array $a, array $b): int => [$order[$a['status']] ?? 99, $a['name']] <=> [$order[$b['status']] ?? 99, $b['name']]);
+
+ $this->team->notify(new VulnerabilityDigest([
+ 'resources' => $resources,
+ 'ignores' => $ignores->map(fn (ImageScanIgnore $ignore): array => [
+ 'resource' => $ignore->resource instanceof Model ? (string) ScanNotifications::pageResource($ignore->resource)->getAttribute('name') : null,
+ 'id' => $ignore->vulnerability_id,
+ 'package' => $ignore->package,
+ 'expires_at' => $ignore->expires_at->toDateString(),
+ ])->values()->all(),
+ 'servers' => $servers->map(fn (Server $server): array => [
+ 'name' => (string) $server->name,
+ 'url' => rtrim(base_url(), '/').'/server/'.$server->uuid,
+ ])->values()->all(),
+ ]));
+
+ foreach ($ignores as $ignore) {
+ Cache::put(self::ignoreCacheKey($ignore), $ignore->expires_at->toIso8601String(), $ignore->expires_at->copy()->addDay());
+ }
+ foreach ($servers as $server) {
+ ImageRescanJob::markFailureNotified($server);
+ }
+ }
+
+ foreach ($marks as $mark) {
+ try {
+ ScanNotifications::applyDigest($mark);
+ } catch (Throwable $e) {
+ report($e);
+ }
+ }
+ }
+
+ public static function ignoreCacheKey(ImageScanIgnore $ignore): string
+ {
+ return "image-scan-digest:ignore:{$ignore->id}";
+ }
+
+ /**
+ * The digest line of one resource page (a service covers its applications and databases), or null. Each scan
+ * owner has its own notification state; its current scans are decoded one owner at a time.
+ *
+ * @param Collection $current The current scans, with their owner.
+ * @param list> $marks Gets the marks of the resource only when its digest succeeded.
+ * @return array{name: string, uuid: string, url: ?string, status: string, badge: string, action: ?string, why: list>}|null
+ */
+ private function resourceDigest(Model $resource, Collection $current, ResourceSecurity $security, array &$marks): ?array
+ {
+ $resourceMarks = [];
+ $digest = $this->buildResourceDigest($resource, $current, $security, $resourceMarks);
+ array_push($marks, ...$resourceMarks);
+
+ return $digest;
+ }
+
+ /**
+ * @param Collection $current
+ * @param list> $marks
+ * @return array{name: string, uuid: string, url: ?string, status: string, badge: string, action: ?string, why: list>}|null
+ */
+ private function buildResourceDigest(Model $resource, Collection $current, ResourceSecurity $security, array &$marks): ?array
+ {
+ $owners = $current->map(fn (ImageScan $scan) => $scan->resource)
+ ->filter(fn ($owner): bool => $owner instanceof Model)
+ ->unique(fn (Model $owner): string => $owner->getMorphClass().':'.$owner->getKey());
+
+ $plans = $owners->map(fn (Model $owner): ?array => ScanNotifications::digestPlan($owner))->filter()->values();
+ if ($plans->isEmpty()) {
+ return null;
+ }
+
+ $needsCheck = $security->status === SecurityStatusType::CheckNeeded;
+ if ($needsCheck && $security->checkReason === SecurityStatus::REASON_VERSIONS_UNCHECKED && $plans->contains('pending', true)) {
+ return null;
+ }
+
+ $active = $plans->where('baseline', false);
+ $holds = fn (array $plan): bool => count($plan['newFixable']) > count($plan['newSignalFixable']) || count($plan['newCandidates']) > count($plan['newSignalCandidates']);
+ // Fixes of problems with an exploit signal are never held; other new problems are held at most MAX_HELD_DIGESTS digests.
+ $bypass = $needsCheck && $active->contains(fn (array $plan): bool => $plan['newSignalFixable'] !== [] || $plan['newSignalCandidates'] !== []);
+ $release = $needsCheck && $active->contains(fn (array $plan): bool => $plan['heldDigests'] >= self::MAX_HELD_DIGESTS) && $active->contains($holds);
+ $report = match ($security->status) {
+ SecurityStatusType::UpdateNow, SecurityStatusType::UpdateAvailable => $active->contains(fn (array $plan): bool => $plan['newFixable'] !== [] || $plan['newCandidates'] !== []),
+ SecurityStatusType::CheckNeeded => $active->isNotEmpty() && ($bypass || $release || ! $active->contains('checkReported', true)),
+ default => false,
+ };
+
+ foreach ($plans as $plan) {
+ $mark = $plan['mark'];
+ $mark['check_reported'] = $needsCheck;
+ $mark['held_digests'] = 0;
+ if ($needsCheck && ! $plan['baseline'] && ! $release) {
+ $mark['add_digest_ids'] = array_values(array_unique(array_map(fn ($finding): string => $finding->id, $plan['newSignalFixable'])));
+ $mark['add_candidates'] = $plan['newSignalCandidates'];
+ $mark['held_digests'] = $holds($plan) ? $plan['heldDigests'] + 1 : 0;
+ }
+ $marks[] = $mark;
+ }
+
+ if (! $report) {
+ return null;
+ }
+
+ $action = TeamVulnerabilities::action($security)['text'] ?? null;
+ if ($needsCheck && ($bypass || $release) && filled($security->primary()?->sentence)) {
+ $action = (string) $security->primary()->sentence;
+ }
+
+ return [
+ 'name' => (string) $resource->getAttribute('name'),
+ 'uuid' => (string) $resource->getAttribute('uuid'),
+ 'url' => ScanNotifications::link($resource),
+ 'status' => $security->status->value,
+ 'badge' => (string) SecuritySentences::badge($security->status),
+ 'action' => $action,
+ 'why' => $needsCheck && ! $bypass && ! $release ? [] : $security->why,
+ ];
+ }
+
+ /**
+ * Ignores of the team that end within IGNORE_WINDOW_DAYS and no digest named with this end date.
+ *
+ * @return Collection
+ */
+ private function endingIgnores(): Collection
+ {
+ return ImageScanIgnore::query()
+ ->where('team_id', $this->team->id)
+ ->whereNotNull('expires_at')
+ ->where('expires_at', '>', now())
+ ->where('expires_at', '<=', now()->addDays(self::IGNORE_WINDOW_DAYS))
+ ->orderBy('expires_at')
+ ->get()
+ ->reject(fn (ImageScanIgnore $ignore): bool => Cache::get(self::ignoreCacheKey($ignore)) === $ignore->expires_at->toIso8601String())
+ ->values();
+ }
+
+ /**
+ * Servers of the team that still scan images and whose daily rescan failed 2 days in a row, not named yet for
+ * this run of failures.
+ *
+ * @return Collection
+ */
+ private function failingServers(): Collection
+ {
+ return Server::query()
+ ->where('team_id', $this->team->id)
+ ->with('settings')
+ ->get()
+ ->filter(fn (Server $server): bool => (bool) $server->settings?->is_image_scan_enabled
+ && ! $server->isTransferredAway()
+ && ImageRescanJob::failedDays($server) >= 2
+ && ! ImageRescanJob::isFailureNotified($server))
+ ->values();
+ }
+}
diff --git a/app/Jobs/PrepareFileDownloadJob.php b/app/Jobs/PrepareFileDownloadJob.php
index d121d5e648..4d57bdf38e 100644
--- a/app/Jobs/PrepareFileDownloadJob.php
+++ b/app/Jobs/PrepareFileDownloadJob.php
@@ -47,7 +47,7 @@ class PrepareFileDownloadJob implements ShouldQueue
public function handle(FileDownloadStorageService $downloads): void
{
try {
- $storage = S3Storage::query()->whereTeamId(0)->where('is_usable', true)->find($this->s3StorageId);
+ $storage = S3Storage::query()->s3Only()->whereTeamId(0)->where('is_usable', true)->find($this->s3StorageId);
if (! $storage) {
throw new \RuntimeException('The file download storage is not available.');
}
diff --git a/app/Jobs/PrepareImageScannerJob.php b/app/Jobs/PrepareImageScannerJob.php
new file mode 100644
index 0000000000..b30a03b01a
--- /dev/null
+++ b/app/Jobs/PrepareImageScannerJob.php
@@ -0,0 +1,251 @@
+onQueue(isCloud() ? maintenance_queue() : 'default');
+ }
+
+ public function uniqueId(): string
+ {
+ return (string) $this->server->id;
+ }
+
+ public static function stateCacheKey(Server $server): string
+ {
+ return "image-scan-prepare:{$server->id}";
+ }
+
+ public static function builtAtCacheKey(Server $server): string
+ {
+ return self::stateCacheKey($server).':built-at';
+ }
+
+ public static function diskUsedCacheKey(Server $server): string
+ {
+ return self::stateCacheKey($server).':disk-used';
+ }
+
+ public static function failedStepCacheKey(Server $server): string
+ {
+ return self::stateCacheKey($server).':failed-step';
+ }
+
+ public static function forget(Server $server): void
+ {
+ Cache::forget(self::stateCacheKey($server));
+ Cache::forget(self::failedStepCacheKey($server));
+ Cache::forget(self::builtAtCacheKey($server));
+ Cache::forget(self::diskUsedCacheKey($server));
+ }
+
+ public function handle(): void
+ {
+ $server = $this->server->fresh(['settings']);
+ if (! $server instanceof Server || $server->isTransferredAway()) {
+ return;
+ }
+
+ $settings = InstanceSettings::get();
+ if (! $settings->is_image_scanning_allowed || ! $server->settings?->is_image_scan_enabled) {
+ return;
+ }
+
+ try {
+ $this->prepare($server, $settings->image_scan_db_url);
+ } catch (\Throwable $e) {
+ report($e);
+ $this->setState($server, self::STATE_FAILED, self::FAILED_CONNECTION);
+ }
+ }
+
+ public function failed(\Throwable $exception): void
+ {
+ $this->setState($this->server, self::STATE_FAILED);
+ }
+
+ private function prepare(Server $server, ?string $dbUrl): void
+ {
+ $commands = new ScannerCommands($server, Str::lower(Str::random(24)));
+
+ $this->setState($server, self::STATE_PULLING);
+ $pulled = self::run($commands->prepare(), $server, self::TIMEOUT_PREPARE);
+ if ($pulled['exit_codes'] === []) {
+ $this->setState($server, self::STATE_FAILED, self::FAILED_CONNECTION);
+
+ return;
+ }
+ foreach (['pull-syft', 'pull-grype', 'pull-regctl', 'pull-init'] as $step) {
+ if (($pulled['exit_codes'][$step] ?? null) !== 0) {
+ $this->setState($server, self::STATE_FAILED, self::FAILED_PULLING);
+
+ return;
+ }
+ }
+ if (($pulled['exit_codes']['db-volume'] ?? null) !== 0) {
+ $this->setState($server, self::STATE_FAILED, self::FAILED_SETUP);
+
+ return;
+ }
+
+ if (! $this->isStillEnabled($server)) {
+ return;
+ }
+
+ $this->setState($server, self::STATE_UPDATING_DATABASE);
+ $lock = ScanImage::serverLock($server);
+ if (! ScanImage::acquireServerLock($lock, $server, 0)) {
+ if ($this->attempts() >= $this->tries) {
+ $this->setState($server, self::STATE_FAILED);
+
+ return;
+ }
+ $this->release(ScanResourceImagesJob::lockRetryDelay($this->attempts()));
+
+ return;
+ }
+
+ try {
+ $updated = self::run($commands->dbUpdate(is_string($dbUrl) && trim($dbUrl) !== '' ? trim($dbUrl) : null), $server, self::TIMEOUT_DB_UPDATE);
+ } finally {
+ $lock->release();
+ }
+ if (($updated['exit_codes']['db-update'] ?? null) !== 0) {
+ $this->setState($server, self::STATE_FAILED);
+
+ return;
+ }
+
+ if (! $this->isStillEnabled($server)) {
+ return;
+ }
+
+ self::storeDetails($server, $commands);
+ $this->setState($server, self::STATE_READY);
+ }
+
+ /**
+ * @param list $commands
+ * @return array{exit_codes: array, sizes: array, payload: string}
+ */
+ private static function run(array $commands, Server $server, int $timeout): array
+ {
+ return ScannerCommands::parseOutput(instant_remote_process($commands, $server, throwError: false, timeout: $timeout));
+ }
+
+ /**
+ * Caches the database build date and the disk use of the scanner data that the server page shows.
+ */
+ public static function storeDetails(Server $server, ScannerCommands $commands): void
+ {
+ Cache::forget(self::builtAtCacheKey($server));
+ Cache::forget(self::diskUsedCacheKey($server));
+
+ try {
+ $status = self::run($commands->dbStatus(), $server, self::TIMEOUT_DB_STATUS);
+ $report = ($status['exit_codes']['db-status'] ?? null) === 0 ? json_decode($status['payload'], true) : null;
+ $built = is_array($report) && ($report['valid'] ?? false) === true ? ($report['built'] ?? null) : null;
+ if (is_string($built)) {
+ Cache::put(self::builtAtCacheKey($server), CarbonImmutable::parse($built)->toIso8601ZuluString(), now()->addDays(7));
+ }
+ } catch (\Throwable $e) {
+ report($e);
+ }
+
+ try {
+ $usage = self::run($commands->diskUsage(), $server, self::TIMEOUT_DB_STATUS);
+ if (isset($usage['sizes']['disk-db']) || isset($usage['sizes']['disk-sboms'])) {
+ Cache::put(self::diskUsedCacheKey($server), ($usage['sizes']['disk-db'] ?? 0) + ($usage['sizes']['disk-sboms'] ?? 0), now()->addDays(7));
+ }
+ } catch (\Throwable $e) {
+ report($e);
+ }
+ }
+
+ private function isStillEnabled(Server $server): bool
+ {
+ $enabled = (bool) $server->settings()->value('is_image_scan_enabled');
+ if (! $enabled) {
+ self::forget($server);
+ }
+
+ return $enabled;
+ }
+
+ /**
+ * @param string|null $failedStep one of the FAILED_* steps; without it a failure is put on the running step
+ */
+ private function setState(Server $server, string $state, ?string $failedStep = null): void
+ {
+ $running = in_array($state, [self::STATE_PULLING, self::STATE_UPDATING_DATABASE], true);
+ $previous = Cache::get(self::stateCacheKey($server));
+ $failedStep ??= in_array($previous, [self::STATE_PULLING, self::STATE_UPDATING_DATABASE], true) ? $previous : null;
+ if ($state === self::STATE_FAILED && $failedStep !== null) {
+ Cache::put(self::failedStepCacheKey($server), $failedStep, now()->addDays(7));
+ } elseif ($running) {
+ Cache::forget(self::failedStepCacheKey($server));
+ }
+ Cache::put(self::stateCacheKey($server), $state, $running ? now()->addHour() : now()->addDays(7));
+ ImageScanUpdated::dispatch($server->team_id, $server->uuid, self::SCAN_UUID);
+ }
+}
diff --git a/app/Jobs/RefreshCandidatesJob.php b/app/Jobs/RefreshCandidatesJob.php
new file mode 100644
index 0000000000..0d1b1165b7
--- /dev/null
+++ b/app/Jobs/RefreshCandidatesJob.php
@@ -0,0 +1,95 @@
+onQueue(isCloud() ? maintenance_queue() : 'default');
+ }
+
+ /**
+ * Queues the check and marks it as queued (`RefreshCandidates::isQueued()`).
+ */
+ public static function queueCheck(ImageScan $scan): void
+ {
+ RefreshCandidates::markQueued($scan, (new self($scan))->uniqueFor);
+ self::dispatch($scan);
+ }
+
+ public function uniqueId(): string
+ {
+ return (string) $this->scan->id;
+ }
+
+ public function handle(): void
+ {
+ $scan = $this->scan->fresh();
+ // The deploy gate or the daily rescan checked it meanwhile.
+ if (! $scan instanceof ImageScan || RefreshCandidates::isFresh($scan)) {
+ RefreshCandidates::forgetQueued($this->scan);
+
+ return;
+ }
+
+ try {
+ RefreshCandidates::run($scan, 0, retryWhenBusy: $this->attempts() < $this->tries);
+ RefreshCandidates::forgetQueued($scan);
+ } catch (ScanLockBusy) {
+ $this->release(ScanResourceImagesJob::lockRetryDelay($this->attempts()));
+ }
+ }
+
+ /**
+ * The check stored no result, so an alert that waits for it is sent now (spec 15.8).
+ */
+ public function failed(?Throwable $exception): void
+ {
+ RefreshCandidates::forgetQueued($this->scan);
+ $scan = $this->scan->fresh();
+ if (! $scan instanceof ImageScan) {
+ return;
+ }
+
+ ScanNotifications::reevaluate($scan, checkGaveUp: true);
+ $resource = $scan->resource;
+ if ($resource instanceof Model) {
+ $teamId = method_exists($resource, 'team') ? $resource->team()?->id : null;
+ ImageScanUpdated::dispatch($teamId, (string) $resource->getAttribute('uuid'), (string) $scan->uuid);
+ }
+ }
+}
diff --git a/app/Jobs/RefreshImageScanSummariesJob.php b/app/Jobs/RefreshImageScanSummariesJob.php
new file mode 100644
index 0000000000..e678767b8a
--- /dev/null
+++ b/app/Jobs/RefreshImageScanSummariesJob.php
@@ -0,0 +1,32 @@
+onQueue(isCloud() ? maintenance_queue() : 'default');
+ }
+
+ public function handle(): void
+ {
+ ResourceScans::refreshSummaries(ResourceScans::currentForTeam($this->teamId));
+ }
+}
diff --git a/app/Jobs/RemoveRotatedAuthorizedKeyJob.php b/app/Jobs/RemoveRotatedAuthorizedKeyJob.php
new file mode 100644
index 0000000000..b9f738c361
--- /dev/null
+++ b/app/Jobs/RemoveRotatedAuthorizedKeyJob.php
@@ -0,0 +1,116 @@
+serverId);
+ if (! $server) {
+ return;
+ }
+ if ($this->serverUsesOldKey($server)) {
+ $this->report($server, 'moved_old_not_removed', 'The old key stays on the server, because the server uses it again.');
+
+ return;
+ }
+ if (! AuthorizedKeys::canRunOn($server)) {
+ $this->report($server, 'moved_old_not_removed', 'The old key stays on the server, because Coolify does not run commands on it.');
+
+ return;
+ }
+ if (! AuthorizedKeys::removePublicKey($server, $this->oldPublicKeyBody, $this->oldPrivateKeyId)) {
+ $this->report($server, 'moved_old_not_removed', 'The old key stays on the server, because another server for this host uses it.');
+
+ return;
+ }
+
+ $this->report($server, 'moved', 'Moved to the new key. The old key was removed.');
+ }
+
+ public function failed(?Throwable $exception): void
+ {
+ $server = Server::find($this->serverId);
+ if ($server) {
+ $message = $exception ? RotatePrivateKeyJob::errorWithoutHost($server, $exception) : 'The job stopped.';
+ $this->report($server, 'moved_old_not_removed', 'The old key was not removed: '.$message);
+ }
+ }
+
+ private function serverUsesOldKey(Server $server): bool
+ {
+ if ($server->private_key_id === $this->oldPrivateKeyId) {
+ return true;
+ }
+ $currentKey = $server->privateKey;
+ $oldFingerprint = AuthorizedKeys::fingerprint($this->oldPublicKeyBody);
+ if (! $currentKey || ! $oldFingerprint) {
+ return false;
+ }
+
+ return ($currentKey->fingerprint ?: PrivateKey::generateFingerprint($currentKey->private_key)) === $oldFingerprint;
+ }
+
+ /** Updates the row of the server in the result of the rotation, while the rotation still waits for this job. */
+ private function report(Server $server, string $status, string $message): void
+ {
+ auditLog('system.server.ssh_key_old_removed', [
+ 'team_id' => $server->team_id,
+ 'server_uuid' => $server->uuid,
+ 'server_name' => $server->name,
+ 'old_private_key_uuid' => $this->oldPrivateKeyUuid,
+ 'result' => $status,
+ ], $status === 'moved' ? 'info' : 'warning');
+
+ $cacheKey = RotatePrivateKeyJob::cacheKey($this->oldPrivateKeyUuid);
+ $state = Cache::get($cacheKey);
+ if (data_get($state, "servers.{$server->uuid}.status") !== RotatePrivateKeyJob::OLD_KEY_PENDING) {
+ return;
+ }
+ $state['servers'][$server->uuid] = [...$state['servers'][$server->uuid], 'status' => $status, 'message' => $message];
+ Cache::put($cacheKey, $state, RotatePrivateKeyJob::RESULT_TTL_SECONDS);
+ }
+}
diff --git a/app/Jobs/RotatePrivateKeyJob.php b/app/Jobs/RotatePrivateKeyJob.php
new file mode 100644
index 0000000000..29d80b7047
--- /dev/null
+++ b/app/Jobs/RotatePrivateKeyJob.php
@@ -0,0 +1,170 @@
+uuid : $privateKey;
+
+ return "private-key-rotation:{$uuid}";
+ }
+
+ public function middleware(): array
+ {
+ return [(new WithoutOverlapping("rotate-private-key-{$this->oldPrivateKeyId}"))->expireAfter($this->timeout + 60)->dontRelease()];
+ }
+
+ public function handle(): void
+ {
+ $oldKey = PrivateKey::find($this->oldPrivateKeyId);
+ if (! $oldKey) {
+ return;
+ }
+ $newKey = PrivateKey::where('team_id', $oldKey->team_id)->find($this->newPrivateKeyId);
+ $state = Cache::get(self::cacheKey($oldKey), []);
+ if (! $newKey) {
+ $this->storeState($oldKey, [...$state, 'status' => 'failed', 'error' => 'The new key does not exist anymore.']);
+
+ return;
+ }
+
+ $state = [...$state, 'status' => 'running', 'new_private_key_id' => $newKey->id, 'servers' => []];
+ $servers = Server::where('team_id', $oldKey->team_id)->where('private_key_id', $oldKey->id)->get();
+ foreach ($servers as $server) {
+ $result = $this->rotateServer($server, $oldKey, $newKey);
+ $state['servers'][$server->uuid] = ['name' => $server->name, ...$result];
+ $this->storeState($oldKey, $state);
+
+ auditLog('system.server.ssh_key_rotated', [
+ 'team_id' => $oldKey->team_id,
+ 'server_uuid' => $server->uuid,
+ 'server_name' => $server->name,
+ 'old_private_key_uuid' => $oldKey->uuid,
+ 'new_private_key_uuid' => $newKey->uuid,
+ 'result' => $result['status'],
+ ], in_array($result['status'], ['moved', self::OLD_KEY_PENDING], true) ? 'info' : 'warning');
+ }
+
+ $counts = collect($state['servers'])->countBy('status')->all();
+ $this->storeState($oldKey, [...$state, 'status' => 'finished']);
+ auditLog('system.private_key.rotated', [
+ 'team_id' => $oldKey->team_id,
+ 'private_key_uuid' => $oldKey->uuid,
+ 'private_key_name' => $oldKey->name,
+ 'new_private_key_uuid' => $newKey->uuid,
+ 'started_by_user_id' => $this->startedByUserId,
+ 'servers' => $counts,
+ ], array_diff(array_keys($counts), ['moved', self::OLD_KEY_PENDING]) === [] ? 'info' : 'warning');
+ }
+
+ public function failed(?Throwable $exception): void
+ {
+ $oldKey = PrivateKey::find($this->oldPrivateKeyId);
+ if ($oldKey) {
+ $this->storeState($oldKey, [...Cache::get(self::cacheKey($oldKey), []), 'status' => 'failed', 'error' => 'The rotation stopped. Start it again.']);
+ }
+ }
+
+ /**
+ * @return array{status: 'moved_old_pending'|'moved_old_not_removed'|'failed'|'old_key_not_found'|'unreachable'|'skipped', message: string}
+ */
+ private function rotateServer(Server $server, PrivateKey $oldKey, PrivateKey $newKey): array
+ {
+ if ($server->isTransferredAway()) {
+ return ['status' => 'skipped', 'message' => 'Another Coolify instance manages this server now.'];
+ }
+ if (! AuthorizedKeys::canRunOn($server)) {
+ return ['status' => 'unreachable', 'message' => 'The server is disabled or has no IP address.'];
+ }
+
+ try {
+ AuthorizedKeys::add($server, $newKey, $oldKey);
+ } catch (Throwable $e) {
+ return match ($e->getCode()) {
+ AuthorizedKeys::OLD_KEY_NOT_FOUND => ['status' => 'old_key_not_found', 'message' => 'The old key is not in ~/.ssh/authorized_keys of the SSH user, so Coolify cannot replace it there. Add the new key on the server yourself.'],
+ // ssh exits with 255 when it cannot connect or log in.
+ 255 => ['status' => 'unreachable', 'message' => 'Could not connect: '.self::errorWithoutHost($server, $e)],
+ default => ['status' => 'failed', 'message' => 'Could not add the new key: '.self::errorWithoutHost($server, $e)],
+ };
+ }
+
+ $probe = clone $server;
+ $probe->private_key_id = $newKey->id;
+ $probe->setRelation('privateKey', $newKey);
+ ['uptime' => $newKeyWorks] = $probe->validateConnection(justCheckingNewKey: true);
+ if (! $newKeyWorks) {
+ try {
+ AuthorizedKeys::undoAdd($server, $newKey, $oldKey);
+ } catch (Throwable) {
+ // The server stays on the old key. The next rotation adds the same new key again.
+ }
+
+ return ['status' => 'failed', 'message' => 'The new key could not log in. Check the AuthorizedKeysFile setting of sshd.'];
+ }
+
+ // The saved hook closes the multiplexed connection, so the next command logs in with the new key.
+ $server->privateKey()->associate($newKey);
+ $server->save();
+
+ $delay = RemoveRotatedAuthorizedKeyJob::delaySeconds($server);
+ RemoveRotatedAuthorizedKeyJob::dispatch($server->id, $oldKey->id, $oldKey->uuid, $oldKey->publicKeyBody())->delay($delay);
+
+ return [
+ 'status' => self::OLD_KEY_PENDING,
+ 'message' => 'Moved to the new key. The old key is removed from the server in '.CarbonInterval::seconds($delay)->cascade()->forHumans(['parts' => 2]).', when jobs that started before the rotation have finished.',
+ ];
+ }
+
+ /**
+ * @param array $state
+ */
+ private function storeState(PrivateKey $oldKey, array $state): void
+ {
+ Cache::put(self::cacheKey($oldKey), $state, self::RESULT_TTL_SECONDS);
+ }
+
+ /** The UI shows server names only, so remove the host address from SSH errors. */
+ public static function errorWithoutHost(Server $server, Throwable $e): string
+ {
+ $message = str_replace((string) $server->ip, 'the server', remove_iip($e->getMessage()));
+
+ return trim(preg_replace('/^ssh: connect to host the server port \d+:\s*/i', '', $message));
+ }
+}
diff --git a/app/Jobs/ScanResourceImagesJob.php b/app/Jobs/ScanResourceImagesJob.php
new file mode 100644
index 0000000000..fb83092883
--- /dev/null
+++ b/app/Jobs/ScanResourceImagesJob.php
@@ -0,0 +1,338 @@
+ $imageRefs Empty: the images of the resource (imageRefsFor()).
+ * @param bool $retryWhenImageMissing A job after a service or database start retries an image that is not pulled yet.
+ * @param ?string $baseImageRef The base image of the Dockerfile build of the application.
+ * @param bool $pullWhenMissing "Scan now": pull an image that is not on the server.
+ */
+ public function __construct(public Model $resource, public ?int $deploymentQueueId = null, public array $imageRefs = [], public bool $retryWhenImageMissing = false, public ?string $baseImageRef = null, public bool $pullWhenMissing = false)
+ {
+ // Self-hosted Horizon works through 'high' first, so scans there would delay deployments.
+ $this->onQueue(isCloud() ? maintenance_queue() : 'default');
+ $images = $imageRefs === [] ? $this->derivedImageCount() : count($imageRefs);
+ $this->timeout = $images * ScanImage::LOCK_TTL;
+ }
+
+ /**
+ * Queues the scan of a service or standalone database that was started. Never blocks or fails the start.
+ */
+ public static function dispatchAfterStart(Model $resource): void
+ {
+ try {
+ if (! ImageScanPolicyResolver::isScanOn($resource)) {
+ return;
+ }
+
+ self::dispatch($resource, null, [], true)->delay(now()->addSeconds(self::START_DELAY_SECONDS));
+ } catch (Throwable $e) {
+ report($e);
+ }
+ }
+
+ /**
+ * Shows a scan that "Scan now" queued as running at once: marks each scanned owner as queued and hides its last
+ * error. The scan replaces the mark with its progress; the job removes marks that are left when it ends.
+ *
+ * @param list $imageRefs
+ */
+ public static function markQueued(Model $resource, array $imageRefs = []): void
+ {
+ foreach (self::markedOwners($resource, $imageRefs) as $owner) {
+ Cache::put(ScanImage::progressCacheKey($owner), [
+ 'run_id' => self::QUEUED_STAGE,
+ 'stage' => self::QUEUED_STAGE,
+ 'started_at' => now()->toIso8601String(),
+ ], ScanImage::LOCK_TTL);
+ Cache::forget(ScanImage::lastErrorCacheKey($owner));
+ }
+ }
+
+ public function handle(): void
+ {
+ $deployment = $this->deployment();
+ $server = $this->server($deployment);
+ if (! $server instanceof Server) {
+ $this->clearQueuedMarks();
+
+ return;
+ }
+
+ $done = $this->doneTargets();
+ $missing = [];
+ $lastAttempt = $this->attempts() >= $this->tries;
+ foreach ($this->scanTargets() as [$resource, $imageRef]) {
+ $key = $this->targetKey($resource, $imageRef);
+ if (in_array($key, $done, true)) {
+ continue;
+ }
+
+ $baseImageRef = $resource === $this->resource && $resource instanceof Application ? $this->baseImageRef : null;
+ // Never sleeps on the server lock: a deploy gate may need it. A busy lock retries the job later.
+ $scan = ScanImage::run($server, $resource, $imageRef, $deployment, 0, $baseImageRef, retryWhenBusy: ! $lastAttempt, pullWhenMissing: $this->pullWhenMissing);
+
+ if (! $lastAttempt && ScanImage::lockWasBusy($scan)) {
+ $this->rememberDoneTargets($done);
+ $this->release(self::lockRetryDelay($this->attempts()));
+
+ return;
+ }
+
+ if ($this->shouldRetry($scan)) {
+ // Only the last attempt leaves the error visible.
+ Cache::forget(ScanImage::lastErrorCacheKey($resource));
+ $missing[] = $key;
+ } else {
+ $done[] = $key;
+ }
+ }
+
+ if ($missing !== []) {
+ $this->rememberDoneTargets($done);
+ $this->release(self::START_RETRY_DELAY_SECONDS);
+
+ return;
+ }
+
+ $this->clearQueuedMarks();
+ }
+
+ public function failed(?Throwable $exception = null): void
+ {
+ $this->clearQueuedMarks();
+ }
+
+ /**
+ * The owners that markQueued() marks: the application, or the scanned applications and databases of a service.
+ *
+ * @param list $imageRefs
+ * @return list
+ */
+ private static function markedOwners(Model $resource, array $imageRefs): array
+ {
+ if ($resource instanceof Application) {
+ return $imageRefs === [] ? [] : [$resource];
+ }
+
+ return array_map(fn (array $target): Model => $target[0], ResourceScans::scanTargets($resource));
+ }
+
+ /**
+ * Removes the queued marks that no scan replaced (for example a scan that could not start), and tells open pages.
+ */
+ private function clearQueuedMarks(): void
+ {
+ foreach (self::markedOwners($this->resource, $this->imageRefs) as $owner) {
+ $key = ScanImage::progressCacheKey($owner);
+ if (data_get(Cache::get($key), 'stage') !== self::QUEUED_STAGE) {
+ continue;
+ }
+
+ Cache::forget($key);
+ $teamId = method_exists($owner, 'team') ? $owner->team()?->id : null;
+ ImageScanUpdated::dispatch($teamId, (string) $owner->getAttribute('uuid'), self::QUEUED_STAGE, true);
+ }
+ }
+
+ /**
+ * The wait before attempt $attempt + 1 after a busy lock: 60, 120, then 300 seconds.
+ */
+ public static function lockRetryDelay(int $attempt): int
+ {
+ return self::LOCK_RETRY_DELAYS[min(max($attempt, 1), count(self::LOCK_RETRY_DELAYS)) - 1];
+ }
+
+ private function shouldRetry(ImageScan $scan): bool
+ {
+ return $this->retryWhenImageMissing
+ && $this->attempts() < min(self::START_MAX_ATTEMPTS, $this->tries)
+ && $scan->status === ImageScanStatus::Failed
+ && $scan->error_code === ImageScanErrorCode::ImageMissing;
+ }
+
+ private function targetKey(Model $resource, string $imageRef): string
+ {
+ return $resource->getMorphClass().':'.$resource->getKey().':'.$imageRef;
+ }
+
+ /**
+ * A released job runs again from its stored payload, so the targets of earlier attempts are kept in the cache.
+ *
+ * @return list
+ */
+ private function doneTargets(): array
+ {
+ $uuid = $this->job?->uuid();
+
+ return $uuid === null ? [] : (array) Cache::get("image-scan:start-done:{$uuid}", []);
+ }
+
+ /**
+ * @param list $done
+ */
+ private function rememberDoneTargets(array $done): void
+ {
+ $uuid = $this->job?->uuid();
+ if ($uuid !== null) {
+ Cache::put("image-scan:start-done:{$uuid}", $done, now()->addHours(2));
+ }
+ }
+
+ /**
+ * The images a resource runs. Applications: the images of their latest completed scans. Services: the images of
+ * their applications and databases. Standalone databases: their image.
+ *
+ * @return list
+ */
+ public function imageRefsFor(Model $resource): array
+ {
+ if ($resource instanceof Application) {
+ return ImageScan::query()
+ ->where('resource_type', $resource->getMorphClass())
+ ->where('resource_id', $resource->getKey())
+ ->where('status', ImageScanStatus::Completed)
+ ->orderByDesc('scanned_at')
+ ->orderByDesc('id')
+ ->pluck('image_ref')
+ ->unique()
+ ->take(self::MAX_DERIVED_IMAGES)
+ ->values()
+ ->all();
+ }
+
+ return array_map(fn (array $target): string => $target[1], $this->scanTargetsFor($resource));
+ }
+
+ /**
+ * Each image with the resource its result belongs to. A service keeps results per application and database.
+ *
+ * @return list
+ */
+ private function scanTargets(): array
+ {
+ if ($this->imageRefs !== []) {
+ $targets = array_map(fn ($imageRef): array => [$this->resource, $imageRef], $this->imageRefs);
+ } elseif ($this->resource instanceof Application) {
+ $targets = array_map(fn (string $imageRef): array => [$this->resource, $imageRef], $this->imageRefsFor($this->resource));
+ } else {
+ $targets = $this->scanTargetsFor($this->resource);
+ }
+
+ $seen = [];
+
+ return array_values(array_filter($targets, function (array $target) use (&$seen): bool {
+ [$resource, $imageRef] = $target;
+ if (! is_string($imageRef)) {
+ return false;
+ }
+ $key = $this->targetKey($resource, $imageRef);
+ if (isset($seen[$key])) {
+ return false;
+ }
+ $seen[$key] = true;
+
+ return true;
+ }));
+ }
+
+ /**
+ * @return list
+ */
+ private function scanTargetsFor(Model $resource): array
+ {
+ return ResourceScans::scanTargets($resource);
+ }
+
+ private function derivedImageCount(): int
+ {
+ if ($this->resource instanceof Service) {
+ return max(self::MAX_DERIVED_IMAGES, $this->resource->applications()->count() + $this->resource->databases()->count());
+ }
+
+ return self::MAX_DERIVED_IMAGES;
+ }
+
+ private function deployment(): ?ApplicationDeploymentQueue
+ {
+ if ($this->deploymentQueueId === null || ! $this->resource instanceof Application) {
+ return null;
+ }
+
+ return ApplicationDeploymentQueue::query()
+ ->whereKey($this->deploymentQueueId)
+ ->where('application_id', (string) $this->resource->getKey())
+ ->first();
+ }
+
+ /**
+ * The server of the deployment (an additional server runs its own copy of the image), else the server the
+ * scan policy of the resource uses.
+ */
+ private function server(?ApplicationDeploymentQueue $deployment): ?Server
+ {
+ $server = $deployment?->server ?? ImageScanPolicyResolver::mainServerOf($this->resource);
+
+ return $server instanceof Server ? $server : null;
+ }
+}
diff --git a/app/Jobs/ServerConnectionCheckJob.php b/app/Jobs/ServerConnectionCheckJob.php
index fc4ff11ac1..7ce25f28ef 100644
--- a/app/Jobs/ServerConnectionCheckJob.php
+++ b/app/Jobs/ServerConnectionCheckJob.php
@@ -49,6 +49,11 @@ class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue
/** Owner marker that the SSH probe read from the host; empty when the host has none. */
private ?string $ownerMarker = null;
+ /** The server sent a host key that does not match the pinned key. */
+ private bool $hostKeyChanged = false;
+
+ private string $sshErrorOutput = '';
+
public function __construct(
public Server $server
) {}
@@ -84,9 +89,30 @@ class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue
return;
}
+ // Enforce mode cannot connect without the key, so the check reads the key also of an unreachable server.
+ if (SshMultiplexingHelper::hostKeyCheckingMode($this->server) === 'enforce') {
+ SshMultiplexingHelper::learnHostKey($this->server, evenIfUnreachable: true);
+ }
+
// Check basic connectivity first
$isReachable = $this->checkConnection();
+ if ($this->hostKeyChanged) {
+ // A failed host key check with the pinned key (an unreadable known_hosts file) is a normal connection error.
+ $this->hostKeyChanged = $this->server->recordHostKeyMismatch($this->sshErrorOutput);
+ }
+
+ if (! $isReachable && $this->hostKeyChanged && SshMultiplexingHelper::hostKeyCheckingMode($this->server) === 'enforce') {
+ // In enforce mode a changed host key blocks every connection: stop at once, without the retry threshold.
+ $this->server->settings->update([
+ 'is_reachable' => false,
+ 'is_usable' => false,
+ ]);
+ $this->logConnectionStateChange($wasReachable, $wasUsable, false, false, 'ssh_host_key_changed');
+
+ return;
+ }
+
if (! $isReachable) {
$this->server->increment('unreachable_count');
@@ -116,6 +142,9 @@ class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue
return;
}
+ // Warn mode connects without a pinned key. Commands do not scan an unreachable server, so learn the key now.
+ SshMultiplexingHelper::learnHostKey($this->server, evenIfUnreachable: true);
+
// Server is reachable, check if Docker is available
$isUsable = $this->checkDockerAvailability();
@@ -217,6 +246,9 @@ class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue
if ($this->attemptConnection()) {
return true;
}
+ if ($this->hostKeyChanged) {
+ return false;
+ }
}
return false;
@@ -226,6 +258,8 @@ class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue
{
try {
$process = $this->runSsh(self::probeCommand(), self::SSH_TIMEOUT_SECONDS, disableMultiplexing: true);
+ $this->sshErrorOutput = $process->errorOutput();
+ $this->hostKeyChanged = SshMultiplexingHelper::isHostKeyMismatch($this->sshErrorOutput);
if ($process->exitCode() !== 0) {
$this->connectionError = 'ssh exit '.$process->exitCode().': '.Str::limit(trim($process->errorOutput()), 300);
} else {
diff --git a/app/Jobs/ServerManagerJob.php b/app/Jobs/ServerManagerJob.php
index 0d93b84d6b..3bdbb4c3a7 100644
--- a/app/Jobs/ServerManagerJob.php
+++ b/app/Jobs/ServerManagerJob.php
@@ -4,6 +4,7 @@ namespace App\Jobs;
use App\Models\InstanceSettings;
use App\Models\Server;
+use App\Models\Team;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
@@ -88,9 +89,40 @@ class ServerManagerJob implements ShouldBeEncrypted, ShouldQueue
// Process server-specific scheduled tasks
$this->processScheduledTasks($servers);
+
+ $this->dispatchImageScanDigests($servers);
}
}
+ /**
+ * One image vulnerability digest per team and day, for teams with a server that scans images (spec 8).
+ */
+ private function dispatchImageScanDigests(Collection $servers): void
+ {
+ if (! $this->settings->is_image_scanning_allowed) {
+ return;
+ }
+
+ $servers->filter(fn (Server $server): bool => (bool) $server->settings?->is_image_scan_enabled && ! $server->isTransferredAway())
+ ->pluck('team_id')
+ ->unique()
+ ->each(function ($teamId): void {
+ try {
+ if (shouldRunCronNow(self::imageScanDigestCron((int) $teamId), $this->instanceTimezone, "image-scan-digest:{$teamId}", $this->executionTime)) {
+ $team = Team::find($teamId);
+ if ($team instanceof Team) {
+ ImageScanDigestJob::dispatch($team);
+ }
+ }
+ } catch (\Exception $e) {
+ Log::channel('scheduled-errors')->error('Failed to dispatch ImageScanDigestJob', [
+ 'team_id' => $teamId,
+ 'error' => get_class($e).': '.$e->getMessage(),
+ ]);
+ }
+ });
+ }
+
/**
* Selects only the IDs, so the paid-subscription filter runs once and the full server rows are loaded
* in batches by primary key. Loading every server in one query kept all of them in worker memory.
@@ -228,6 +260,15 @@ class ServerManagerJob implements ShouldBeEncrypted, ShouldQueue
ServerPatchCheckJob::dispatch($server);
}
+ // Daily image rescan, staggered per server. The cron is read only for servers that can be scanned, so a
+ // server that comes back catches up a missed day.
+ if ($server->settings?->is_image_scan_enabled
+ && ImageRescanJob::canRunOn($server)
+ && shouldRunCronNow(self::imageRescanCron($server), $serverTimezone, "image-rescan:{$server->id}", $this->executionTime)
+ ) {
+ ImageRescanJob::dispatch($server);
+ }
+
// Crash recovery is handled by sentinelOutOfSync → ServerCheckJob → CheckAndStartSentinelJob.
}
@@ -258,6 +299,25 @@ class ServerManagerJob implements ShouldBeEncrypted, ShouldQueue
return sprintf('%d %d * * 0', $slot % 60, 4 + intdiv($slot, 60));
}
+ /**
+ * Daily image rescan at a stable per-server time between 02:00 and 04:59 (spec 4.5).
+ */
+ public static function imageRescanCron(Server $server): string
+ {
+ return sprintf('%d %d * * *', $server->id % 60, 2 + intdiv($server->id % 180, 60));
+ }
+
+ /**
+ * Daily image vulnerability digest at a stable per-team time between 06:00 and 07:59, after the rescans of
+ * 02:00 to 04:59 (spec 8).
+ */
+ public static function imageScanDigestCron(int $teamId): string
+ {
+ $slot = $teamId % 120;
+
+ return sprintf('%d %d * * *', $slot % 60, 6 + intdiv($slot, 60));
+ }
+
/**
* Determine the backoff cycle interval based on how many consecutive times a server has been unreachable.
* Higher counts → less frequent checks (based on 5-min cloud cycle):
diff --git a/app/Jobs/SyncGithubDeploymentJob.php b/app/Jobs/SyncGithubDeploymentJob.php
new file mode 100644
index 0000000000..aa2afa0631
--- /dev/null
+++ b/app/Jobs/SyncGithubDeploymentJob.php
@@ -0,0 +1,255 @@
+onQueue('default');
+ }
+
+ /**
+ * Queue a sync when the deployment belongs to a GitHub App that can write deployments.
+ */
+ public static function dispatchFor(ApplicationDeploymentQueue $deployment): void
+ {
+ if (filled($deployment->parent_deployment_uuid)) {
+ // Deployments to additional servers are part of the main deployment.
+ return;
+ }
+
+ // A soft-deleted application still needs its last status on GitHub.
+ $source = Application::withTrashed()->find($deployment->application_id)?->source;
+ if (! $source instanceof GithubApp || ! $source->canSyncDeployments()) {
+ return;
+ }
+
+ self::dispatch($deployment->id);
+ }
+
+ /**
+ * Mark the GitHub Deployments of a closed pull request as inactive. This also covers
+ * deployments that GitHub does not know yet, so a late sync cannot create one.
+ */
+ public static function deactivatePullRequest(Application $application, int $pullRequestId): void
+ {
+ ApplicationDeploymentQueue::query()
+ ->where('application_id', $application->id)
+ ->where('pull_request_id', $pullRequestId)
+ ->where(fn ($query) => $query->whereNull('github_deployment_state')->orWhere('github_deployment_state', '!=', 'inactive'))
+ ->pluck('id')
+ ->each(fn (int $deploymentQueueId) => self::dispatch($deploymentQueueId, deactivate: true));
+ }
+
+ /**
+ * All syncs of one deployment share this lock, and handle() reads the state while it
+ * holds the lock. Thus a deactivation cannot happen between that read and the requests.
+ *
+ * @return array
+ */
+ public function middleware(): array
+ {
+ return [(new WithoutOverlapping("github-deployment:{$this->deploymentQueueId}"))->releaseAfter(self::LOCK_RELEASE_DELAY)->expireAfter(self::LOCK_EXPIRES_AFTER)];
+ }
+
+ /**
+ * A sync can wait for a lock that is held up to its expiry. Fixed at dispatch.
+ */
+ public function retryUntil(): \DateTimeInterface
+ {
+ return now()->addSeconds(self::LOCK_EXPIRES_AFTER + 60);
+ }
+
+ public function handle(): void
+ {
+ $deployment = ApplicationDeploymentQueue::find($this->deploymentQueueId);
+ $application = $deployment ? Application::withTrashed()->find($deployment->application_id) : null;
+ $source = $application?->source;
+ if (! $source instanceof GithubApp || ! $source->canSyncDeployments()) {
+ return;
+ }
+
+ // Inactive is the end state: a closed pull request must not show as deployed again.
+ if ($deployment->github_deployment_state === 'inactive') {
+ return;
+ }
+
+ $state = $this->deactivate ? 'inactive' : $this->githubState($deployment->status);
+ if ($state === null || $state === $deployment->github_deployment_state) {
+ return;
+ }
+
+ $repository = gitRepositorySlug($application->git_repository);
+ $githubDeploymentId = $deployment->github_deployment_id;
+ if (! $githubDeploymentId) {
+ if ($state === 'inactive') {
+ // Keep the marker, so a late sync does not create a GitHub Deployment.
+ ApplicationDeploymentQueue::whereKey($deployment->id)->update(['github_deployment_state' => 'inactive']);
+
+ return;
+ }
+ // The deployment ended before GitHub knew about it. The deployment job sets
+ // horizon_job_worker together with IN_PROGRESS, so a failure without it never started.
+ if ($state === 'error' || ($state === 'failure' && blank($deployment->horizon_job_worker))) {
+ return;
+ }
+ $githubDeploymentId = $this->createGithubDeployment($deployment, $application, $source, $repository);
+ ApplicationDeploymentQueue::whereKey($deployment->id)->update(['github_deployment_id' => $githubDeploymentId]);
+ }
+
+ githubApi($source, "repos/{$repository}/deployments/{$githubDeploymentId}/statuses", 'post', array_filter([
+ 'state' => $state,
+ 'description' => $this->description($state),
+ 'log_url' => $this->logUrl($deployment, $application),
+ 'environment_url' => $state === 'success' ? $this->environmentUrl($deployment, $application) : null,
+ ]), timeout: self::HTTP_TIMEOUT_SECONDS);
+
+ ApplicationDeploymentQueue::whereKey($deployment->id)->update(['github_deployment_state' => $state]);
+ }
+
+ public function failed(Throwable $exception): void
+ {
+ Log::warning('Failed to sync the GitHub deployment.', [
+ 'deployment_queue_id' => $this->deploymentQueueId,
+ 'error' => $exception->getMessage(),
+ ]);
+ }
+
+ private function githubState(string $status): ?string
+ {
+ return match ($status) {
+ ApplicationDeploymentStatus::IN_PROGRESS->value => 'in_progress',
+ ApplicationDeploymentStatus::FINISHED->value => 'success',
+ ApplicationDeploymentStatus::FAILED->value => 'failure',
+ ApplicationDeploymentStatus::CANCELLED_BY_USER->value => 'error',
+ default => null,
+ };
+ }
+
+ private function createGithubDeployment(ApplicationDeploymentQueue $deployment, Application $application, GithubApp $source, string $repository): int
+ {
+ $isPreview = $deployment->pull_request_id !== 0;
+ $environment = $isPreview ? "{$application->name} (preview)" : $application->name;
+
+ $response = githubApi($source, "repos/{$repository}/deployments", 'post', [
+ 'ref' => $this->ref($deployment, $application, $source, $repository),
+ 'environment' => Str::limit($environment, 255, ''),
+ 'description' => Str::limit($isPreview ? "Coolify preview for PR #{$deployment->pull_request_id}" : 'Coolify deployment', 140, ''),
+ // GitHub would merge the default branch into the ref, or wait for all commit checks.
+ 'auto_merge' => false,
+ 'required_contexts' => [],
+ 'transient_environment' => $isPreview,
+ 'production_environment' => ! $isPreview,
+ ], timeout: self::HTTP_TIMEOUT_SECONDS);
+
+ return (int) $response['data']->get('id');
+ }
+
+ /**
+ * GitHub links a deployment to a pull request only when its ref is the head branch. A fork
+ * branch is not in the base repository, so a fork preview uses the head commit. GitHub
+ * resolves a branch to its current commit, so the branch is used only while it still
+ * points to the deployed commit.
+ */
+ private function ref(ApplicationDeploymentQueue $deployment, Application $application, GithubApp $source, string $repository): string
+ {
+ $commit = filled($deployment->commit) && $deployment->commit !== 'HEAD' ? $deployment->commit : null;
+
+ if ($deployment->pull_request_id !== 0) {
+ $pullRequest = githubApi($source, "repos/{$repository}/pulls/{$deployment->pull_request_id}", timeout: self::HTTP_TIMEOUT_SECONDS)['data'];
+ $headRepository = (string) data_get($pullRequest, 'head.repo.full_name', '');
+ $baseRepository = (string) data_get($pullRequest, 'base.repo.full_name', $repository);
+ $headBranch = data_get($pullRequest, 'head.ref');
+ $headCommit = (string) data_get($pullRequest, 'head.sha');
+ $isDeployedCommit = $commit === null || strcasecmp($commit, $headCommit) === 0;
+ if (filled($headBranch) && strcasecmp($headRepository, $baseRepository) === 0 && $isDeployedCommit) {
+ return $headBranch;
+ }
+
+ return $commit ?? $headCommit;
+ }
+
+ // The branch can move past the deployed commit before the GitHub deployment is created.
+ return $commit ?? $application->git_branch;
+ }
+
+ private function description(string $state): string
+ {
+ return match ($state) {
+ 'in_progress' => 'Deploying with Coolify.',
+ 'success' => 'Deployed with Coolify.',
+ 'failure' => 'The Coolify deployment failed.',
+ 'error' => 'The Coolify deployment was cancelled.',
+ default => 'The pull request was closed.',
+ };
+ }
+
+ private function logUrl(ApplicationDeploymentQueue $deployment, Application $application): string
+ {
+ return base_url()."/project/{$application->environment->project->uuid}/environment/{$application->environment->uuid}/application/{$application->uuid}/deployment/{$deployment->deployment_uuid}";
+ }
+
+ private function environmentUrl(ApplicationDeploymentQueue $deployment, Application $application): ?string
+ {
+ if ($deployment->pull_request_id !== 0) {
+ $preview = $application->previews()->where('pull_request_id', $deployment->pull_request_id)->first();
+ $domains = $application->build_pack === 'dockercompose'
+ ? collect(json_decode((string) $preview?->docker_compose_domains, true) ?: [])->pluck('domain')->filter()->first()
+ : $preview?->fqdn;
+ } else {
+ $domains = $application->build_pack === 'dockercompose'
+ ? collect(json_decode((string) $application->docker_compose_domains, true) ?: [])->pluck('domain')->filter()->first()
+ : $application->fqdn;
+ }
+
+ $url = trim((string) str($domains)->explode(',')->first());
+
+ return str_starts_with($url, 'http://') || str_starts_with($url, 'https://') ? $url : null;
+ }
+}
diff --git a/app/Jobs/UpdateCoolifyJob.php b/app/Jobs/UpdateCoolifyJob.php
index e2310f0bee..c8976ba08a 100644
--- a/app/Jobs/UpdateCoolifyJob.php
+++ b/app/Jobs/UpdateCoolifyJob.php
@@ -3,7 +3,10 @@
namespace App\Jobs;
use App\Actions\Server\UpdateCoolify;
+use App\Exceptions\UpgradeAlreadyRunningException;
use App\Models\Server;
+use App\Services\AutoUpdateNotifier;
+use App\Services\CoolifyUpgradeStatus;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
@@ -45,9 +48,17 @@ class UpdateCoolifyJob implements ShouldBeEncrypted, ShouldQueue
UpdateCoolify::run(false); // false means it's not a manual update
Log::info('Coolify upgrade script started. Result: /data/coolify/source/.upgrade-status, log: /data/coolify/source/upgrade-*.log.');
+ } catch (UpgradeAlreadyRunningException $e) {
+ // The running upgrade keeps its saved attempt, so its result is still reported.
+ Log::warning('Automatic update skipped: '.$e->getMessage());
} catch (\Throwable $e) {
Log::error('UpdateCoolifyJob failed: '.$e->getMessage());
- // Consider implementing a notification to administrators
+ try {
+ CoolifyUpgradeStatus::writeError(Server::findOrFail(0), $e->getMessage());
+ } catch (\Throwable $statusException) {
+ report($statusException);
+ }
+ AutoUpdateNotifier::failed($e->getMessage());
}
}
}
diff --git a/app/Jobs/VolumeBackupJob.php b/app/Jobs/VolumeBackupJob.php
index d27632982e..9135ec4ec6 100644
--- a/app/Jobs/VolumeBackupJob.php
+++ b/app/Jobs/VolumeBackupJob.php
@@ -16,6 +16,7 @@ use App\Models\VolumeBackupS3Replica;
use App\Notifications\ScheduledJobRecovered;
use App\Notifications\VolumeBackup\BackupFailed;
use App\Notifications\VolumeBackup\BackupSuccess;
+use App\Services\RcloneTransfer;
use App\Services\ScheduledJobDeliveryService;
use App\Services\Security\SensitiveDataRedactor;
use App\Support\BackupCompression;
@@ -90,9 +91,10 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue
$filename = str($this->backup->targetType())->lower().'-'.str($this->backup->targetName())->slug().'-'.Carbon::now()->timestamp.'.tar.gz';
$backupLocation = $backupDirectory.'/'.$filename;
$this->execution->update(['filename' => $backupLocation]);
- // Streaming writes no local archive, so it can upload to one destination only. A curl upload needs the size
- // of the file before it starts, so it cannot stream.
- $streamToS3 = $this->backup->disable_local_backup && $destinations->count() === 1 && ! $destinations->first()->usesCurlTransfer();
+ // Streaming writes no local archive, so it can upload to one destination only. Only S3 storages stream, and a
+ // curl upload needs the size of the file before it starts, so it cannot stream.
+ $streamToS3 = $this->backup->disable_local_backup && $destinations->count() === 1
+ && $destinations->first()->isS3() && ! $destinations->first()->usesCurlTransfer();
try {
$source = $this->backup->sourcePath();
@@ -174,7 +176,7 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue
$this->execution->refreshS3Summary();
$this->execution->update(['s3_cleanup_pending' => false]);
} elseif ($this->backup->save_s3 && $destinations->isEmpty()) {
- $warning = 'S3 upload failed: The selected S3 storage no longer exists.';
+ $warning = 'Storage upload failed: The selected backup storage no longer exists.';
} elseif ($this->backup->save_s3) {
$s3CleanupPending = true;
$this->execution->update(['s3_cleanup_pending' => true]);
@@ -184,14 +186,14 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue
if ($s3Uploaded) {
$s3CleanupPending = false;
} else {
- $warning = 'S3 upload failed: '.implode(' ', $uploadErrors);
+ $warning = 'Storage upload failed: '.implode(' ', $uploadErrors);
try {
VolumeBackupRecoveryJob::cleanupS3Upload($this->execution);
$s3CleanupPending = false;
} catch (Throwable $cleanupException) {
VolumeBackupRecoveryJob::dispatch($this->execution);
- $warning .= ' Partial S3 upload cleanup failed: '.$cleanupException->getMessage();
+ $warning .= ' Partial storage upload cleanup failed: '.$cleanupException->getMessage();
}
}
@@ -200,7 +202,7 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue
deleteBackupsLocally($backupLocation, $server, throwError: true);
$localStorageDeleted = true;
} catch (Throwable $exception) {
- $warning = 'S3 upload succeeded, but the local archive could not be deleted: '.$exception->getMessage();
+ $warning = 'Storage upload succeeded, but the local archive could not be deleted: '.$exception->getMessage();
}
}
}
@@ -447,7 +449,7 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue
/** @var VolumeBackupS3Replica $replica */
try {
if (! $replica->s3) {
- throw new \RuntimeException('The selected S3 storage no longer exists.');
+ throw new \RuntimeException('The selected backup storage no longer exists.');
}
$this->uploadToS3($replica->s3, $backupLocation, $size, $backupDirectory, $server);
@@ -468,6 +470,17 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue
{
$s3->testConnection(shouldSave: true);
$containerName = 'volume-upload-'.$this->execution->uuid;
+ if (! $s3->isS3()) {
+ $transfer = new RcloneTransfer($s3, $server, $containerName);
+ try {
+ $transfer->start([$backupLocation.':'.$backupLocation.':ro'], timeout: $this->timeout);
+ $transfer->upload($backupLocation, $backupLocation, $this->timeout);
+ } finally {
+ instant_remote_process([$transfer->removeCommand()], $server, throwError: false, disableMultiplexing: true);
+ }
+
+ return;
+ }
$image = coolifyHelperImage().':'.getHelperVersion();
try {
diff --git a/app/Livewire/Boarding/Index.php b/app/Livewire/Boarding/Index.php
index 885c3c22e2..103d8bbe79 100644
--- a/app/Livewire/Boarding/Index.php
+++ b/app/Livewire/Boarding/Index.php
@@ -7,6 +7,7 @@ use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
use App\Models\Team;
+use App\Rules\ValidServerIp;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
@@ -220,7 +221,7 @@ class Index extends Component
{
return [
'remoteServerName' => 'required|string',
- 'remoteServerHost' => 'required|string',
+ 'remoteServerHost' => ['required', 'string', new ValidServerIp],
'remoteServerPort' => 'required|integer|min:1|max:65535',
'remoteServerUser' => ValidationPatterns::serverUsernameRules(),
];
@@ -290,6 +291,7 @@ class Index extends Component
]);
$this->createdPrivateKey = $privateKey;
+ $this->privateKey = null;
$this->currentState = 'create-server';
} catch (\Exception $e) {
$this->addError('privateKey', 'Failed to save private key: '.$e->getMessage());
diff --git a/app/Livewire/Concerns/ManagesSmtpServers.php b/app/Livewire/Concerns/ManagesSmtpServers.php
new file mode 100644
index 0000000000..bd886a78b7
--- /dev/null
+++ b/app/Livewire/Concerns/ManagesSmtpServers.php
@@ -0,0 +1,189 @@
+
+ */
+ public function smtpServerOptions(): array
+ {
+ $options = [['value' => 'active', 'label' => $this->settings->smtpServerName()]];
+ foreach ($this->settings->savedSmtpServers() as $server) {
+ $options[] = ['value' => $server['id'], 'label' => $server['name']];
+ }
+
+ return $options;
+ }
+
+ /**
+ * Start a new server, with the fields of the selected template or empty fields. The server in use stays saved until the new one is saved.
+ */
+ public function addSmtpServer(): void
+ {
+ $this->authorize('update', $this->settings);
+ $this->validate([
+ 'newSmtpServerName' => 'required|string|max:255',
+ 'newSmtpServerTemplate' => ['nullable', Rule::in(array_keys(SmtpPresets::PRESETS))],
+ ], [
+ 'newSmtpServerName.required' => 'Server name is required.',
+ ]);
+ $preset = SmtpPresets::PRESETS[$this->newSmtpServerTemplate] ?? null;
+
+ $this->isNewSmtpServer = true;
+ $this->smtpServerName = $this->newSmtpServerName;
+ $this->newSmtpServerName = null;
+ $this->newSmtpServerTemplate = null;
+ $this->smtpHost = $preset['host'] ?? null;
+ $this->smtpPort = isset($preset['port']) ? (string) $preset['port'] : null;
+ $this->smtpEncryption = $preset['encryption'] ?? 'starttls';
+ $this->smtpUsername = $preset['username'] ?? null;
+ $this->smtpPassword = null;
+ $this->smtpTimeout = null;
+ $this->smtpEhloDomain = null;
+ }
+
+ public function cancelNewSmtpServer(): void
+ {
+ $this->isNewSmtpServer = false;
+ $this->resetErrorBag();
+ $this->loadEmailSettings();
+ }
+
+ public function switchSmtpServer(string $id)
+ {
+ try {
+ $this->authorize('update', $this->settings);
+ if ($id === 'active') {
+ return null;
+ }
+ if (! $this->settings->switchToSmtpServer($id)) {
+ throw new Exception('This SMTP server does not exist anymore. Reload the page.');
+ }
+
+ $this->saveEmailSettings();
+ $this->isNewSmtpServer = false;
+ $this->resetErrorBag();
+ $this->loadEmailSettings();
+ $this->dispatch('success', 'Switched to SMTP server '.$this->settings->smtpServerName().'.');
+ } catch (\Throwable $e) {
+ $this->selectedSmtpServer = 'active';
+
+ return handleError($e, $this);
+ }
+
+ return null;
+ }
+
+ /**
+ * Rename the server in use. Only the name is saved; other unsaved changes on the page stay unsaved.
+ */
+ public function renameSmtpServer()
+ {
+ try {
+ $this->authorize('update', $this->settings);
+ $this->validate(['renamedSmtpServerName' => 'required|string|max:255'], [
+ 'renamedSmtpServerName.required' => 'Server name is required.',
+ ]);
+
+ $this->smtpServerName = $this->settings->smtp_server_name = $this->renamedSmtpServerName;
+ $this->renamedSmtpServerName = null;
+ $this->saveEmailSettings();
+ $this->dispatch('success', 'SMTP server renamed.');
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
+ }
+
+ return null;
+ }
+
+ /**
+ * Delete the server in use and use the next saved server.
+ */
+ public function deleteSmtpServer()
+ {
+ try {
+ $this->authorize('update', $this->settings);
+ if ($this->isNewSmtpServer) {
+ $this->cancelNewSmtpServer();
+
+ return null;
+ }
+
+ $name = $this->settings->smtpServerName();
+ if (! $this->settings->deleteActiveSmtpServer()) {
+ throw new Exception('This is the only SMTP server. To stop using SMTP, select another provider.');
+ }
+
+ $this->saveEmailSettings();
+ $this->resetErrorBag();
+ $this->loadEmailSettings();
+ $this->dispatch('success', 'SMTP server '.$name.' deleted. Now using '.$this->settings->smtpServerName().'.');
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
+ }
+
+ return null;
+ }
+
+ /**
+ * Copy the SMTP fields, including the sender, to the settings model. A new server first keeps the
+ * server in use in the saved list, so set no smtp_* column before this call.
+ */
+ protected function fillSmtpServerSettings(): void
+ {
+ if ($this->isNewSmtpServer) {
+ $this->settings->keepActiveSmtpServer();
+ $this->isNewSmtpServer = false;
+ }
+
+ $this->settings->smtp_server_name = $this->smtpServerName;
+ $this->settings->smtp_from_address = $this->smtpFromAddress;
+ $this->settings->smtp_from_name = $this->smtpFromName;
+ $this->settings->smtp_host = $this->smtpHost;
+ $this->settings->smtp_port = $this->smtpPort;
+ $this->settings->smtp_encryption = $this->smtpEncryption;
+ $this->settings->smtp_username = $this->smtpUsername;
+ $this->settings->smtp_password = $this->smtpPassword;
+ $this->settings->smtp_timeout = $this->smtpTimeout;
+ $this->settings->smtp_ehlo_domain = $this->smtpEhloDomain;
+ }
+}
diff --git a/app/Livewire/Dashboard.php b/app/Livewire/Dashboard.php
index c542342b6c..1c86a3a79a 100644
--- a/app/Livewire/Dashboard.php
+++ b/app/Livewire/Dashboard.php
@@ -8,6 +8,7 @@ use App\Models\Project;
use App\Models\Server;
use App\Models\TeamInvitation;
use App\Services\Access\AccessResolver;
+use App\Services\ImageScan\TeamVulnerabilities;
use Illuminate\Support\Collection;
use Livewire\Attributes\Locked;
use Livewire\Component;
@@ -41,6 +42,14 @@ class Dashboard extends Component
#[Locked]
public bool $showsTeamSections = true;
+ /**
+ * UUIDs of the resources the user can see that need a security update now (update_now).
+ *
+ * @var list
+ */
+ #[Locked]
+ public array $securityUpdateNow = [];
+
public function mount()
{
$this->showsTeamSections = app(AccessResolver::class)->coversWholeTeam(auth()->user(), currentTeam()->id);
@@ -51,6 +60,7 @@ class Dashboard extends Component
return;
}
+ $this->securityUpdateNow = TeamVulnerabilities::updateNowResources(currentTeam()->id);
$this->privateKeys = $this->showsTeamSections ? PrivateKey::ownedByCurrentTeamCached() : collect();
$this->servers = $this->showsTeamSections ? Server::ownedByCurrentTeamCached() : collect();
$this->projects = Project::ownedByCurrentTeam()
@@ -174,6 +184,16 @@ class Dashboard extends Component
return in_array($column, $columns, true) && in_array($direction, ['asc', 'desc'], true);
}
+ /**
+ * "1 resource needs a security update now." / "3 resources need a security update now."
+ */
+ public static function securityCalloutTitle(int $count): string
+ {
+ return $count === 1
+ ? '1 resource needs a security update now.'
+ : "{$count} resources need a security update now.";
+ }
+
/**
* Unexpired invitations for the user's email to teams the user has not joined yet.
*
diff --git a/app/Livewire/Dashboard/ActiveDeployments.php b/app/Livewire/Dashboard/ActiveDeployments.php
index 1af9de56a8..f1ba0d1ba2 100644
--- a/app/Livewire/Dashboard/ActiveDeployments.php
+++ b/app/Livewire/Dashboard/ActiveDeployments.php
@@ -25,6 +25,7 @@ class ActiveDeployments extends Component
'server_name',
'server_id',
'status',
+ 'scan_gate_result',
'created_at',
'finished_at',
];
diff --git a/app/Livewire/GlobalSearch.php b/app/Livewire/GlobalSearch.php
index c9a0aaed7f..193eff604f 100644
--- a/app/Livewire/GlobalSearch.php
+++ b/app/Livewire/GlobalSearch.php
@@ -819,6 +819,20 @@ class GlobalSearch extends Component
'link' => route('team.audit-log'),
'search_text' => 'team audit log activity history events',
] : null,
+ instanceSettings()->is_image_scanning_allowed ? [
+ 'name' => 'Security',
+ 'type' => 'navigation',
+ 'description' => 'What each resource of your team needs, from its last image scan',
+ 'link' => route('vulnerabilities'),
+ 'search_text' => 'vulnerabilities cve ghsa image scanning fixable packages security',
+ ] : null,
+ instanceSettings()->is_image_scanning_allowed ? [
+ 'name' => 'Team Image Scanning',
+ 'type' => 'navigation',
+ 'description' => 'Scan rules and ignored vulnerabilities of the team',
+ 'link' => route('team.image-scanning'),
+ 'search_text' => 'team image scanning vulnerabilities cve scan rules alert block deploys ignored security',
+ ] : null,
isInstanceAdmin() ? [
'name' => 'Team Admin View',
'type' => 'navigation',
@@ -929,6 +943,13 @@ class GlobalSearch extends Component
'link' => route('settings.updates'),
'search_text' => 'settings updates auto update upgrade version frequency',
],
+ [
+ 'name' => 'Image Scanning Settings',
+ 'type' => 'navigation',
+ 'description' => 'Allow image scanning, the vulnerability database, and default scan rules',
+ 'link' => route('settings.image-scanning'),
+ 'search_text' => 'settings image scanning vulnerabilities cve scan rules grype database mirror alert block deploys security',
+ ],
[
'name' => 'Instance Backup',
'type' => 'navigation',
@@ -962,6 +983,7 @@ class GlobalSearch extends Component
route('profile'),
route('profile.appearance'),
route('security.api-tokens'),
+ route('vulnerabilities'),
];
$navigation = $navigation->whereIn('link', $projectPages)->values();
}
@@ -1177,8 +1199,8 @@ class GlobalSearch extends Component
// Storage - can be created if user is admin or owner
if ($user->isAdmin() || $user->isOwner()) {
$items->push([
- 'name' => 'S3 Storage',
- 'description' => 'Add S3 storage for backups and file uploads',
+ 'name' => 'Storage',
+ 'description' => 'Add S3, SFTP, FTPS, or WebDAV storage for backups',
'quickcommand' => '(type: new storage)',
'type' => 'storage',
'category' => 'Quick Actions',
diff --git a/app/Livewire/ImageScan/FleetVulnerabilities.php b/app/Livewire/ImageScan/FleetVulnerabilities.php
new file mode 100644
index 0000000000..1a75c6483f
--- /dev/null
+++ b/app/Livewire/ImageScan/FleetVulnerabilities.php
@@ -0,0 +1,290 @@
+ Server UUIDs.
+ */
+ public $servers = [];
+
+ /** @var list Project UUIDs. */
+ public $projects = [];
+
+ /** @var list SecurityStatusType values. */
+ #[Url(except: [])]
+ public $statuses = [];
+
+ /** Shows the resources that were not scanned when no status filter is set. */
+ #[Url(except: false)]
+ public bool $showNotChecked = false;
+
+ public int $perPage = 25;
+
+ /** At most one reload per this many seconds for scan events of other resources. */
+ public const RELOAD_INTERVAL = 10;
+
+ /** When a scan event last reloaded the page (Unix time). */
+ public int $reloadedAt = 0;
+
+ /** A scan result came in during the interval; the page reloads once the interval ends. */
+ public bool $reloadPending = false;
+
+ private ?TeamVulnerabilities $fleet = null;
+
+ public function getListeners(): array
+ {
+ return $this->teamChannelListeners([
+ 'ImageScanUpdated' => 'scanUpdated',
+ ]);
+ }
+
+ /**
+ * A scan finished somewhere in the team: render again with the new results, at most once per RELOAD_INTERVAL
+ * seconds, because a scan of a whole server sends one event per image. Progress updates of a running scan change no
+ * result, so they do not reload the page.
+ *
+ * @param array $event
+ */
+ public function scanUpdated(array $event = []): void
+ {
+ if (data_get($event, 'final', true) === false) {
+ $this->skipRender();
+
+ return;
+ }
+
+ $now = now()->getTimestamp();
+ if ($now - $this->reloadedAt < self::RELOAD_INTERVAL) {
+ $this->reloadPending = true;
+ $this->skipRender();
+
+ return;
+ }
+
+ $this->reloadedAt = $now;
+ $this->reloadPending = false;
+ }
+
+ /**
+ * Render the scan results that came in during the last interval.
+ */
+ public function reloadPendingResults(): void
+ {
+ if (! $this->reloadPending) {
+ $this->skipRender();
+
+ return;
+ }
+
+ $this->reloadedAt = now()->getTimestamp();
+ $this->reloadPending = false;
+ }
+
+ public function toggleNotChecked(): void
+ {
+ $this->showNotChecked = ! $this->showNotChecked;
+ $this->resetPage();
+ }
+
+ public function updated(string $property): void
+ {
+ $this->normalizeFilters();
+
+ if (in_array($property, ['search', 'perPage'], true)) {
+ $this->resetPage();
+ }
+ }
+
+ /**
+ * Keeps only string values, and only allowed statuses. Servers and projects are checked against the options in
+ * render().
+ */
+ private function normalizeFilters(): void
+ {
+ $strings = fn (mixed $values): array => array_values(array_unique(array_filter(is_array($values) ? $values : [], 'is_string')));
+
+ $this->servers = $strings($this->servers);
+ $this->projects = $strings($this->projects);
+ $this->statuses = array_values(array_intersect($strings($this->statuses), TeamVulnerabilities::STATUS_ORDER));
+ }
+
+ public function toggleServer(string $uuid): void
+ {
+ $this->servers = $this->toggle($this->servers, $uuid);
+ }
+
+ public function toggleProject(string $uuid): void
+ {
+ $this->projects = $this->toggle($this->projects, $uuid);
+ }
+
+ public function toggleStatus(string $status): void
+ {
+ if (in_array($status, TeamVulnerabilities::STATUS_ORDER, true)) {
+ $this->statuses = $this->toggle($this->statuses, $status);
+ }
+ }
+
+ public function resetFilters(): void
+ {
+ $this->servers = [];
+ $this->projects = [];
+ $this->statuses = [];
+ $this->resetPage();
+ }
+
+ /**
+ * @param list $values
+ * @return list
+ */
+ private function toggle(array $values, string $value): array
+ {
+ $this->resetPage();
+
+ return in_array($value, $values, true)
+ ? array_values(array_diff($values, [$value]))
+ : [...$values, $value];
+ }
+
+ public function render(): View
+ {
+ $this->normalizeFilters();
+ $user = auth()->user();
+ $team = currentTeam();
+ $allowed = (bool) instanceSettings()->is_image_scanning_allowed;
+
+ if (! $allowed) {
+ return view('livewire.image-scan.fleet-vulnerabilities', ['allowed' => false]);
+ }
+
+ $fleet = $this->fleet($team->id);
+ $entries = $fleet->entries;
+
+ $serverOptions = $entries->pluck('server')->filter()->unique('id')
+ ->mapWithKeys(fn ($server) => [$server->uuid => $server->name])->sort(SORT_NATURAL | SORT_FLAG_CASE)->all();
+ $projectOptions = $entries->pluck('project')->filter()->unique('id')
+ ->mapWithKeys(fn ($project) => [$project->uuid => $project->name])->sort(SORT_NATURAL | SORT_FLAG_CASE)->all();
+ $this->servers = array_values(array_intersect($this->servers, array_keys($serverOptions)));
+ $this->projects = array_values(array_intersect($this->projects, array_keys($projectOptions)));
+
+ $inScope = $entries->filter(fn (array $entry): bool => ($this->servers === [] || in_array($entry['server']?->uuid, $this->servers, true))
+ && ($this->projects === [] || in_array($entry['project']?->uuid, $this->projects, true)))->values();
+ $statusCounts = $inScope->countBy(fn (array $entry): string => $entry['security']->status->value);
+ $statusOptions = collect(TeamVulnerabilities::STATUS_ORDER)
+ ->mapWithKeys(fn (string $status): array => [$status => TeamVulnerabilities::statusLabel(SecurityStatusType::from($status)).' ('.number_format($statusCounts[$status] ?? 0).')'])
+ ->all();
+ $filtered = $inScope->filter(fn (array $entry): bool => $this->statuses === []
+ ? $this->showNotChecked || $entry['security']->status !== SecurityStatusType::NotChecked
+ : in_array($entry['security']->status->value, $this->statuses, true))->values();
+
+ $notScanned = $inScope->filter(fn (array $entry): bool => $entry['security']->status === SecurityStatusType::NotChecked);
+ $searching = trim($this->search) !== '';
+
+ $rows = $searching
+ ? $fleet->search($filtered->filter(fn (array $entry): bool => $entry['scanned'])->values(), $this->search)
+ : self::byStatus($filtered);
+ $notScannedServers = $notScanned->pluck('server')->filter()->unique('id')->count();
+
+ return view('livewire.image-scan.fleet-vulnerabilities', [
+ 'allowed' => true,
+ 'items' => $this->paginate($rows),
+ 'searching' => $searching,
+ 'notScannedLine' => $notScanned->isEmpty() ? null : self::notScannedSentence($notScanned->count(), $notScannedServers),
+ // The toggle only matters while no status filter is set.
+ 'notCheckedToggle' => $notScanned->isEmpty() || $this->statuses !== [] || $searching ? null : ($this->showNotChecked ? 'Hide them' : 'Show them'),
+ 'hidesNotChecked' => $this->statuses === [] && ! $this->showNotChecked && $notScanned->isNotEmpty(),
+ 'searchCapped' => $searching && $fleet->searchWasCapped(),
+ 'serversNotScanning' => $user->isAdminOfTeam($team->id) ? TeamVulnerabilities::serversNotScanning($team->id) : 0,
+ 'serverOptions' => $serverOptions,
+ 'projectOptions' => $projectOptions,
+ 'statusOptions' => $statusOptions,
+ 'hasFilters' => $this->servers !== [] || $this->projects !== [] || $this->statuses !== [],
+ 'hasResources' => $entries->isNotEmpty(),
+ 'stateLabels' => TeamVulnerabilities::STATE_LABELS,
+ ]);
+ }
+
+ /**
+ * "3 resources on 2 servers were not scanned, so Coolify cannot tell." Resources without a server leave out the
+ * servers part when no server is left.
+ */
+ public static function notScannedSentence(int $resources, int $servers): string
+ {
+ $subject = $resources.' '.str('resource')->plural($resources);
+ if ($servers > 0) {
+ $subject .= ' on '.$servers.' '.str('server')->plural($servers);
+ }
+
+ return $subject.($resources === 1 ? ' was' : ' were').' not scanned, so Coolify cannot tell.';
+ }
+
+ /**
+ * The team's resources and their stored statuses, loaded once per request.
+ */
+ private function fleet(int $teamId): TeamVulnerabilities
+ {
+ if ($this->fleet?->teamId !== $teamId) {
+ $this->fleet = TeamVulnerabilities::load($teamId);
+ }
+
+ return $this->fleet;
+ }
+
+ /**
+ * update_now, update_available, no_fix, check_needed, ok, not_checked; then the resource name.
+ *
+ * @param Collection> $entries
+ * @return Collection>
+ */
+ public static function byStatus(Collection $entries): Collection
+ {
+ $rank = array_flip(TeamVulnerabilities::STATUS_ORDER);
+
+ return $entries
+ ->sort(fn (array $a, array $b): int => ($rank[$a['security']->status->value] <=> $rank[$b['security']->status->value])
+ ?: strnatcasecmp((string) $a['resource']->name, (string) $b['resource']->name))
+ ->values();
+ }
+
+ /**
+ * @param Collection> $rows
+ */
+ private function paginate(Collection $rows): LengthAwarePaginator
+ {
+ $this->perPage = max(1, min(100, $this->perPage));
+ $lastPage = max(1, (int) ceil($rows->count() / $this->perPage));
+ $page = max(1, min((int) $this->getPage(), $lastPage));
+ if ($page !== (int) $this->getPage()) {
+ $this->setPage($page);
+ }
+
+ return new LengthAwarePaginator($rows->forPage($page, $this->perPage)->values(), $rows->count(), $this->perPage, $page);
+ }
+}
diff --git a/app/Livewire/ImageScan/InstanceImageScanning.php b/app/Livewire/ImageScan/InstanceImageScanning.php
new file mode 100644
index 0000000000..164ed9edd8
--- /dev/null
+++ b/app/Livewire/ImageScan/InstanceImageScanning.php
@@ -0,0 +1,73 @@
+route('dashboard');
+ }
+
+ $settings = InstanceSettings::get();
+ $this->isImageScanningAllowed = $settings->is_image_scanning_allowed ? '1' : '0';
+ $this->imageScanDbUrl = $settings->image_scan_db_url;
+ }
+
+ public function updatedIsImageScanningAllowed(): void
+ {
+ abort_unless(isInstanceAdmin(), 403);
+ $this->validate(['isImageScanningAllowed' => ['required', 'in:1,0']]);
+
+ $this->saveSettings(['is_image_scanning_allowed' => $this->isImageScanningAllowed === '1']);
+ }
+
+ public function saveDatabaseUrl(): void
+ {
+ abort_unless(isInstanceAdmin(), 403);
+ $this->imageScanDbUrl = filled($this->imageScanDbUrl) ? trim($this->imageScanDbUrl) : null;
+ $this->validate(
+ ['imageScanDbUrl' => ['nullable', 'url:https', 'max:2048', 'regex:#\Ahttps://[A-Za-z0-9.-]+(:[0-9]{1,5})?(/[A-Za-z0-9._~%/+=-]*)?\z#']],
+ ['imageScanDbUrl.regex' => 'Use an https URL without a query, a fragment, or credentials.'],
+ );
+
+ $this->saveSettings(['image_scan_db_url' => $this->imageScanDbUrl]);
+ }
+
+ /**
+ * @param array $values
+ */
+ private function saveSettings(array $values): void
+ {
+ $settings = InstanceSettings::get();
+ $settings->fill($values);
+ $changedFields = auditChangedFields($settings);
+ $settings->save();
+ if ($changedFields !== []) {
+ auditLog('ui.instance.settings.updated', [
+ 'team_id' => null,
+ 'resource' => 'instance',
+ 'section' => 'image_scanning',
+ 'changed_fields' => $changedFields,
+ ]);
+ }
+ $this->dispatch('success', 'Saved.');
+ }
+
+ public function render(): View
+ {
+ return view('livewire.image-scan.instance-image-scanning');
+ }
+}
diff --git a/app/Livewire/ImageScan/Packages.php b/app/Livewire/ImageScan/Packages.php
new file mode 100644
index 0000000000..efafe936fd
--- /dev/null
+++ b/app/Livewire/ImageScan/Packages.php
@@ -0,0 +1,211 @@
+ */
+ public array $types = [];
+
+ /** @var list Image refs, for pages with more than one image. */
+ public array $images = [];
+
+ public int $perPage = 10;
+
+ private ?Model $loadedResource = null;
+
+ public function getListeners(): array
+ {
+ return $this->teamChannelListeners([
+ 'ImageScanUpdated' => 'scanUpdated',
+ ]);
+ }
+
+ public function mount(string $resourceType, string $resourceUuid): void
+ {
+ abort_unless(in_array($resourceType, ResourceScans::RESOURCE_TYPES, true), 404);
+ $this->resourceType = $resourceType;
+ $this->resourceUuid = $resourceUuid;
+ $this->resource();
+ }
+
+ /**
+ * @param array $event
+ */
+ public function scanUpdated(array $event = []): void
+ {
+ $uuids = ResourceScans::owners($this->resource())
+ ->map(fn (Model $owner): string => (string) $owner->getAttribute('uuid'))
+ ->push($this->resourceUuid);
+
+ if (! $uuids->contains((string) data_get($event, 'resourceUuid'))) {
+ $this->skipRender();
+ }
+ }
+
+ public function updated(string $property): void
+ {
+ if (in_array($property, ['search', 'perPage'], true)) {
+ $this->resetPage(self::PAGE_NAME);
+ }
+ }
+
+ public function toggleType(string $type): void
+ {
+ $this->types = in_array($type, $this->types, true)
+ ? array_values(array_diff($this->types, [$type]))
+ : [...$this->types, $type];
+ $this->resetPage(self::PAGE_NAME);
+ }
+
+ public function toggleImage(string $image): void
+ {
+ $this->images = in_array($image, $this->images, true)
+ ? array_values(array_diff($this->images, [$image]))
+ : [...$this->images, $image];
+ $this->resetPage(self::PAGE_NAME);
+ }
+
+ /**
+ * The packages of one image only, or of all images for an empty image.
+ */
+ public function showImage(string $image = ''): void
+ {
+ $this->images = $image === '' ? [] : [$image];
+ $this->resetPage(self::PAGE_NAME);
+ }
+
+ public function resetFilters(): void
+ {
+ $this->types = [];
+ $this->resetPage(self::PAGE_NAME);
+ }
+
+ private function resource(): Model
+ {
+ if ($this->loadedResource !== null) {
+ return $this->loadedResource;
+ }
+
+ $user = auth()->user();
+ abort_unless($user !== null, 403);
+
+ $resource = ResourceScans::find($this->resourceType, $this->resourceUuid, $user->teams->pluck('id')->all());
+ abort_unless($resource instanceof Model, 404);
+ $this->authorize('view', $resource);
+
+ return $this->loadedResource = $resource;
+ }
+
+ /**
+ * @return array{routeName: string, parameters: array}
+ */
+ private function downloadRoute(Model $resource): array
+ {
+ $environment = $resource->getAttribute('environment');
+
+ return [
+ 'routeName' => "project.{$this->resourceType}.vulnerabilities.sbom",
+ 'parameters' => [
+ 'project_uuid' => (string) data_get($environment, 'project.uuid'),
+ 'environment_uuid' => (string) data_get($environment, 'uuid'),
+ "{$this->resourceType}_uuid" => $this->resourceUuid,
+ ],
+ ];
+ }
+
+ public function render(): View
+ {
+ $resource = $this->resource();
+ $scans = instanceSettings()->is_image_scanning_allowed
+ ? ResourceScans::current($resource, null, ['id', 'uuid', 'resource_type', 'resource_id', 'deployment_queue_id', 'image_sbom_id', 'image_ref', 'scanned_at'])
+ ->filter(fn (ImageScan $scan): bool => $scan->image_sbom_id !== null)
+ ->values()
+ : collect();
+
+ $sboms = ImageSbom::query()->whereIn('id', $scans->pluck('image_sbom_id')->unique())->get()->keyBy('id');
+
+ /** @var Collection, purl: string, image: string}> $packages */
+ $packages = $scans->flatMap(function (ImageScan $scan) use ($sboms): array {
+ $sbom = $sboms->get($scan->image_sbom_id);
+
+ return collect((array) $sbom?->packages)
+ ->filter(fn ($row): bool => is_array($row))
+ ->map(fn (array $row, int $index): array => [
+ 'key' => $scan->uuid.'-'.$index,
+ 'name' => (string) ($row['name'] ?? ''),
+ 'version' => (string) ($row['version'] ?? ''),
+ 'type' => (string) ($row['type'] ?? ''),
+ 'licenses' => array_values(array_map('strval', (array) ($row['licenses'] ?? []))),
+ 'purl' => (string) ($row['purl'] ?? ''),
+ 'image' => (string) $scan->image_ref,
+ ])
+ ->all();
+ })->values();
+
+ $typeOptions = $packages->pluck('type')->filter()->unique()->sort()->values()->all();
+ $imageOptions = $scans->pluck('image_ref')->map(fn ($ref): string => (string) $ref)->unique()->values()->all();
+ $this->images = array_values(array_intersect($this->images, $imageOptions));
+ $search = mb_strtolower(trim($this->search));
+ $filtered = $packages
+ ->when($this->types !== [], fn (Collection $rows) => $rows->filter(fn (array $row): bool => in_array($row['type'], $this->types, true)))
+ ->when($this->images !== [], fn (Collection $rows) => $rows->filter(fn (array $row): bool => in_array($row['image'], $this->images, true)))
+ ->when($search !== '', fn (Collection $rows) => $rows->filter(fn (array $row): bool => str_contains(mb_strtolower($row['name'].' '.$row['version'].' '.$row['purl']), $search)))
+ ->sortBy([['name', 'asc'], ['version', 'asc']])
+ ->values();
+
+ $this->perPage = max(1, min(100, $this->perPage));
+ $lastPage = max(1, (int) ceil($filtered->count() / $this->perPage));
+ $page = max(1, min((int) $this->getPage(self::PAGE_NAME), $lastPage));
+ if ($page !== (int) $this->getPage(self::PAGE_NAME)) {
+ $this->setPage($page, self::PAGE_NAME);
+ }
+
+ $route = $this->downloadRoute($resource);
+
+ return view('livewire.image-scan.packages', [
+ 'scans' => $scans,
+ 'total' => $packages->count(),
+ 'items' => new LengthAwarePaginator($filtered->forPage($page, $this->perPage)->values(), $filtered->count(), $this->perPage, $page, ['pageName' => self::PAGE_NAME]),
+ 'typeOptions' => $typeOptions,
+ 'imageOptions' => count($imageOptions) > 1 ? $imageOptions : [],
+ 'showsImage' => $scans->pluck('image_ref')->unique()->count() > 1,
+ 'truncated' => $sboms->contains(fn (ImageSbom $sbom): bool => (bool) $sbom->packages_truncated),
+ 'downloads' => $scans->map(fn (ImageScan $scan): array => [
+ 'image' => (string) $scan->image_ref,
+ 'cyclonedx' => route($route['routeName'], [...$route['parameters'], 'scan_uuid' => $scan->uuid, 'format' => 'cyclonedx']),
+ 'spdx' => route($route['routeName'], [...$route['parameters'], 'scan_uuid' => $scan->uuid, 'format' => 'spdx']),
+ ])->all(),
+ ]);
+ }
+}
diff --git a/app/Livewire/ImageScan/ScanSettings.php b/app/Livewire/ImageScan/ScanSettings.php
new file mode 100644
index 0000000000..63488ed2d8
--- /dev/null
+++ b/app/Livewire/ImageScan/ScanSettings.php
@@ -0,0 +1,442 @@
+ 'Coolify', 'team' => 'team', 'server' => 'server'];
+
+ #[Locked]
+ public string $scopeType;
+
+ /** Server, application, database, or service uuid, or the team id for the team scope (teams have no uuid). */
+ #[Locked]
+ public ?string $scopeUuid = null;
+
+ public ?string $gateMode = '';
+
+ public ?string $onScanError = '';
+
+ public ?string $countMedium = '';
+
+ public ?string $appScanEnabled = '1';
+
+ public ?string $buildsByTeam = '';
+
+ public ?string $scanNewServers = '0';
+
+ public function mount(string $scopeType, ?string $scopeUuid = null): void
+ {
+ abort_unless(in_array($scopeType, self::SCOPES, true), 404);
+ $this->scopeType = $scopeType;
+ $this->scopeUuid = $scopeUuid;
+
+ $scope = $this->loadScope();
+ if ($this->isIgnoreOnly()) {
+ return;
+ }
+
+ $policy = ImageScanPolicyResolver::forScope($scope);
+ $this->gateMode = $policy->gate_mode?->value ?? '';
+ $this->onScanError = $policy->on_scan_error?->value ?? '';
+ $this->countMedium = self::boolToOption($policy->count_medium);
+
+ if ($scope instanceof Application) {
+ $this->appScanEnabled = self::boolToOption((bool) $scope->settings?->is_image_scan_enabled);
+ $this->buildsByTeam = self::boolToOption($scope->settings?->image_builds_by_team);
+ }
+ if ($scope instanceof Team) {
+ $this->scanNewServers = self::boolToOption((bool) $scope->scan_images_on_new_servers);
+ }
+ }
+
+ public function updatedGateMode(): void
+ {
+ $this->saveRule('gate_mode', 'gateMode');
+ }
+
+ public function updatedOnScanError(): void
+ {
+ $this->saveRule('on_scan_error', 'onScanError');
+ }
+
+ public function updatedCountMedium(): void
+ {
+ $this->saveRule('count_medium', 'countMedium');
+ }
+
+ public function updatedAppScanEnabled(): void
+ {
+ $application = $this->loadScope();
+ abort_unless($application instanceof Application, 404);
+ $this->authorizeEdit($application);
+ $this->validate(['appScanEnabled' => ['required', 'in:1,0']]);
+
+ $application->settings->is_image_scan_enabled = $this->appScanEnabled === '1';
+ $application->settings->save();
+ $this->audit($application, 'is_image_scan_enabled');
+ $this->dispatch('success', 'Saved.');
+ }
+
+ public function updatedBuildsByTeam(): void
+ {
+ $application = $this->loadScope();
+ abort_unless($application instanceof Application, 404);
+ $this->authorizeEdit($application);
+ $this->validate(['buildsByTeam' => ['nullable', 'in:1,0']]);
+
+ $application->settings->image_builds_by_team = self::optionToBool($this->buildsByTeam);
+ $application->settings->save();
+ $this->audit($application, 'image_builds_by_team');
+ $this->dispatch('success', 'Saved.');
+ }
+
+ public function updatedScanNewServers(): void
+ {
+ $team = $this->loadScope();
+ abort_unless($team instanceof Team, 404);
+ $this->authorizeEdit($team);
+ $this->validate(['scanNewServers' => ['required', 'in:1,0']]);
+
+ $team->scan_images_on_new_servers = $this->scanNewServers === '1';
+ $team->save();
+ $this->audit($team, 'scan_images_on_new_servers');
+ $this->dispatch('success', 'Saved.');
+ }
+
+ /**
+ * Removes an ignore of this level. On a resource page only the ignores of that resource (for a service: of its
+ * applications and databases); team-wide ignores are removed on the team settings.
+ */
+ public function removeIgnore(string $uuid): void
+ {
+ $scope = $this->loadScope();
+ $ignore = match (true) {
+ $scope instanceof Team => ImageScanIgnore::query()
+ ->where('team_id', $scope->id)
+ ->whereNull('resource_id'),
+ $this->isResourceScope($scope) => $this->resourceIgnores($scope),
+ default => null,
+ };
+ $ignore = $ignore?->where('uuid', $uuid)->first();
+ abort_unless($ignore instanceof ImageScanIgnore, 404);
+ $this->authorizeEdit($scope);
+
+ $ignore->delete();
+ if ($ignore->resource_id === null) {
+ RefreshImageScanSummariesJob::dispatch((int) $ignore->team_id);
+ } elseif ($this->isResourceScope($scope)) {
+ ResourceScans::refreshSummaries(ResourceScans::current($scope));
+ }
+ auditLog('ui.image_scan.ignore_removed', [
+ 'team_id' => $ignore->team_id,
+ 'scope' => $this->scopeType,
+ 'scope_uuid' => $this->scopeUuid,
+ 'vulnerability_id' => $ignore->vulnerability_id,
+ 'package' => $ignore->package,
+ ]);
+ $this->dispatch('success', 'Removed.');
+ }
+
+ private function saveRule(string $column, string $property): void
+ {
+ abort_if($this->isIgnoreOnly(), 404);
+ $scope = $this->loadScope();
+ $this->authorizeEdit($scope);
+
+ $rules = ImageScanPolicy::validationRules()[$column];
+ if ($scope === null) {
+ $rules = ['required', ...array_filter($rules, fn ($rule) => $rule !== 'nullable')];
+ }
+ $this->validate([$property => $rules]);
+
+ $policy = ImageScanPolicyResolver::forScope($scope);
+ if ($column === 'gate_mode' && $scope instanceof Application && ! $scope->settings?->is_image_scan_enabled) {
+ $this->gateMode = $policy->gate_mode?->value ?? '';
+ $this->dispatch('error', 'Turn on the scan for this app first.');
+
+ return;
+ }
+
+ $value = $this->{$property} === '' || $this->{$property} === null ? null : $this->{$property};
+ $policy->{$column} = $column === 'count_medium' ? self::optionToBool($value) : $value;
+ $policy->save();
+ if ($column === 'count_medium') {
+ $this->refreshStatuses($scope);
+ }
+ $this->audit($scope, $column);
+ $this->dispatch('success', 'Saved.');
+ }
+
+ /**
+ * Writes the stored statuses again after "Count medium problems" changed: of the app now, of the team of a team
+ * or server in a job, and of every team with scans for the instance.
+ */
+ private function refreshStatuses(?Model $scope): void
+ {
+ if ($scope instanceof Application) {
+ ResourceScans::refreshSummaries(ResourceScans::current($scope));
+
+ return;
+ }
+
+ $teamIds = $scope === null
+ ? Server::query()->whereIn('id', ImageScan::query()->select('server_id'))->distinct()->pluck('team_id')->all()
+ : [$this->teamIdOf($scope)];
+ foreach (array_filter($teamIds, fn ($teamId): bool => $teamId !== null) as $teamId) {
+ RefreshImageScanSummariesJob::dispatch((int) $teamId);
+ }
+ }
+
+ /**
+ * Loads the scope from the locked identifiers and checks that the user may see it.
+ */
+ private function loadScope(): ?Model
+ {
+ $user = auth()->user();
+ abort_unless($user !== null, 403);
+
+ if ($this->scopeType === 'instance') {
+ abort_unless(isInstanceAdmin(), 403);
+
+ return null;
+ }
+
+ $scope = match ($this->scopeType) {
+ 'team' => ctype_digit((string) $this->scopeUuid) ? Team::query()->find((int) $this->scopeUuid) : null,
+ 'server' => Server::query()->where('uuid', (string) $this->scopeUuid)->first(),
+ 'application' => Application::query()->where('uuid', (string) $this->scopeUuid)->first(),
+ // Found only within the user's teams.
+ 'database', 'service' => ResourceScans::find($this->scopeType, (string) $this->scopeUuid, $user->teams->pluck('id')->all()),
+ };
+ $teamId = $this->teamIdOf($scope);
+ abort_unless($teamId !== null && $user->teams->contains('id', $teamId), 404);
+
+ if ($this->isResourceScope($scope)) {
+ $this->authorize('view', $scope);
+ }
+
+ return $scope;
+ }
+
+ private function isIgnoreOnly(): bool
+ {
+ return in_array($this->scopeType, self::IGNORE_ONLY_SCOPES, true);
+ }
+
+ /**
+ * An application, database, or service: a resource with its own Security page.
+ */
+ private function isResourceScope(?Model $scope): bool
+ {
+ return $scope instanceof Application || ($scope !== null && in_array($this->scopeType, self::IGNORE_ONLY_SCOPES, true));
+ }
+
+ private function teamIdOf(?Model $scope): ?int
+ {
+ return match (true) {
+ $scope instanceof Team => $scope->id,
+ $scope instanceof Server => $scope->team_id,
+ $scope !== null && method_exists($scope, 'team') => $scope->team()?->id,
+ default => null,
+ };
+ }
+
+ /**
+ * The ignores of a resource: of an application or database itself, of the applications and databases of a service
+ * (the Security page sets them there), and of the service.
+ *
+ * @return Builder
+ */
+ private function resourceIgnores(Model $scope): Builder
+ {
+ $owners = $scope instanceof Service ? ResourceScans::owners($scope)->push($scope) : collect([$scope]);
+
+ return ImageScanIgnore::query()
+ ->where('team_id', $this->teamIdOf($scope))
+ ->where(fn (Builder $query) => $owners->each(fn (Model $owner) => $query->orWhere(fn (Builder $query) => $query
+ ->where('resource_type', $owner->getMorphClass())
+ ->where('resource_id', $owner->getKey()))));
+ }
+
+ private function authorizeEdit(?Model $scope): void
+ {
+ match (true) {
+ $scope === null => abort_unless(isInstanceAdmin(), 403),
+ $scope instanceof Team => abort_unless(auth()->user()->isAdminOfTeam($scope->id), 403),
+ default => $this->authorize('update', $scope),
+ };
+ }
+
+ private function canEdit(?Model $scope): bool
+ {
+ return match (true) {
+ $scope === null => isInstanceAdmin(),
+ $scope instanceof Team => auth()->user()->isAdminOfTeam($scope->id),
+ default => auth()->user()->can('update', $scope),
+ };
+ }
+
+ private function audit(?Model $scope, string $field): void
+ {
+ auditLog('ui.image_scan.settings_updated', [
+ 'team_id' => $this->teamIdOf($scope),
+ 'scope' => $this->scopeType,
+ 'scope_uuid' => $this->scopeUuid,
+ 'changed_fields' => [$field],
+ ]);
+ }
+
+ private static function boolToOption(?bool $value): string
+ {
+ return match ($value) {
+ true => '1',
+ false => '0',
+ null => '',
+ };
+ }
+
+ private static function optionToBool(?string $value): ?bool
+ {
+ return match ($value) {
+ '1' => true,
+ '0' => false,
+ default => null,
+ };
+ }
+
+ /**
+ * @return array|null Inherit option label per rule field, null for the instance level.
+ */
+ private function inheritLabels(?Model $scope): ?array
+ {
+ if ($scope === null || $this->isIgnoreOnly()) {
+ return null;
+ }
+
+ $inherited = ImageScanPolicyResolver::inheritedFor($scope);
+ $label = fn (string $field, string $valueLabel) => 'Use '.self::LEVEL_NAMES[$inherited[$field]['source']].' default ('.$valueLabel.')';
+
+ return [
+ 'gate_mode' => $label('gate_mode', $inherited['gate_mode']['value']->label()),
+ 'on_scan_error' => $label('on_scan_error', $inherited['on_scan_error']['value']->label()),
+ 'count_medium' => $label('count_medium', $inherited['count_medium']['value'] ? 'Yes' : 'No'),
+ ];
+ }
+
+ /**
+ * @return Collection
+ */
+ private function ignores(?Model $scope): Collection
+ {
+ return match (true) {
+ $scope instanceof Team => ImageScanIgnore::query()
+ ->where('team_id', $scope->id)
+ ->whereNull('resource_id')
+ ->latest()
+ ->get(),
+ $this->isResourceScope($scope) => ImageScanIgnore::query()
+ ->where('team_id', $this->teamIdOf($scope))
+ ->where(fn (Builder $query) => $query
+ ->whereNull('resource_id')
+ ->orWhereIn('id', $this->resourceIgnores($scope)->select('id')))
+ ->orderByRaw('resource_id is null')
+ ->latest()
+ ->get(),
+ default => collect(),
+ };
+ }
+
+ /**
+ * For a service: the name of the application or database each ignore was set for.
+ *
+ * @param Collection $ignores
+ * @return array By ignore uuid.
+ */
+ private function ownerNames(?Model $scope, Collection $ignores): array
+ {
+ if (! $scope instanceof Service) {
+ return [];
+ }
+
+ $names = ResourceScans::owners($scope)->mapWithKeys(fn (Model $owner): array => [
+ $owner->getMorphClass().':'.$owner->getKey() => (string) ($owner->getAttribute('human_name') ?: $owner->getAttribute('name')),
+ ]);
+
+ return $ignores
+ ->filter(fn (ImageScanIgnore $ignore): bool => $ignore->resource_id !== null && $names->has($ignore->resource_type.':'.$ignore->resource_id))
+ ->mapWithKeys(fn (ImageScanIgnore $ignore): array => [(string) $ignore->uuid => $names->get($ignore->resource_type.':'.$ignore->resource_id)])
+ ->all();
+ }
+
+ /**
+ * The team settings page opens the session team, so the link is shown only when that is the team of the
+ * resource and the user can open team-wide pages.
+ */
+ private function showsTeamSettingsLink(?Model $scope): bool
+ {
+ $teamId = $this->isResourceScope($scope) ? $this->teamIdOf($scope) : null;
+
+ return $teamId !== null
+ && currentTeam()?->id === $teamId
+ && app(AccessResolver::class)->coversWholeTeam(auth()->user(), $teamId);
+ }
+
+ public function render(): View
+ {
+ $scope = $this->loadScope();
+ $ignores = $this->ignores($scope);
+
+ return view('livewire.image-scan.scan-settings', [
+ 'canEdit' => $this->canEdit($scope),
+ 'inheritLabels' => $this->inheritLabels($scope),
+ 'gateModeOptions' => collect(ImageScanGateMode::cases())
+ ->map(fn (ImageScanGateMode $mode) => array_filter(['value' => $mode->value, 'label' => $mode->label(), 'description' => $mode->description()], fn ($value) => $value !== null))
+ ->all(),
+ 'onScanErrorOptions' => collect(ImageScanOnError::cases())
+ ->map(fn (ImageScanOnError $option) => ['value' => $option->value, 'label' => $option->label()])
+ ->all(),
+ 'countMediumOptions' => [['value' => '1', 'label' => 'Yes'], ['value' => '0', 'label' => 'No']],
+ 'appScanOn' => $scope instanceof Application ? (bool) $scope->settings?->is_image_scan_enabled : true,
+ 'isDockerImage' => $scope instanceof Application && $scope->build_pack === 'dockerimage',
+ 'showsIgnores' => $scope instanceof Team || $this->isResourceScope($scope),
+ 'showsTeamSettingsLink' => $this->showsTeamSettingsLink($scope),
+ 'ignores' => $ignores,
+ 'ownerNames' => $this->ownerNames($scope, $ignores),
+ ]);
+ }
+}
diff --git a/app/Livewire/ImageScan/ServerImageScanning.php b/app/Livewire/ImageScan/ServerImageScanning.php
new file mode 100644
index 0000000000..8d5cc9d96f
--- /dev/null
+++ b/app/Livewire/ImageScan/ServerImageScanning.php
@@ -0,0 +1,142 @@
+teamChannelListeners([
+ 'ImageScanUpdated' => 'scanUpdated',
+ ]);
+ }
+
+ public function mount(string $server_uuid)
+ {
+ $this->server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail();
+ $this->authorize('viewSecurity', $this->server);
+ }
+
+ public function scanUpdated(array $event = []): void
+ {
+ $resourceUuid = data_get($event, 'resourceUuid');
+ if ($resourceUuid !== $this->server->uuid || data_get($event, 'scanUuid') !== PrepareImageScannerJob::SCAN_UUID) {
+ $this->skipRender();
+ }
+ }
+
+ public function enable()
+ {
+ $this->authorize('update', $this->server);
+
+ try {
+ SetServerImageScanning::run($this->server, true);
+ $this->auditImageScanning('image_scanning_enabled');
+ $this->dispatch('success', 'Image scanning turned on. Coolify is getting the scanner.');
+ } catch (ImageScanningRefused $e) {
+ $this->dispatch('error', message: $e->getMessage());
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
+ }
+ }
+
+ public function prepareScanner()
+ {
+ $this->authorize('update', $this->server);
+
+ try {
+ SetServerImageScanning::ensureManagedHere($this->server);
+ if (! InstanceSettings::get()->is_image_scanning_allowed || ! $this->server->settings->refresh()->is_image_scan_enabled) {
+ $this->dispatch('error', message: 'Turn on image scanning first.');
+
+ return;
+ }
+
+ SetServerImageScanning::prepareScanner($this->server);
+ $this->dispatch('success', 'Coolify is getting the scanner.');
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
+ }
+ }
+
+ public function disable()
+ {
+ $this->authorize('update', $this->server);
+
+ try {
+ SetServerImageScanning::run($this->server, false);
+ $this->auditImageScanning('image_scanning_disabled');
+ $this->dispatch('success', 'Image scanning turned off.');
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
+ }
+ }
+
+ public function deleteScannerData()
+ {
+ $this->authorize('update', $this->server);
+
+ try {
+ SetServerImageScanning::ensureManagedHere($this->server);
+ $commands = new ScannerCommands($this->server, Str::lower(Str::random(24)));
+ instant_remote_process($commands->deleteScannerData(), $this->server, throwError: true, timeout: 120);
+ PrepareImageScannerJob::forget($this->server);
+ $this->auditImageScanning('image_scanner_data_deleted');
+ $this->dispatch('success', 'Scanner data deleted.');
+ } catch (\Throwable $e) {
+ return handleError($e, $this, 'Could not delete the scanner data.');
+ }
+ }
+
+ private function auditImageScanning(string $action): void
+ {
+ auditLog("ui.server.{$action}", [
+ 'team_id' => $this->server->team_id,
+ 'server_uuid' => $this->server->uuid,
+ 'server_name' => $this->server->name,
+ ]);
+ }
+
+ public function render()
+ {
+ $state = Cache::get(PrepareImageScannerJob::stateCacheKey($this->server));
+ $builtAt = Cache::get(PrepareImageScannerJob::builtAtCacheKey($this->server));
+ $failedStep = Cache::get(PrepareImageScannerJob::failedStepCacheKey($this->server));
+ $diskUsed = Cache::get(PrepareImageScannerJob::diskUsedCacheKey($this->server));
+
+ return view('livewire.image-scan.server-image-scanning', [
+ 'allowed' => (bool) InstanceSettings::get()->is_image_scanning_allowed,
+ 'enabled' => (bool) $this->server->settings->is_image_scan_enabled,
+ 'prepareState' => is_string($state) ? $state : null,
+ 'failedStep' => is_string($failedStep) ? $failedStep : null,
+ 'registries' => collect(config('constants.image_scan.images'))
+ ->map(fn (array $image) => ImageRef::parse($image['repo'])->registry)
+ ->unique()
+ ->implode(', '),
+ 'diskUsed' => is_int($diskUsed) ? formatBytes($diskUsed, 1) : null,
+ 'toolVersions' => collect(['syft' => 'Syft', 'grype' => 'Grype', 'regctl' => 'regctl'])
+ ->map(fn (string $name, string $tool) => $name.' '.config("constants.image_scan.images.{$tool}.version"))
+ ->implode(', '),
+ 'builtAt' => is_string($builtAt) ? CarbonImmutable::parse($builtAt) : null,
+ ]);
+ }
+}
diff --git a/app/Livewire/ImageScan/TeamImageScanning.php b/app/Livewire/ImageScan/TeamImageScanning.php
new file mode 100644
index 0000000000..4da00aefd6
--- /dev/null
+++ b/app/Livewire/ImageScan/TeamImageScanning.php
@@ -0,0 +1,32 @@
+user()->teams->contains('id', $team->id), 403);
+ $this->teamId = $team->id;
+ }
+
+ public function render(): View
+ {
+ return view('livewire.image-scan.team-image-scanning', [
+ 'allowed' => (bool) InstanceSettings::get()->is_image_scanning_allowed,
+ ]);
+ }
+}
diff --git a/app/Livewire/ImageScan/Vulnerabilities.php b/app/Livewire/ImageScan/Vulnerabilities.php
new file mode 100644
index 0000000000..688cf92d79
--- /dev/null
+++ b/app/Livewire/ImageScan/Vulnerabilities.php
@@ -0,0 +1,1024 @@
+ 4, 'high' => 3, 'medium' => 2, 'low' => 1, 'unknown' => 0];
+
+ private const VIEWS = ['package', 'cve'];
+
+ private const EXPIRES = ['30', '90', 'never'];
+
+ private const CHECKPOINTS = ['Get scanner', 'Update database', 'Read image', 'Find vulnerabilities'];
+
+ /** Progress stage of ScanImage => index in CHECKPOINTS. */
+ private const STAGE_CHECKPOINT = [ScanResourceImagesJob::QUEUED_STAGE => 0, 'database' => 1, 'pull' => 2, 'inspect' => 2, 'sbom' => 2, 'vulnerabilities' => 3];
+
+ /** The fix states of the findings table: a fixed version exists (or an action fixes it), or none exists yet. */
+ public const FIX_STATES = ['fixed' => 'Fix available upstream', 'no_fix' => 'No fix yet'];
+
+ #[Locked]
+ public string $resourceType;
+
+ #[Locked]
+ public string $resourceUuid;
+
+ public string $fixState = 'fixed';
+
+ /** @var list */
+ public array $severities = [];
+
+ /** @var list Image refs, for pages with more than one image. */
+ public array $images = [];
+
+ public string $view = 'package';
+
+ public string $search = '';
+
+ public bool $showIgnored = false;
+
+ public int $perPage = 10;
+
+ private ?Model $loadedResource = null;
+
+ /** @var Collection|null */
+ private ?Collection $loadedOwners = null;
+
+ /** @var Collection|null */
+ private ?Collection $scans = null;
+
+ /** @var list|null */
+ private ?array $rows = null;
+
+ /** @var array */
+ private array $relevanceRules = [];
+
+ public function getListeners(): array
+ {
+ return $this->teamChannelListeners([
+ 'ImageScanUpdated' => 'scanUpdated',
+ ]);
+ }
+
+ public function mount(string $resourceType, string $resourceUuid): void
+ {
+ abort_unless(in_array($resourceType, ResourceScans::RESOURCE_TYPES, true), 404);
+ $this->resourceType = $resourceType;
+ $this->resourceUuid = $resourceUuid;
+ $this->resource();
+ }
+
+ /**
+ * @param array $event
+ */
+ public function scanUpdated(array $event = []): void
+ {
+ $uuids = $this->owners()
+ ->map(fn (Model $owner): string => (string) $owner->getAttribute('uuid'))
+ ->push($this->resourceUuid);
+
+ if (! $uuids->contains((string) data_get($event, 'resourceUuid'))) {
+ $this->skipRender();
+ }
+ }
+
+ public function updated(string $property): void
+ {
+ if (in_array($property, ['fixState', 'search', 'view', 'perPage', 'showIgnored'], true)) {
+ $this->resetPage();
+ }
+ }
+
+ public function setFixState(string $state): void
+ {
+ $this->fixState = array_key_exists($state, self::FIX_STATES) ? $state : 'fixed';
+ $this->showIgnored = false;
+ $this->resetPage();
+ }
+
+ public function toggleSeverity(string $severity): void
+ {
+ if (! in_array($severity, self::SEVERITIES, true)) {
+ return;
+ }
+
+ $this->severities = in_array($severity, $this->severities, true)
+ ? array_values(array_diff($this->severities, [$severity]))
+ : [...$this->severities, $severity];
+ $this->resetPage();
+ }
+
+ public function toggleImage(string $image): void
+ {
+ $this->images = in_array($image, $this->images, true)
+ ? array_values(array_diff($this->images, [$image]))
+ : [...$this->images, $image];
+ $this->resetPage();
+ }
+
+ public function resetFilters(): void
+ {
+ $this->severities = [];
+ $this->resetPage();
+ }
+
+ /**
+ * The image picker of the findings toolbar: one image, or every image for ''.
+ */
+ public function selectImage(string $image): void
+ {
+ if ($image === '') {
+ $this->images = [];
+ $this->resetPage();
+
+ return;
+ }
+
+ $this->showImageFindings($image);
+ }
+
+ /**
+ * The "{n} findings" link of an image row: the findings of that image only. The browser opens the section.
+ */
+ public function showImageFindings(string $image): void
+ {
+ $refs = $this->currentScans()->pluck('image_ref')->map(fn ($ref): string => (string) $ref)->unique();
+ if (! $refs->contains($image)) {
+ return;
+ }
+
+ $this->images = [$image];
+ $this->showIgnored = false;
+ $this->resetPage();
+ }
+
+ public function scanNow(): void
+ {
+ $resource = $this->resource();
+ $this->authorize('update', $resource);
+ $owners = $this->owners();
+
+ if ($this->progress($owners) !== null) {
+ $this->dispatch('error', 'A scan is already running.');
+
+ return;
+ }
+
+ $policy = ImageScanPolicyResolver::for($resource);
+ if ($policy->scanServer instanceof Server && $policy->scanServer->isTransferredAway()) {
+ $this->dispatch('error', 'Another Coolify instance manages this server now.');
+
+ return;
+ }
+
+ if (! $policy->scanEnabled) {
+ $this->dispatch('error', 'Image scanning is off for this resource.');
+
+ return;
+ }
+
+ $imageRefs = [];
+ if ($resource instanceof Application) {
+ $imageRefs = $this->applicationImageRefs($resource);
+ if ($imageRefs === []) {
+ $this->dispatch('error', 'Deploy the app first.');
+
+ return;
+ }
+ }
+
+ $started = RateLimiter::attempt(
+ "image-scan-now:{$resource->getMorphClass()}:{$resource->getKey()}",
+ 1,
+ function () use ($resource, $imageRefs): void {
+ // Marked first: a sync queue runs the job inside dispatch().
+ ScanResourceImagesJob::markQueued($resource, $imageRefs);
+ ScanResourceImagesJob::dispatch($resource, null, $imageRefs, pullWhenMissing: true);
+ },
+ 60,
+ );
+
+ if (! $started) {
+ $this->dispatch('error', 'A scan started less than a minute ago. Try again later.');
+
+ return;
+ }
+
+ auditLog('ui.image_scan.scan_now', [
+ 'team_id' => $resource->team()?->id,
+ 'resource_type' => $resource->getMorphClass(),
+ 'resource_uuid' => $this->resourceUuid,
+ ]);
+ $this->dispatch('success', 'Scan started');
+ }
+
+ /**
+ * Stops replacing the image of a Compose service: the next deploy uses the image of the compose file again.
+ */
+ public function removeImageOverride(string $service): void
+ {
+ $resource = $this->resource();
+ $this->authorize('update', $resource);
+ abort_unless($resource instanceof Application && $resource->build_pack === 'dockercompose', 404);
+ if ($resource->destination?->server?->isTransferredAway()) {
+ $this->dispatch('error', 'Another Coolify instance manages this server now.');
+
+ return;
+ }
+
+ $overrides = (array) $resource->docker_compose_image_overrides;
+ if (! isset($overrides[$service])) {
+ return;
+ }
+ unset($overrides[$service]);
+ $resource->docker_compose_image_overrides = $overrides === [] ? null : $overrides;
+ $resource->save();
+
+ auditLog('ui.image_scan.image_override_removed', [
+ 'team_id' => $resource->team()?->id,
+ 'resource_type' => $resource->getMorphClass(),
+ 'resource_uuid' => (string) $resource->uuid,
+ 'service' => $service,
+ 'user_id' => auth()->id(),
+ ]);
+ $this->dispatch('success', 'The next deploy uses the image of the compose file.');
+ }
+
+ /**
+ * Updates the image to a candidate of an action of the main card. The confirmation modal passes the password field
+ * last; it is not used.
+ *
+ * @param string|null $ownerUuid On a service page: the application or database whose scan offers the candidate.
+ */
+ public function updateImage(string $candidateRef, ?string $ownerUuid = null): bool
+ {
+ $resource = $this->resource();
+ $this->authorize('update', $resource);
+
+ $target = $resource;
+ if ($resource instanceof Service) {
+ $target = $this->owners()->first(fn (Model $owner): bool => filled($ownerUuid) && $owner->getAttribute('uuid') === $ownerUuid);
+ abort_unless($target instanceof Model, 404);
+ }
+
+ $refusal = $this->updateRefusal(collect(SecurityStatus::forResource($resource)->actions)->first(
+ fn (SecurityAction $action): bool => $action->ref === $candidateRef
+ || collect($action->parts)->contains(fn (array $part): bool => $part['ref'] === $candidateRef),
+ ));
+ if ($refusal !== null) {
+ $this->dispatch('error', $refusal);
+
+ return true;
+ }
+
+ try {
+ UpdateResourceImage::run($target, $candidateRef, auth()->user());
+ } catch (ImageUpdateRefused $e) {
+ $this->dispatch('error', $e->getMessage());
+
+ return true;
+ } catch (AuthorizationException|HttpExceptionInterface $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ report($e);
+ $this->dispatch('error', 'The update could not start.');
+
+ return true;
+ }
+
+ $this->loadedOwners = null;
+ $this->scans = null;
+ $this->rows = null;
+ $this->dispatch('success', 'Update started.');
+
+ return true;
+ }
+
+ /**
+ * Runs a merged service update ("Update n8n and n8n runners to 2.10.4."): every image of the action in one change
+ * and one restart. The action is looked up again from the current status by its key.
+ */
+ public function updateImages(string $actionKey): bool
+ {
+ $resource = $this->resource();
+ $this->authorize('update', $resource);
+ abort_unless($resource instanceof Service, 404);
+
+ $action = collect(SecurityStatus::forResource($resource)->actions)
+ ->first(fn (SecurityAction $action): bool => $action->parts !== [] && SecurityCard::key($action) === $actionKey);
+ $refusal = $this->updateRefusal($action);
+ if ($refusal !== null) {
+ $this->dispatch('error', $refusal);
+
+ return true;
+ }
+
+ $owners = $this->owners();
+ $parts = [];
+ foreach ($action->parts as $part) {
+ $owner = $owners->first(fn (Model $owner): bool => $owner->getMorphClass() === $part['owner_type'] && (string) $owner->getKey() === (string) $part['owner_id']);
+ abort_unless($owner instanceof Model && is_string($part['ref']), 404);
+ $parts[] = ['owner' => $owner, 'ref' => $part['ref']];
+ }
+
+ try {
+ UpdateResourceImage::make()->handleParts($resource, $parts, auth()->user());
+ } catch (ImageUpdateRefused $e) {
+ $this->dispatch('error', $e->getMessage());
+
+ return true;
+ } catch (AuthorizationException|HttpExceptionInterface $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ report($e);
+ $this->dispatch('error', 'The update could not start.');
+
+ return true;
+ }
+
+ $this->loadedOwners = null;
+ $this->scans = null;
+ $this->rows = null;
+ $this->dispatch('success', 'Update started.');
+
+ return true;
+ }
+
+ /**
+ * Why the page does not run an update: it is no longer an action of the current status, or the action has no
+ * button (Coolify's own resources, a server another Coolify instance manages, an image set in a compose file).
+ */
+ private function updateRefusal(?SecurityAction $action): ?string
+ {
+ return match (true) {
+ ! $action instanceof SecurityAction => 'This update is no longer offered. Reload the page.',
+ ! $action->button => 'Coolify cannot run this update. Follow the steps on this page instead.',
+ default => null,
+ };
+ }
+
+ /**
+ * Rebuilds the running commit of the app without cache and redeploys it (spec 15.5 `rebuild`), on the server and
+ * destination of the deployment that runs now.
+ */
+ public function rebuild(): bool
+ {
+ $application = $this->resource();
+ abort_unless($application instanceof Application, 404);
+ $this->authorize('deploy', $application);
+ abort_if((int) $application->getKey() === 0, 403, 'Coolify does not rebuild its own resources.');
+
+ $running = SecurityCard::runningDeployment($application);
+ if (! $running instanceof ApplicationDeploymentQueue) {
+ $this->dispatch('error', 'Deploy the app first.');
+
+ return true;
+ }
+ if (! SecurityCard::knowsRunningCommit($application, $running)) {
+ $this->dispatch('error', 'Coolify does not know which commit runs now. Redeploy the app first.');
+
+ return true;
+ }
+
+ $server = $application->deploymentServer($running);
+ if (! $server instanceof Server) {
+ $this->dispatch('error', 'The server of the running deployment is no longer one of this app.');
+
+ return true;
+ }
+ if ($server->isTransferredAway()) {
+ $this->dispatch('error', 'Another Coolify instance manages this server now.');
+
+ return true;
+ }
+ $destination = StandaloneDocker::query()
+ ->whereKey($running->destination_id)
+ ->where('server_id', $server->id)
+ ->first();
+
+ try {
+ $result = queue_application_deployment(
+ application: $application,
+ deployment_uuid: new_public_id(),
+ commit: $running->commit,
+ force_rebuild: true,
+ git_type: $running->git_type,
+ server: $server,
+ destination: $destination,
+ only_this_server: (bool) $running->only_this_server,
+ docker_registry_image_tag: $running->docker_registry_image_tag,
+ );
+ } catch (AuthorizationException|HttpExceptionInterface $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ report($e);
+ $this->dispatch('error', 'The rebuild could not start.');
+
+ return true;
+ }
+
+ if (($result['status'] ?? null) !== 'queued') {
+ $this->dispatch('error', 'The rebuild did not start.', (string) ($result['message'] ?? ''));
+
+ return true;
+ }
+
+ auditLog('ui.image_scan.rebuild', [
+ 'team_id' => $application->team()?->id,
+ 'resource_type' => $application->getMorphClass(),
+ 'resource_uuid' => (string) $application->uuid,
+ 'commit' => $running->commit,
+ ]);
+ $this->dispatch('success', 'Rebuild started.');
+
+ return true;
+ }
+
+ /**
+ * Ignores a vulnerability ID or all vulnerabilities of a package.
+ *
+ * @param string $kind vulnerability|package
+ * @param string $scope resource|team
+ * @param string $expires 30|90|never (days)
+ * @param string|null $ownerUuid On a service page: the application or database the finding belongs to.
+ */
+ public function ignore(string $target, string $kind, string $scope, string $expires, string $reason, ?string $ownerUuid = null): bool
+ {
+ $resource = $this->resource();
+ $this->authorize('update', $resource);
+ $teamId = $resource->team()?->id;
+ abort_unless($teamId !== null, 404);
+
+ if ($scope === 'team') {
+ abort_unless(auth()->user()->isAdminOfTeam($teamId), 403);
+ }
+
+ $package = $kind === 'package' ? (preg_replace('/\p{Cc}/u', '', $target) ?? '') : null;
+ $data = [
+ 'kind' => $kind,
+ 'scope' => $scope,
+ 'expires' => $expires,
+ 'vulnerability_id' => $kind === 'vulnerability' ? $target : null,
+ 'package' => $package,
+ 'reason' => trim($reason),
+ ];
+ $this->validateOrToast(Validator::make($data, [
+ ...ImageScanIgnore::validationRules(),
+ 'kind' => ['required', 'in:vulnerability,package'],
+ 'scope' => ['required', 'in:resource,team'],
+ 'expires' => ['required', 'in:'.implode(',', self::EXPIRES)],
+ ]));
+
+ $owner = $resource instanceof Service
+ ? $this->owners()->first(fn (Model $owner): bool => $ownerUuid !== null && $owner->getAttribute('uuid') === $ownerUuid)
+ : $resource;
+ abort_unless($owner instanceof Model, 404);
+
+ $matching = $this->ownerFindings($owner, $kind, $kind === 'package' ? (string) $package : $target);
+ if ($matching === []) {
+ $this->validateOrToast(Validator::make([], [])->after(fn ($validator) => $validator->errors()
+ ->add($kind === 'package' ? 'package' : 'vulnerability_id', 'This is not in the scan results.')));
+ }
+
+ // Spec 15.2: an ignore of a problem with an exploit signal expires after at most 90 days.
+ if ($expires === 'never' && collect($matching)->contains(fn (Finding $finding): bool => self::hasExploitSignal($finding))) {
+ $this->validateOrToast(Validator::make([], [])->after(fn ($validator) => $validator->errors()
+ ->add('expires', 'Attackers are using or are likely to use this problem, so its ignore must expire. Choose 30 or 90 days.')));
+ }
+
+ $ignore = ImageScanIgnore::query()->create([
+ 'team_id' => $teamId,
+ 'resource_type' => $scope === 'resource' ? $owner->getMorphClass() : null,
+ 'resource_id' => $scope === 'resource' ? $owner->getKey() : null,
+ 'vulnerability_id' => $data['vulnerability_id'],
+ 'package' => $data['package'],
+ 'reason' => $data['reason'],
+ 'expires_at' => $expires === 'never' ? null : now()->addDays((int) $expires),
+ 'created_by' => auth()->id(),
+ ]);
+
+ $this->rows = null;
+ $this->relevanceRules = [];
+ ResourceScans::refreshSummaries($this->currentScans());
+ if ($scope === 'team') {
+ RefreshImageScanSummariesJob::dispatch($teamId);
+ }
+
+ auditLog('ui.image_scan.ignore_added', [
+ 'team_id' => $teamId,
+ 'resource_type' => $ignore->resource_type,
+ 'resource_uuid' => $scope === 'resource' ? $owner->getAttribute('uuid') : null,
+ 'vulnerability_id' => $ignore->vulnerability_id,
+ 'package' => $ignore->package,
+ 'expires_at' => $ignore->expires_at?->toIso8601String(),
+ ]);
+ $this->dispatch('success', 'Ignored');
+
+ return true;
+ }
+
+ /**
+ * Validates, and shows the first error as a toast too, because the ignore modal does not render server errors.
+ */
+ private function validateOrToast(\Illuminate\Validation\Validator $validator): void
+ {
+ try {
+ $validator->validate();
+ } catch (ValidationException $e) {
+ $this->dispatch('error', $e->validator->errors()->first());
+
+ throw $e;
+ }
+ }
+
+ /**
+ * The current scans of the page, loaded once per request.
+ *
+ * @return Collection
+ */
+ private function currentScans(): Collection
+ {
+ return $this->scans ??= ResourceScans::current($this->resource(), $this->owners());
+ }
+
+ private function rulesFor(Model $owner): RelevanceRules
+ {
+ return $this->relevanceRules[$owner->getMorphClass().':'.$owner->getKey()] ??= RelevanceRules::forResource($owner);
+ }
+
+ /**
+ * Loads the resource from the locked identifiers, only in the user's teams, and checks that the user may see it.
+ */
+ private function resource(): Model
+ {
+ if ($this->loadedResource !== null) {
+ return $this->loadedResource;
+ }
+
+ $user = auth()->user();
+ abort_unless($user !== null, 403);
+
+ $resource = ResourceScans::find($this->resourceType, $this->resourceUuid, $user->teams->pluck('id')->all());
+ abort_unless($resource instanceof Model, 404);
+ $this->authorize('view', $resource);
+
+ return $this->loadedResource = $resource;
+ }
+
+ /**
+ * @return Collection
+ */
+ private function owners(): Collection
+ {
+ return $this->loadedOwners ??= ResourceScans::owners($this->resource());
+ }
+
+ /**
+ * @return list
+ */
+ private function applicationImageRefs(Application $application): array
+ {
+ $ref = ResourceScans::currentImageRef($application);
+ if ($ref !== null) {
+ return [$ref];
+ }
+
+ return ResourceScans::currentFor($application, ['id', 'resource_type', 'resource_id', 'deployment_queue_id', 'image_ref', 'scanned_at'])
+ ->pluck('image_ref')
+ ->filter()
+ ->unique()
+ ->values()
+ ->all();
+ }
+
+ /**
+ * The findings of the owner's current scans that an ignore of $target would match.
+ *
+ * @return list
+ */
+ private function ownerFindings(Model $owner, string $kind, string $target): array
+ {
+ $target = $kind === 'package' ? $target : mb_strtolower($target);
+ $scans = $this->currentScans()->filter(fn (ImageScan $scan): bool => $scan->resource->is($owner));
+
+ $matching = [];
+ foreach ($scans as $scan) {
+ foreach (ResourceScans::findings($scan) as $finding) {
+ $matches = $kind === 'package'
+ ? $finding->package === $target
+ : in_array($target, array_map('mb_strtolower', [$finding->id, ...$finding->aliases]), true);
+
+ if ($matches) {
+ $matching[] = $finding;
+ }
+ }
+ }
+
+ return $matching;
+ }
+
+ /**
+ * A finding whose ignore must expire (spec 15.2): an exploit signal, and not a CPE-only OS match.
+ */
+ private static function hasExploitSignal(Finding $finding): bool
+ {
+ return SecurityRules::exploitSignal($finding) !== null && ! SecurityRules::isCpeOnlyOsMatch($finding);
+ }
+
+ /**
+ * The scan progress of the first owner that is being scanned.
+ *
+ * @param Collection $owners
+ * @return array|null
+ */
+ private function progress(Collection $owners): ?array
+ {
+ foreach ($owners as $owner) {
+ $progress = Cache::get(ScanImage::progressCacheKey($owner));
+ if (is_array($progress)) {
+ return $progress;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * The checkpoint list of a running scan. ScanImage reports the stages database, inspect, sbom (Syft reads the
+ * image), and vulnerabilities (Grype). The scanner images are ready before the first stage.
+ *
+ * @param array|null $progress
+ * @return list
+ */
+ private function checkpoints(?array $progress): array
+ {
+ if ($progress === null) {
+ return [];
+ }
+
+ $current = self::STAGE_CHECKPOINT[(string) ($progress['stage'] ?? '')] ?? 0;
+
+ return array_map(fn (string $title, int $index): array => [
+ 'title' => $title,
+ 'status' => match (true) {
+ $index < $current => 'success',
+ $index === $current => 'running',
+ default => 'pending',
+ },
+ ], self::CHECKPOINTS, array_keys(self::CHECKPOINTS));
+ }
+
+ /**
+ * The last scan errors of the owners, as their fixed messages.
+ *
+ * @param Collection $owners
+ * @return list
+ */
+ private function errorMessages(Collection $owners): array
+ {
+ $messages = [];
+ foreach ($owners as $owner) {
+ $error = Cache::get(ScanImage::lastErrorCacheKey($owner));
+ $code = is_array($error) ? ImageScanErrorCode::tryFrom((string) ($error['code'] ?? '')) : null;
+ if ($code !== null) {
+ $messages[] = $code->message((array) ($error['context'] ?? []));
+ }
+ }
+
+ return array_values(array_unique($messages));
+ }
+
+ /**
+ * One row per finding of the current scans, with its fix state and the ignore that matches it. Built once per
+ * request.
+ *
+ * @return list
+ */
+ private function findingRows(): array
+ {
+ if ($this->rows !== null) {
+ return $this->rows;
+ }
+
+ $rows = [];
+ foreach ($this->currentScans() as $scan) {
+ $owner = $scan->resource;
+ $rules = $this->rulesFor($owner);
+ $classify = SecurityActions::fixStates($scan, $rules);
+ $baseIds = $this->baseIds($scan);
+
+ foreach (ResourceScans::findings($scan) as $index => $finding) {
+ $fromBase = $baseIds !== [] && collect([$finding->id, ...$finding->aliases])
+ ->contains(fn (string $id): bool => isset($baseIds[mb_strtolower($id)]));
+ $rows[] = [
+ 'key' => $scan->uuid.'-'.$index,
+ 'scan' => (string) $scan->uuid,
+ 'finding' => $finding,
+ 'state' => $classify($finding)->value,
+ 'ignore' => $rules->ignores->matches($finding),
+ 'image' => (string) $scan->image_ref,
+ 'owner' => (string) $owner->getAttribute('uuid'),
+ 'base' => $fromBase ? (string) $scan->base_image_ref : null,
+ ];
+ }
+ }
+
+ // The ignore modal offers "Never" only when no finding the ignore would match has an exploit signal.
+ $signalIds = [];
+ $signalPackages = [];
+ foreach ($rows as $row) {
+ if (self::hasExploitSignal($row['finding'])) {
+ foreach ([$row['finding']->id, ...$row['finding']->aliases] as $id) {
+ $signalIds[$row['owner']][mb_strtolower($id)] = true;
+ }
+ $signalPackages[$row['owner']][$row['finding']->package] = true;
+ }
+ }
+ foreach ($rows as $index => $row) {
+ $rows[$index]['never_allowed'] = ! isset($signalIds[$row['owner']][mb_strtolower($row['finding']->id)]);
+ $rows[$index]['package_never_allowed'] = ! isset($signalPackages[$row['owner']][$row['finding']->package]);
+ }
+
+ return $this->rows = $rows;
+ }
+
+ /**
+ * The IDs of the findings that the current base image of a Dockerfile build has too, from the last base check.
+ *
+ * @return array
+ */
+ private function baseIds(ImageScan $scan): array
+ {
+ $base = data_get($scan->candidates, 'base');
+ if (blank($scan->base_image_ref) || ! is_array($base) || ($base['image'] ?? null) !== $scan->base_image_ref) {
+ return [];
+ }
+
+ $ids = [];
+ foreach ((array) ($base['attributable_ids'] ?? []) as $id) {
+ if (is_string($id) && $id !== '') {
+ $ids[mb_strtolower($id)] = true;
+ }
+ }
+
+ return $ids;
+ }
+
+ /**
+ * @param list $rows
+ * @return list
+ */
+ private function filterRows(array $rows): array
+ {
+ $search = mb_strtolower(trim($this->search));
+ $severities = array_values(array_intersect($this->severities, self::SEVERITIES));
+
+ return array_values(array_filter($rows, function (array $row) use ($search, $severities): bool {
+ $finding = $row['finding'];
+
+ if ($severities !== [] && ! in_array($this->severityOf($finding), $severities, true)) {
+ return false;
+ }
+
+ if ($this->images !== [] && ! in_array($row['image'], $this->images, true)) {
+ return false;
+ }
+
+ if ($search === '') {
+ return true;
+ }
+
+ $haystack = mb_strtolower(implode(' ', [$finding->id, ...$finding->aliases, $finding->package, $finding->version]));
+
+ return str_contains($haystack, $search);
+ }));
+ }
+
+ private function severityOf(Finding $finding): string
+ {
+ $severity = strtolower($finding->severity);
+
+ return in_array($severity, self::SEVERITIES, true) ? $severity : 'unknown';
+ }
+
+ /**
+ * Rows of the By package view: one per package version of an image, worst first.
+ *
+ * @param list $rows
+ * @return list>
+ */
+ private function packageGroups(array $rows): array
+ {
+ $groups = [];
+ foreach ($rows as $row) {
+ $finding = $row['finding'];
+ $key = md5($row['owner']."\0".$row['image']."\0".$finding->package."\0".$finding->version);
+ $groups[$key] ??= [
+ 'key' => $key,
+ 'package' => $finding->package,
+ 'version' => $finding->version,
+ 'type' => $finding->type,
+ 'image' => $row['image'],
+ 'owner' => $row['owner'],
+ 'severity' => 'unknown',
+ 'kev' => false,
+ 'never_allowed' => $row['package_never_allowed'],
+ 'fixed' => [],
+ 'rows' => [],
+ ];
+
+ $group = &$groups[$key];
+ $group['rows'][] = $row;
+ $group['kev'] = $group['kev'] || $finding->kev;
+ if (self::SEVERITY_RANK[$this->severityOf($finding)] > self::SEVERITY_RANK[$group['severity']]) {
+ $group['severity'] = $this->severityOf($finding);
+ }
+ if ($finding->fixedIn !== null && $finding->fixedIn !== '') {
+ $group['fixed'][] = $finding->fixedIn;
+ }
+ unset($group);
+ }
+
+ $groups = array_map(function (array $group): array {
+ $fixed = array_values(array_unique($group['fixed']));
+ usort($fixed, fn (string $a, string $b): int => version_compare($b, $a) ?: strnatcmp($b, $a));
+ $group['fixed'] = $fixed[0] ?? null;
+ $group['count'] = count($group['rows']);
+
+ return $group;
+ }, array_values($groups));
+
+ usort($groups, fn (array $a, array $b): int => [$b['kev'], self::SEVERITY_RANK[$b['severity']], $b['count'], $a['package']]
+ <=> [$a['kev'], self::SEVERITY_RANK[$a['severity']], $a['count'], $b['package']]);
+
+ return $groups;
+ }
+
+ /**
+ * @param list $rows
+ * @return list
+ */
+ private function sortRows(array $rows): array
+ {
+ usort($rows, fn (array $a, array $b): int => [$b['finding']->kev, self::SEVERITY_RANK[$this->severityOf($b['finding'])], $b['finding']->risk ?? 0.0]
+ <=> [$a['finding']->kev, self::SEVERITY_RANK[$this->severityOf($a['finding'])], $a['finding']->risk ?? 0.0]);
+
+ return $rows;
+ }
+
+ /**
+ * @param list $items
+ */
+ private function paginate(array $items): LengthAwarePaginator
+ {
+ $this->perPage = max(1, min(100, $this->perPage));
+ $lastPage = max(1, (int) ceil(count($items) / $this->perPage));
+ $page = max(1, min((int) $this->getPage(), $lastPage));
+ if ($page !== (int) $this->getPage()) {
+ $this->setPage($page);
+ }
+
+ return new LengthAwarePaginator(
+ collect($items)->forPage($page, $this->perPage)->values(),
+ count($items),
+ $this->perPage,
+ $page,
+ );
+ }
+
+ /**
+ * The page state before results: instance off, server off, or app scan off.
+ */
+ private function offState(Model $resource, ?Server $server): ?string
+ {
+ return ImageScanPolicyResolver::offState($resource, $server);
+ }
+
+ public function render(): View
+ {
+ $resource = $this->resource();
+ $owners = $this->owners();
+ $server = ImageScanPolicyResolver::for($resource)->scanServer;
+ $offState = $this->offState($resource, $server);
+ $user = auth()->user();
+ $canUpdate = $user->can('update', $resource);
+
+ $base = [
+ 'offState' => $offState,
+ 'serverName' => (string) ($server?->name ?? 'this server'),
+ 'serverUrl' => $server instanceof Server && $user->can('update', $server)
+ ? route('server.security.image-scanning', ['server_uuid' => $server->uuid])
+ : null,
+ 'canUpdate' => $canUpdate,
+ ];
+
+ if ($offState !== null) {
+ return view('livewire.image-scan.vulnerabilities', $base);
+ }
+
+ $this->fixState = array_key_exists($this->fixState, self::FIX_STATES) ? $this->fixState : 'fixed';
+ $this->view = in_array($this->view, self::VIEWS, true) ? $this->view : 'package';
+
+ $scans = $this->currentScans();
+ $allRows = $this->findingRows();
+ $activeRows = array_values(array_filter($allRows, fn (array $row): bool => $row['ignore'] === null));
+ $ignoredRows = array_values(array_filter($allRows, fn (array $row): bool => $row['ignore'] !== null));
+
+ $filteredRows = $this->filterRows($activeRows);
+ $stateCounts = array_fill_keys(array_keys(self::FIX_STATES), 0);
+ foreach ($filteredRows as $row) {
+ $stateCounts[$row['state']]++;
+ }
+
+ if ($this->showIgnored) {
+ $listRows = $this->sortRows($this->filterRows($ignoredRows));
+ $items = $this->paginate($listRows);
+ } else {
+ $listRows = array_values(array_filter($filteredRows, fn (array $row): bool => $row['state'] === $this->fixState));
+ $items = $this->paginate($this->view === 'package' ? $this->packageGroups($listRows) : $this->sortRows($listRows));
+ }
+
+ $progress = $this->progress($owners);
+ // One status per image, with the relevance rules of the findings table, shared by the resource status and the rows.
+ $imageStatuses = $scans->mapWithKeys(fn (ImageScan $scan): array => [(string) $scan->uuid => SecurityStatus::forScan($scan, $this->rulesFor($scan->resource))])->all();
+ $security = $scans->isEmpty() ? null : SecurityStatus::forResource($resource, $scans, $imageStatuses);
+ $checkedAt = $security?->checkedAt ?? $scans->max('scanned_at');
+ $imageOptions = $scans->pluck('image_ref')->map(fn ($ref) => (string) $ref)->unique()->values()->all();
+
+ return view('livewire.image-scan.vulnerabilities', [
+ ...$base,
+ 'scans' => $scans,
+ 'progress' => $progress,
+ 'checkpoints' => $this->checkpoints($progress),
+ 'errorMessages' => $this->errorMessages($owners),
+ 'card' => $security === null ? null : SecurityCard::for($resource, $security, $owners, $scans, $user, $imageStatuses, fn (Model $owner): RelevanceRules => $this->rulesFor($owner)),
+ 'isApplication' => $resource instanceof Application,
+ 'checkedAt' => $checkedAt === null ? null : Carbon::instance($checkedAt),
+ 'truncated' => $scans->contains(fn (ImageScan $scan): bool => (bool) data_get($scan->summary, 'truncated', false)),
+ 'findingCount' => count($activeRows),
+ 'stateCounts' => $stateCounts,
+ 'fixStates' => self::FIX_STATES,
+ 'ignoredCount' => count($ignoredRows),
+ 'items' => $items,
+ 'groupedFindingCount' => ! $this->showIgnored && $this->view === 'package' ? count($listRows) : null,
+ 'imageOptions' => count($imageOptions) > 1 ? $imageOptions : [],
+ 'showsImage' => count($imageOptions) > 1,
+ 'isService' => $resource instanceof Service,
+ 'canIgnoreForTeam' => $canUpdate && $user->isAdminOfTeam((int) $resource->team()?->id),
+ 'hasFilters' => $this->severities !== [] || $this->images !== [] || trim($this->search) !== '',
+ 'severityOptions' => self::SEVERITIES,
+ ]);
+ }
+}
diff --git a/app/Livewire/Notifications/Concerns/TogglesNotificationEvents.php b/app/Livewire/Notifications/Concerns/TogglesNotificationEvents.php
index 37291ba59b..ec42baa941 100644
--- a/app/Livewire/Notifications/Concerns/TogglesNotificationEvents.php
+++ b/app/Livewire/Notifications/Concerns/TogglesNotificationEvents.php
@@ -20,6 +20,7 @@ trait TogglesNotificationEvents
'serverUnreachable',
'serverPatch',
'traefikOutdated',
+ 'imageVulnerabilities',
];
public function toggleEvent(string $property): void
diff --git a/app/Livewire/Notifications/Discord.php b/app/Livewire/Notifications/Discord.php
index 46f6739833..7e0f169d2b 100644
--- a/app/Livewire/Notifications/Discord.php
+++ b/app/Livewire/Notifications/Discord.php
@@ -70,6 +70,9 @@ class Discord extends Component
#[Validate(['boolean'])]
public bool $traefikOutdatedDiscordNotifications = true;
+ #[Validate(['boolean'])]
+ public bool $imageVulnerabilitiesDiscordNotifications = false;
+
#[Validate(['boolean'])]
public bool $discordPingEnabled = true;
@@ -107,6 +110,7 @@ class Discord extends Component
$this->settings->server_unreachable_discord_notifications = $this->serverUnreachableDiscordNotifications;
$this->settings->server_patch_discord_notifications = $this->serverPatchDiscordNotifications;
$this->settings->traefik_outdated_discord_notifications = $this->traefikOutdatedDiscordNotifications;
+ $this->settings->image_vulnerabilities_discord_notifications = $this->imageVulnerabilitiesDiscordNotifications;
$this->settings->discord_ping_enabled = $this->discordPingEnabled;
@@ -135,6 +139,7 @@ class Discord extends Component
$this->serverUnreachableDiscordNotifications = $this->settings->server_unreachable_discord_notifications;
$this->serverPatchDiscordNotifications = $this->settings->server_patch_discord_notifications;
$this->traefikOutdatedDiscordNotifications = $this->settings->traefik_outdated_discord_notifications;
+ $this->imageVulnerabilitiesDiscordNotifications = $this->settings->image_vulnerabilities_discord_notifications;
$this->discordPingEnabled = $this->settings->discord_ping_enabled;
}
diff --git a/app/Livewire/Notifications/Email.php b/app/Livewire/Notifications/Email.php
index 0913e93507..a81d4249b3 100644
--- a/app/Livewire/Notifications/Email.php
+++ b/app/Livewire/Notifications/Email.php
@@ -2,20 +2,26 @@
namespace App\Livewire\Notifications;
+use App\Livewire\Concerns\ManagesSmtpServers;
use App\Livewire\Notifications\Concerns\TogglesNotificationEvents;
use App\Models\EmailNotificationSettings;
use App\Models\Team;
use App\Notifications\Test;
+use App\Rules\SafeSmtpHost;
use App\Rules\ValidHostname;
+use App\Support\SmtpPresets;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\RateLimiter;
+use Illuminate\Validation\Rule;
+use Illuminate\Validation\ValidationException;
+use Livewire\Attributes\Computed;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Validate;
use Livewire\Component;
class Email extends Component
{
- use AuthorizesRequests, TogglesNotificationEvents;
+ use AuthorizesRequests, ManagesSmtpServers, TogglesNotificationEvents;
protected $listeners = ['refresh' => '$refresh'];
@@ -67,6 +73,15 @@ class Email extends Component
#[Validate(['nullable', 'string'])]
public ?string $resendApiKey = null;
+ #[Validate(['nullable', 'email'])]
+ public ?string $resendFromAddress = null;
+
+ #[Validate(['nullable', 'string'])]
+ public ?string $resendFromName = null;
+
+ #[Validate(['required', 'in:none,smtp,resend'])]
+ public string $emailProvider = 'none';
+
#[Validate(['boolean'])]
public bool $useInstanceEmailSettings = false;
@@ -118,6 +133,9 @@ class Email extends Component
#[Validate(['boolean'])]
public bool $traefikOutdatedEmailNotifications = true;
+ #[Validate(['boolean'])]
+ public bool $imageVulnerabilitiesEmailNotifications = true;
+
#[Validate(['nullable', 'email'])]
public ?string $testEmailAddress = null;
@@ -140,20 +158,17 @@ class Email extends Component
if ($toModel) {
$this->validate();
$this->validate(['smtpEhloDomain' => ['nullable', 'string', new ValidHostname]]);
+ if ($this->smtpHost !== $this->settings->smtp_host) {
+ $this->validate(['smtpHost' => ['nullable', 'string', new SafeSmtpHost]]);
+ }
$this->settings->smtp_enabled = $this->smtpEnabled;
- $this->settings->smtp_from_address = $this->smtpFromAddress;
- $this->settings->smtp_from_name = $this->smtpFromName;
$this->settings->smtp_recipients = $this->smtpRecipients;
- $this->settings->smtp_host = $this->smtpHost;
- $this->settings->smtp_port = $this->smtpPort;
- $this->settings->smtp_encryption = $this->smtpEncryption;
- $this->settings->smtp_username = $this->smtpUsername;
- $this->settings->smtp_password = $this->smtpPassword;
- $this->settings->smtp_timeout = $this->smtpTimeout;
- $this->settings->smtp_ehlo_domain = $this->smtpEhloDomain;
+ $this->fillSmtpServerSettings();
$this->settings->resend_enabled = $this->resendEnabled;
$this->settings->resend_api_key = $this->resendApiKey;
+ $this->settings->resend_from_address = $this->resendFromAddress;
+ $this->settings->resend_from_name = $this->resendFromName;
$this->settings->use_instance_email_settings = $this->useInstanceEmailSettings;
@@ -173,9 +188,8 @@ class Email extends Component
$this->settings->server_unreachable_email_notifications = $this->serverUnreachableEmailNotifications;
$this->settings->server_patch_email_notifications = $this->serverPatchEmailNotifications;
$this->settings->traefik_outdated_email_notifications = $this->traefikOutdatedEmailNotifications;
- $changedFields = array_keys($this->settings->getDirty());
- $this->settings->save();
- $this->auditNotificationSettings($changedFields);
+ $this->settings->image_vulnerabilities_email_notifications = $this->imageVulnerabilitiesEmailNotifications;
+ $this->saveEmailSettings();
} else {
$this->smtpEnabled = $this->settings->smtp_enabled;
@@ -193,11 +207,17 @@ class Email extends Component
$this->smtpEhloDomain = $this->settings->smtp_ehlo_domain;
$this->resendEnabled = $this->settings->resend_enabled;
+ $this->resendFromAddress = $this->settings->resend_from_address;
+ $this->resendFromName = $this->settings->resend_from_name;
$this->resendApiKey = auth()->user()->can('update', $this->settings)
? $this->settings->resend_api_key
: null;
+ $this->smtpServerName = $this->settings->smtp_server_name;
+ $this->selectedSmtpServer = 'active';
+
$this->useInstanceEmailSettings = $this->settings->use_instance_email_settings;
+ $this->emailProvider = $this->activeEmailProvider();
$this->deploymentSuccessEmailNotifications = $this->settings->deployment_success_email_notifications;
$this->deploymentFailureEmailNotifications = $this->settings->deployment_failure_email_notifications;
@@ -215,6 +235,7 @@ class Email extends Component
$this->serverUnreachableEmailNotifications = $this->settings->server_unreachable_email_notifications;
$this->serverPatchEmailNotifications = $this->settings->server_patch_email_notifications;
$this->traefikOutdatedEmailNotifications = $this->settings->traefik_outdated_email_notifications;
+ $this->imageVulnerabilitiesEmailNotifications = $this->settings->image_vulnerabilities_email_notifications;
}
}
@@ -222,12 +243,77 @@ class Email extends Component
{
try {
$this->resetErrorBag();
+ if (! $this->useInstanceEmailSettings) {
+ $this->enableSelectedEmailProvider();
+ }
$this->saveModel();
} catch (\Throwable $e) {
+ $this->restoreSavedEmailProvider();
+
return handleError($e, $this);
}
}
+ /**
+ * Turn on the provider selected in the provider list and save. When a required field of the
+ * provider is empty, nothing is saved and the page only shows the fields to fill in.
+ */
+ public function selectEmailProvider()
+ {
+ try {
+ $this->enableSelectedEmailProvider();
+ } catch (ValidationException) {
+ // Not complete yet: show the fields of the provider and the "Not active yet" notice, without an error.
+ $this->resetErrorBag();
+
+ return null;
+ }
+
+ return $this->submit();
+ }
+
+ /**
+ * The provider that sends the team's email now. It differs from $emailProvider until the selected provider is saved.
+ */
+ #[Computed]
+ public function activeEmailProvider(): string
+ {
+ return enabled_email_provider($this->settings) ?? 'none';
+ }
+
+ /**
+ * Turn on the provider selected in the provider list. When its settings are not valid, the saved
+ * provider stays on, so that a later save of another field cannot turn off the working provider.
+ */
+ private function enableSelectedEmailProvider(): void
+ {
+ $this->smtpEnabled = $this->emailProvider === 'smtp';
+ $this->resendEnabled = $this->emailProvider === 'resend';
+
+ try {
+ $this->validateEnabledEmailProvider();
+ } catch (ValidationException $e) {
+ $this->restoreSavedEmailProvider();
+
+ throw $e;
+ }
+ }
+
+ private function restoreSavedEmailProvider(): void
+ {
+ $this->smtpEnabled = (bool) $this->settings->getOriginal('smtp_enabled');
+ $this->resendEnabled = (bool) $this->settings->getOriginal('resend_enabled');
+ }
+
+ private function validateEnabledEmailProvider(): void
+ {
+ match (true) {
+ $this->smtpEnabled => $this->validateSmtpSettings(),
+ $this->resendEnabled => $this->validateResendSettings(),
+ default => null,
+ };
+ }
+
public function saveModel()
{
$this->authorize('update', $this->settings);
@@ -288,7 +374,6 @@ class Email extends Component
} else {
$this->validateSmtpSettings();
$this->smtpEnabled = true;
- $this->resendEnabled = false;
$this->submitSmtp();
}
} catch (\Throwable $e) {
@@ -311,7 +396,6 @@ class Email extends Component
} else {
$this->validateResendSettings();
$this->resendEnabled = true;
- $this->smtpEnabled = false;
$this->submitResend();
}
} catch (\Throwable $e) {
@@ -332,23 +416,13 @@ class Email extends Component
$this->validateSmtpSettings();
if ($this->smtpEnabled) {
- $this->settings->resend_enabled = $this->resendEnabled = false;
+ $this->disableEmailProvidersExcept('smtp');
}
$this->settings->smtp_enabled = $this->smtpEnabled;
- $this->settings->smtp_from_address = $this->smtpFromAddress;
- $this->settings->smtp_from_name = $this->smtpFromName;
- $this->settings->smtp_host = $this->smtpHost;
- $this->settings->smtp_port = $this->smtpPort;
- $this->settings->smtp_encryption = $this->smtpEncryption;
- $this->settings->smtp_username = $this->smtpUsername;
- $this->settings->smtp_password = $this->smtpPassword;
- $this->settings->smtp_timeout = $this->smtpTimeout;
- $this->settings->smtp_ehlo_domain = $this->smtpEhloDomain;
+ $this->fillSmtpServerSettings();
- $changedFields = array_keys($this->settings->getDirty());
- $this->settings->save();
- $this->auditNotificationSettings($changedFields);
+ $this->saveEmailSettings();
$this->dispatch('success', 'SMTP settings updated.');
} catch (\Throwable $e) {
$this->smtpEnabled = false;
@@ -365,34 +439,52 @@ class Email extends Component
$this->resetErrorBag();
$this->validateResendSettings();
if ($this->resendEnabled) {
- $this->settings->smtp_enabled = $this->smtpEnabled = false;
+ $this->disableEmailProvidersExcept('resend');
}
$this->settings->resend_enabled = $this->resendEnabled;
$this->settings->resend_api_key = $this->resendApiKey;
- $this->settings->smtp_from_address = $this->smtpFromAddress;
- $this->settings->smtp_from_name = $this->smtpFromName;
+ $this->settings->resend_from_address = $this->resendFromAddress;
+ $this->settings->resend_from_name = $this->resendFromName;
- $changedFields = array_keys($this->settings->getDirty());
- $this->settings->save();
- $this->auditNotificationSettings($changedFields);
+ $this->saveEmailSettings();
$this->dispatch('success', 'Resend settings updated.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
+ /**
+ * Turn off every email provider except the given one, so only one provider sends email.
+ */
+ private function disableEmailProvidersExcept(string $provider): void
+ {
+ if ($provider !== 'smtp') {
+ $this->settings->smtp_enabled = $this->smtpEnabled = false;
+ }
+ if ($provider !== 'resend') {
+ $this->settings->resend_enabled = $this->resendEnabled = false;
+ }
+ }
+
+ protected function loadEmailSettings(): void
+ {
+ $this->syncData();
+ }
+
private function validateSmtpSettings(): void
{
$this->validate([
'smtpEnabled' => 'boolean',
+ 'smtpServerName' => 'nullable|string|max:255',
'smtpFromAddress' => 'required|email',
'smtpFromName' => 'required|string',
- 'smtpHost' => 'required|string',
+ // A stored host is checked again at send time, so an unchanged host must not block saving other fields.
+ 'smtpHost' => $this->smtpHost === $this->settings->smtp_host ? ['required', 'string'] : ['required', 'string', new SafeSmtpHost],
'smtpPort' => 'required|numeric',
'smtpEncryption' => 'required|string|in:starttls,tls,none',
- 'smtpUsername' => 'nullable|string',
- 'smtpPassword' => 'nullable|string',
+ 'smtpUsername' => [Rule::requiredIf(SmtpPresets::requiresLogin($this->smtpHost)), 'nullable', 'string'],
+ 'smtpPassword' => [Rule::requiredIf(SmtpPresets::requiresLogin($this->smtpHost)), 'nullable', 'required_with:smtpUsername', 'string'],
'smtpTimeout' => 'nullable|numeric',
'smtpEhloDomain' => ['nullable', 'string', new ValidHostname],
], [
@@ -402,6 +494,9 @@ class Email extends Component
'smtpHost.required' => 'SMTP Host is required.',
'smtpPort.required' => 'SMTP Port is required.',
'smtpPort.numeric' => 'SMTP Port must be a number.',
+ 'smtpUsername.required' => 'This provider needs a username.',
+ 'smtpPassword.required' => 'This provider needs a password.',
+ 'smtpPassword.required_with' => 'Enter the password for this username.',
'smtpEncryption.required' => 'Encryption type is required.',
]);
}
@@ -411,13 +506,13 @@ class Email extends Component
$this->validate([
'resendEnabled' => 'boolean',
'resendApiKey' => $this->resendEnabled ? 'required|string' : 'nullable|string',
- 'smtpFromAddress' => 'required|email',
- 'smtpFromName' => 'required|string',
+ 'resendFromAddress' => $this->resendEnabled ? 'required|email' : 'nullable|email',
+ 'resendFromName' => $this->resendEnabled ? 'required|string' : 'nullable|string',
], [
'resendApiKey.required' => 'Resend API Key is required.',
- 'smtpFromAddress.required' => 'From Address is required.',
- 'smtpFromAddress.email' => 'Please enter a valid email address.',
- 'smtpFromName.required' => 'From Name is required.',
+ 'resendFromAddress.required' => 'From Address is required.',
+ 'resendFromAddress.email' => 'Please enter a valid email address.',
+ 'resendFromName.required' => 'From Name is required.',
]);
}
@@ -450,44 +545,20 @@ class Email extends Component
}
}
- public function copyFromInstanceSettings()
- {
- $this->authorize('update', $this->settings);
- $settings = instanceSettings();
- $this->authorize('view', $settings);
- $this->smtpFromAddress = $settings->smtp_from_address;
- $this->smtpFromName = $settings->smtp_from_name;
-
- if ($settings->smtp_enabled) {
- $this->smtpEnabled = true;
- $this->resendEnabled = false;
- }
-
- $this->smtpRecipients = $settings->smtp_recipients;
- $this->smtpHost = $settings->smtp_host;
- $this->smtpPort = $settings->smtp_port;
- $this->smtpEncryption = $settings->smtp_encryption;
- $this->smtpUsername = $settings->smtp_username;
- $this->smtpPassword = $settings->smtp_password;
- $this->smtpTimeout = $settings->smtp_timeout;
- $this->smtpEhloDomain = $settings->smtp_ehlo_domain;
-
- if ($settings->resend_enabled) {
- $this->resendEnabled = true;
- $this->smtpEnabled = false;
- }
- $this->resendApiKey = $settings->resend_api_key;
- $this->saveModel();
-
- }
-
public function render()
{
return view('livewire.notifications.email');
}
- private function auditNotificationSettings(array $changedFields): void
+ /**
+ * Save the settings, record the changed field names (never secret values), and show the saved provider.
+ */
+ protected function saveEmailSettings(): void
{
+ $changedFields = array_keys($this->settings->getDirty());
+ $this->settings->save();
+ $this->emailProvider = $this->activeEmailProvider();
+
if ($changedFields !== []) {
auditLog('ui.notifications.email.updated', [
'team_id' => $this->team->id,
diff --git a/app/Livewire/Notifications/Pushover.php b/app/Livewire/Notifications/Pushover.php
index ee09cc7393..78a1b56f09 100644
--- a/app/Livewire/Notifications/Pushover.php
+++ b/app/Livewire/Notifications/Pushover.php
@@ -77,6 +77,9 @@ class Pushover extends Component
#[Validate(['boolean'])]
public bool $traefikOutdatedPushoverNotifications = true;
+ #[Validate(['boolean'])]
+ public bool $imageVulnerabilitiesPushoverNotifications = false;
+
public function mount()
{
try {
@@ -112,6 +115,7 @@ class Pushover extends Component
$this->settings->server_unreachable_pushover_notifications = $this->serverUnreachablePushoverNotifications;
$this->settings->server_patch_pushover_notifications = $this->serverPatchPushoverNotifications;
$this->settings->traefik_outdated_pushover_notifications = $this->traefikOutdatedPushoverNotifications;
+ $this->settings->image_vulnerabilities_pushover_notifications = $this->imageVulnerabilitiesPushoverNotifications;
$changedFields = array_keys($this->settings->getDirty());
$this->settings->save();
@@ -142,6 +146,7 @@ class Pushover extends Component
$this->serverUnreachablePushoverNotifications = $this->settings->server_unreachable_pushover_notifications;
$this->serverPatchPushoverNotifications = $this->settings->server_patch_pushover_notifications;
$this->traefikOutdatedPushoverNotifications = $this->settings->traefik_outdated_pushover_notifications;
+ $this->imageVulnerabilitiesPushoverNotifications = $this->settings->image_vulnerabilities_pushover_notifications;
}
}
diff --git a/app/Livewire/Notifications/Slack.php b/app/Livewire/Notifications/Slack.php
index 2a6fbfbb55..e5645b3ea1 100644
--- a/app/Livewire/Notifications/Slack.php
+++ b/app/Livewire/Notifications/Slack.php
@@ -75,6 +75,9 @@ class Slack extends Component
#[Validate(['boolean'])]
public bool $traefikOutdatedSlackNotifications = true;
+ #[Validate(['boolean'])]
+ public bool $imageVulnerabilitiesSlackNotifications = false;
+
public function mount()
{
try {
@@ -109,6 +112,7 @@ class Slack extends Component
$this->settings->server_unreachable_slack_notifications = $this->serverUnreachableSlackNotifications;
$this->settings->server_patch_slack_notifications = $this->serverPatchSlackNotifications;
$this->settings->traefik_outdated_slack_notifications = $this->traefikOutdatedSlackNotifications;
+ $this->settings->image_vulnerabilities_slack_notifications = $this->imageVulnerabilitiesSlackNotifications;
$changedFields = array_keys($this->settings->getDirty());
$this->settings->save();
@@ -135,6 +139,7 @@ class Slack extends Component
$this->serverUnreachableSlackNotifications = $this->settings->server_unreachable_slack_notifications;
$this->serverPatchSlackNotifications = $this->settings->server_patch_slack_notifications;
$this->traefikOutdatedSlackNotifications = $this->settings->traefik_outdated_slack_notifications;
+ $this->imageVulnerabilitiesSlackNotifications = $this->settings->image_vulnerabilities_slack_notifications;
}
}
diff --git a/app/Livewire/Notifications/Telegram.php b/app/Livewire/Notifications/Telegram.php
index 5992aac048..1185bdce8e 100644
--- a/app/Livewire/Notifications/Telegram.php
+++ b/app/Livewire/Notifications/Telegram.php
@@ -78,6 +78,9 @@ class Telegram extends Component
#[Validate(['boolean'])]
public bool $traefikOutdatedTelegramNotifications = true;
+ #[Validate(['boolean'])]
+ public bool $imageVulnerabilitiesTelegramNotifications = false;
+
#[Validate(['nullable', 'string'])]
public ?string $telegramNotificationsDeploymentSuccessThreadId = null;
@@ -123,6 +126,9 @@ class Telegram extends Component
#[Validate(['nullable', 'string'])]
public ?string $telegramNotificationsTraefikOutdatedThreadId = null;
+ #[Validate(['nullable', 'string', 'max:255'])]
+ public ?string $telegramNotificationsImageVulnerabilitiesThreadId = null;
+
public function mount()
{
try {
@@ -158,6 +164,7 @@ class Telegram extends Component
$this->settings->server_unreachable_telegram_notifications = $this->serverUnreachableTelegramNotifications;
$this->settings->server_patch_telegram_notifications = $this->serverPatchTelegramNotifications;
$this->settings->traefik_outdated_telegram_notifications = $this->traefikOutdatedTelegramNotifications;
+ $this->settings->image_vulnerabilities_telegram_notifications = $this->imageVulnerabilitiesTelegramNotifications;
$this->settings->telegram_notifications_deployment_success_thread_id = $this->telegramNotificationsDeploymentSuccessThreadId;
$this->settings->telegram_notifications_deployment_failure_thread_id = $this->telegramNotificationsDeploymentFailureThreadId;
@@ -174,6 +181,7 @@ class Telegram extends Component
$this->settings->telegram_notifications_server_unreachable_thread_id = $this->telegramNotificationsServerUnreachableThreadId;
$this->settings->telegram_notifications_server_patch_thread_id = $this->telegramNotificationsServerPatchThreadId;
$this->settings->telegram_notifications_traefik_outdated_thread_id = $this->telegramNotificationsTraefikOutdatedThreadId;
+ $this->settings->telegram_notifications_image_vulnerabilities_thread_id = $this->telegramNotificationsImageVulnerabilitiesThreadId;
$changedFields = array_keys($this->settings->getDirty());
$this->settings->save();
@@ -198,6 +206,7 @@ class Telegram extends Component
$this->telegramNotificationsServerUnreachableThreadId = $this->settings->telegram_notifications_server_unreachable_thread_id;
$this->telegramNotificationsServerPatchThreadId = $this->settings->telegram_notifications_server_patch_thread_id;
$this->telegramNotificationsTraefikOutdatedThreadId = $this->settings->telegram_notifications_traefik_outdated_thread_id;
+ $this->telegramNotificationsImageVulnerabilitiesThreadId = $this->settings->telegram_notifications_image_vulnerabilities_thread_id;
} else {
$this->telegramToken = null;
$this->telegramChatId = null;
@@ -218,6 +227,7 @@ class Telegram extends Component
$this->serverUnreachableTelegramNotifications = $this->settings->server_unreachable_telegram_notifications;
$this->serverPatchTelegramNotifications = $this->settings->server_patch_telegram_notifications;
$this->traefikOutdatedTelegramNotifications = $this->settings->traefik_outdated_telegram_notifications;
+ $this->imageVulnerabilitiesTelegramNotifications = $this->settings->image_vulnerabilities_telegram_notifications;
}
}
diff --git a/app/Livewire/Notifications/Webhook.php b/app/Livewire/Notifications/Webhook.php
index e382ee4394..a2015f7095 100644
--- a/app/Livewire/Notifications/Webhook.php
+++ b/app/Livewire/Notifications/Webhook.php
@@ -70,6 +70,9 @@ class Webhook extends Component
#[Validate(['boolean'])]
public bool $traefikOutdatedWebhookNotifications = true;
+ #[Validate(['boolean'])]
+ public bool $imageVulnerabilitiesWebhookNotifications = false;
+
public function mount()
{
try {
@@ -104,6 +107,7 @@ class Webhook extends Component
$this->settings->server_unreachable_webhook_notifications = $this->serverUnreachableWebhookNotifications;
$this->settings->server_patch_webhook_notifications = $this->serverPatchWebhookNotifications;
$this->settings->traefik_outdated_webhook_notifications = $this->traefikOutdatedWebhookNotifications;
+ $this->settings->image_vulnerabilities_webhook_notifications = $this->imageVulnerabilitiesWebhookNotifications;
$changedFields = array_keys($this->settings->getDirty());
$this->settings->save();
@@ -130,6 +134,7 @@ class Webhook extends Component
$this->serverUnreachableWebhookNotifications = $this->settings->server_unreachable_webhook_notifications;
$this->serverPatchWebhookNotifications = $this->settings->server_patch_webhook_notifications;
$this->traefikOutdatedWebhookNotifications = $this->settings->traefik_outdated_webhook_notifications;
+ $this->imageVulnerabilitiesWebhookNotifications = $this->settings->image_vulnerabilities_webhook_notifications;
}
}
diff --git a/app/Livewire/Profile/Index.php b/app/Livewire/Profile/Index.php
index 825d6a27c5..526b819144 100644
--- a/app/Livewire/Profile/Index.php
+++ b/app/Livewire/Profile/Index.php
@@ -116,13 +116,10 @@ class Index extends Component
}
// For self-hosted, check if email is enabled
- if (! isCloud()) {
- $settings = instanceSettings();
- if (! $settings->smtp_enabled && ! $settings->resend_enabled) {
- $this->dispatch('error', 'Email functionality is not configured. Please contact your administrator.');
+ if (! isCloud() && ! is_transactional_emails_enabled()) {
+ $this->dispatch('error', 'Email functionality is not configured. Please contact your administrator.');
- return;
- }
+ return;
}
$this->validate([
diff --git a/app/Livewire/Project/Application/Deployment/Show.php b/app/Livewire/Project/Application/Deployment/Show.php
index 5227784b6d..e2dee30361 100644
--- a/app/Livewire/Project/Application/Deployment/Show.php
+++ b/app/Livewire/Project/Application/Deployment/Show.php
@@ -2,11 +2,16 @@
namespace App\Livewire\Project\Application\Deployment;
+use App\Enums\ApplicationDeploymentStatus;
+use App\Jobs\ApplicationDeploymentJob;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\EnvironmentVariable;
+use App\Models\StandaloneDocker;
+use App\Services\ImageScan\SecurityCard;
use App\Traits\AuditsApplicationSettings;
use Livewire\Component;
+use Throwable;
class Show extends Component
{
@@ -66,7 +71,7 @@ class Show extends Component
$this->application_deployment_queue = $application_deployment_queue;
$this->horizon_job_status = $this->application_deployment_queue->getHorizonJobStatus();
$this->deployment_uuid = $deploymentUuid;
- $this->is_debug_enabled = auth()->user()->isMember()
+ $this->is_debug_enabled = ! auth()->user()->can('update', $this->application)
? false
: $this->application->settings->is_debug_enabled;
$this->isKeepAliveOn();
@@ -108,11 +113,102 @@ class Show extends Component
$this->saveApplicationSettingsWithAudit($this->application);
$this->is_debug_enabled = $this->application->settings->is_debug_enabled;
$this->application_deployment_queue->refresh();
- } catch (\Throwable $e) {
+ } catch (Throwable $e) {
return handleError($e, $this);
}
}
+ /**
+ * Queue the blocked deployment again for the same commit, with the image scan gate bypassed.
+ */
+ public function deployAnyway()
+ {
+ $this->authorize('update', $this->application);
+
+ try {
+ $deployment = ApplicationDeploymentQueue::query()
+ ->whereKey($this->application_deployment_queue->getKey())
+ ->where('application_id', $this->application->id)
+ ->firstOrFail();
+
+ if (! $deployment->isImageScanBlocked()) {
+ $this->dispatch('error', 'This deployment was not blocked.');
+
+ return;
+ }
+
+ $latestDeploymentId = ApplicationDeploymentQueue::query()
+ ->where('application_id', $this->application->id)
+ ->where('pull_request_id', $deployment->pull_request_id)
+ ->max('id');
+ if ((int) $latestDeploymentId !== (int) $deployment->id) {
+ $this->dispatch('error', 'A newer deployment exists.');
+
+ return;
+ }
+
+ // The same server and destination as the blocked deployment, and the image it blocked.
+ $server = $this->application->deploymentServer($deployment);
+ $destination = $server === null ? null : StandaloneDocker::query()
+ ->whereKey($deployment->destination_id)
+ ->where('server_id', $server->id)
+ ->first();
+ $pinnedImage = data_get($deployment->scan_gate_result, 'repo_digest');
+
+ $deploymentUuid = new_public_id();
+ $result = queue_application_deployment(
+ application: $this->application,
+ deployment_uuid: $deploymentUuid,
+ pull_request_id: $deployment->pull_request_id,
+ commit: $deployment->commit,
+ force_rebuild: false,
+ git_type: $deployment->git_type,
+ server: $server,
+ destination: $destination,
+ only_this_server: (bool) $deployment->only_this_server,
+ docker_registry_image_tag: $deployment->docker_registry_image_tag,
+ scan_gate_bypassed_by: auth()->id(),
+ scan_gate_pinned_image: $this->application->build_pack === 'dockerimage' && is_string($pinnedImage) ? $pinnedImage : null,
+ );
+ if ($result['status'] !== 'queued') {
+ $this->dispatch('error', 'Deploy did not start.', $result['message']);
+
+ return;
+ }
+
+ $this->dispatch('success', 'Deploy started.');
+
+ return redirectRoute($this, 'project.application.deployment.show', [
+ 'project_uuid' => $this->application->environment->project->uuid,
+ 'environment_uuid' => $this->application->environment->uuid,
+ 'application_uuid' => $this->application->uuid,
+ 'deployment_uuid' => $deploymentUuid,
+ ]);
+ } catch (Throwable $e) {
+ return handleError($e, $this);
+ }
+ }
+
+ /**
+ * Gate result of the shown deployment, for the log toolbar. Null when the gate passed or did not run. `action` is
+ * the sentence of the action that fixes the blocked image, from the Security page.
+ *
+ * @return array{result: array, blocked: bool, action: ?string}|null
+ */
+ public function getImageScanGateProperty(): ?array
+ {
+ $deployment = $this->application_deployment_queue;
+ if (! $deployment->isImageScanBlocked() && ! $deployment->isImageScanSkipped()) {
+ return null;
+ }
+
+ return [
+ 'result' => $deployment->scan_gate_result,
+ 'blocked' => $deployment->isImageScanBlocked(),
+ 'action' => $deployment->isImageScanBlocked() ? SecurityCard::gateAction($this->application, $deployment)['sentence'] ?? null : null,
+ ];
+ }
+
public function refreshQueue()
{
$this->application_deployment_queue->refresh();
@@ -140,6 +236,24 @@ class Show extends Component
}
}
+ /**
+ * The Source page URL when a git command of Coolify failed because the Git host sent another SSH host key.
+ * Only failed deployments are checked, so the logs are not searched on each poll of a running deployment.
+ */
+ public function getHostKeyChangedSourceUrlProperty(): ?string
+ {
+ if ($this->application_deployment_queue->status !== ApplicationDeploymentStatus::FAILED->value
+ || ! str_contains((string) $this->application_deployment_queue->logs, ApplicationDeploymentJob::GIT_HOST_KEY_CHANGED_HINT)) {
+ return null;
+ }
+
+ return route('project.application.source', [
+ 'project_uuid' => $this->application->environment->project->uuid,
+ 'environment_uuid' => $this->application->environment->uuid,
+ 'application_uuid' => $this->application->uuid,
+ ]);
+ }
+
public function getLogLinesProperty()
{
return decode_remote_command_output($this->application_deployment_queue);
diff --git a/app/Livewire/Project/Application/DeploymentNavbar.php b/app/Livewire/Project/Application/DeploymentNavbar.php
index f9e79a8c73..5505b77bf9 100644
--- a/app/Livewire/Project/Application/DeploymentNavbar.php
+++ b/app/Livewire/Project/Application/DeploymentNavbar.php
@@ -29,7 +29,7 @@ class DeploymentNavbar extends Component
{
$this->application = Application::ownedByCurrentTeam()->find($this->application_deployment_queue->application_id);
$this->server = $this->application->destination->server;
- $this->is_debug_enabled = auth()->user()->isMember()
+ $this->is_debug_enabled = ! auth()->user()->can('update', $this->application)
? false
: $this->application->settings->is_debug_enabled;
}
diff --git a/app/Livewire/Project/Application/Heading.php b/app/Livewire/Project/Application/Heading.php
index 190e8e19a8..c30f30dbfc 100644
--- a/app/Livewire/Project/Application/Heading.php
+++ b/app/Livewire/Project/Application/Heading.php
@@ -8,8 +8,11 @@ use App\Livewire\Project\Service\Storage;
use App\Livewire\Project\Shared\EnvironmentVariable\All;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
+use App\Services\ImageScan\ImageScanPolicyResolver;
+use App\Services\ImageScan\SecurityCard;
use App\Traits\ListensToTeamChannel;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
+use Livewire\Attributes\Computed;
use Livewire\Attributes\Locked;
use Livewire\Component;
@@ -157,6 +160,44 @@ class Heading extends Component
->value('deployment_url');
}
+ /**
+ * Gate warning for the latest deployment of the application (not a preview) when the image scan
+ * gate blocked it or could not run. Re-evaluated on the heading's existing 10s poll.
+ *
+ * @return array{result: array, action: ?string, vulnerabilities_url: string, deployment_url: string}|null
+ */
+ #[Computed]
+ public function imageScanGate(): ?array
+ {
+ // The gate never runs without both. Scanning off on the server still shows: the gate reports it.
+ if (! ImageScanPolicyResolver::isAllowedFor($this->application)) {
+ return null;
+ }
+
+ $deployment = ApplicationDeploymentQueue::query()
+ ->where('application_id', $this->application->id)
+ ->where('pull_request_id', 0)
+ ->orderByDesc('id')
+ ->first(['id', 'deployment_uuid', 'status', 'scan_gate_result']);
+
+ if (! $deployment || (! $deployment->isImageScanBlocked() && ! $deployment->isImageScanSkipped())) {
+ return null;
+ }
+
+ $parameters = [
+ 'project_uuid' => $this->parameters['project_uuid'],
+ 'environment_uuid' => $this->parameters['environment_uuid'],
+ 'application_uuid' => $this->parameters['application_uuid'],
+ ];
+
+ return [
+ 'result' => $deployment->scan_gate_result,
+ 'action' => $deployment->isImageScanBlocked() ? SecurityCard::gateAction($this->application, $deployment)['sentence'] ?? null : null,
+ 'vulnerabilities_url' => route('project.application.vulnerabilities', $parameters),
+ 'deployment_url' => route('project.application.deployment.show', [...$parameters, 'deployment_uuid' => $deployment->deployment_uuid]),
+ ];
+ }
+
public function force_deploy_without_cache()
{
try {
diff --git a/app/Livewire/Project/Application/Source.php b/app/Livewire/Project/Application/Source.php
index 84dbffbc80..6d9d32d966 100644
--- a/app/Livewire/Project/Application/Source.php
+++ b/app/Livewire/Project/Application/Source.php
@@ -42,6 +42,24 @@ class Source extends Component
#[Locked]
public $sources;
+ #[Validate(['nullable', 'string', 'max:65535'])]
+ public ?string $gitSshKnownHosts = null;
+
+ /**
+ * The saved host keys when the form was loaded. Save writes the host keys only when the user changed
+ * them, so it does not remove keys that a deployment saved after the page opened.
+ */
+ #[Locked]
+ public ?string $loadedGitSshKnownHosts = null;
+
+ /**
+ * Host keys that the Git host sends now, which the user can accept or decline.
+ *
+ * @var list
+ */
+ #[Locked]
+ public array $fetchedHostKeys = [];
+
#[Validate(['nullable', 'string', 'max:255', 'regex:/^[^:\r\n]*$/'])]
public ?string $gitHttpsUsername = null;
@@ -84,12 +102,16 @@ class Source extends Component
{
if ($toModel) {
$this->validate();
- $this->application->fill([
+ $attributes = [
'git_repository' => $this->gitRepository,
'git_branch' => $this->gitBranch,
'git_commit_sha' => $this->gitCommitSha,
'private_key_id' => $this->privateKeyId,
- ]);
+ ];
+ if (gitKnownHostsLines($this->gitSshKnownHosts) !== gitKnownHostsLines($this->loadedGitSshKnownHosts)) {
+ $attributes['git_ssh_known_hosts'] = implode("\n", gitKnownHostsLines($this->gitSshKnownHosts)) ?: null;
+ }
+ $this->application->fill($attributes);
if (filled($this->gitHttpsPassword)) {
$this->application->git_https_username = $this->gitHttpsUsername;
$this->application->git_https_password = $this->gitHttpsPassword;
@@ -109,9 +131,135 @@ class Source extends Component
$this->gitHttpsUsername = $this->application->git_https_username;
$this->gitHttpsPassword = null;
$this->hasGitHttpsPassword = filled($this->application->git_https_password);
+ $this->syncKnownHostsFromModel();
}
}
+ private function syncKnownHostsFromModel(): void
+ {
+ $this->gitSshKnownHosts = $this->application->git_ssh_known_hosts;
+ $this->loadedGitSshKnownHosts = $this->application->git_ssh_known_hosts;
+ }
+
+ /**
+ * The SHA256 fingerprints of the saved host keys, in the format that `ssh-keygen -lf` shows.
+ * The id identifies the line, because two hosts can have the same key.
+ *
+ * @return list
+ */
+ public function knownHostFingerprints(): array
+ {
+ return array_map(
+ fn (string $line) => ['id' => sha1($line), 'published' => false, ...gitKnownHostFingerprint($line)],
+ gitKnownHostsLines($this->application->git_ssh_known_hosts),
+ );
+ }
+
+ /**
+ * The keys that deployments use for a repository on GitHub, GitLab.com, or Bitbucket Cloud (gitProviderHostKeys()).
+ */
+ public function publishedHostKeyFingerprints(): array
+ {
+ $host = strtolower($this->application->gitSshHost()['host'] ?? '');
+ if (! in_array($host, gitPinnedHosts(), true)) {
+ return [];
+ }
+ $lines = array_filter(gitProviderHostKeys(), fn (string $line) => in_array($host, gitKnownHostsLineHosts($line), true));
+
+ return array_map(
+ fn (string $line) => ['id' => 'published-'.sha1($line), 'published' => true, ...gitKnownHostFingerprint($line), 'host' => $host],
+ array_values($lines),
+ );
+ }
+
+ /**
+ * Removes the saved host key line with this id. The next deployment saves the key that the host sends then.
+ */
+ public function removeHostKey(string $id): void
+ {
+ try {
+ $this->authorize('update', $this->application);
+ $lines = array_filter(
+ gitKnownHostsLines($this->application->git_ssh_known_hosts),
+ fn (string $line) => sha1($line) !== $id,
+ );
+ $this->application->update(['git_ssh_known_hosts' => implode("\n", $lines) ?: null]);
+ $this->syncKnownHostsFromModel();
+ $this->dispatch('success', 'Host key removed.');
+ } catch (\Throwable $e) {
+ handleError($e, $this);
+ }
+ }
+
+ /**
+ * @return list
+ */
+ public function fetchedHostKeyFingerprints(): array
+ {
+ return array_map(gitKnownHostFingerprint(...), $this->fetchedHostKeys);
+ }
+
+ public function fetchHostKeys(): void
+ {
+ try {
+ $this->authorize('update', $this->application);
+ $this->fetchedHostKeys = [];
+ $sshHost = $this->application->gitSshHost();
+ $hosts = $this->application->gitSshHostsToScan();
+ if ($sshHost === null && $hosts === []) {
+ $this->dispatch('error', 'The repository does not use SSH.');
+
+ return;
+ }
+ $providerHosts = $sshHost === null ? null : gitProviderHostKeySource($sshHost['host']);
+ if ($providerHosts !== null) {
+ $count = count(fetchGitProviderHostKeys($providerHosts));
+ $this->dispatch('success', "Deployments now use the {$count} host keys that {$sshHost['host']} publishes.");
+ }
+ if ($hosts === []) {
+ return;
+ }
+ $fetchedHostKeys = $this->application->fetchGitSshHostKeys();
+ // A gitlab.com key that is the same as a built-in key needs no accept.
+ $builtInHostKeys = collect(gitProviderHostKeys())->flatMap(function (string $line) {
+ [$hosts, $key] = explode(' ', $line, 2);
+
+ return array_map(fn (string $host) => "{$host} {$key}", explode(',', $hosts));
+ })->all();
+ $this->fetchedHostKeys = array_values(array_diff($fetchedHostKeys, $builtInHostKeys));
+ if ($fetchedHostKeys === []) {
+ $names = implode(', ', array_map(fn (array $host) => "{$host['host']} (port {$host['port']})", $hosts));
+ $this->dispatch('error', "Could not read the host keys of {$names}. To add a key, click Edit manually and paste it.");
+ } elseif ($this->fetchedHostKeys === []) {
+ $this->dispatch('success', 'The host sends the built-in published keys. Nothing to accept.');
+ }
+ } catch (\Throwable $e) {
+ handleError($e, $this);
+ }
+ }
+
+ /**
+ * Replaces the saved keys of the fetched host with the fetched keys.
+ */
+ public function acceptFetchedHostKeys(): void
+ {
+ try {
+ $this->authorize('update', $this->application);
+ $lines = gitKnownHostsReplaceHostKeys($this->application->git_ssh_known_hosts, $this->fetchedHostKeys);
+ $this->application->update(['git_ssh_known_hosts' => implode("\n", $lines) ?: null]);
+ $this->fetchedHostKeys = [];
+ $this->syncKnownHostsFromModel();
+ $this->dispatch('success', 'Host keys saved. Later deployments accept only these keys.');
+ } catch (\Throwable $e) {
+ handleError($e, $this);
+ }
+ }
+
+ public function declineFetchedHostKeys(): void
+ {
+ $this->fetchedHostKeys = [];
+ }
+
/**
* Other keys and sources of the team. Users without write access on the whole team keep the ones the
* application already uses, so they only see those.
@@ -184,12 +332,32 @@ class Source extends Component
if (str($this->gitCommitSha)->isEmpty()) {
$this->gitCommitSha = 'HEAD';
}
+ $knownHostsLines = collect(preg_split('/\R/', (string) $this->gitSshKnownHosts))
+ ->map(fn (string $line) => preg_replace('/\s+/', ' ', trim($line)))
+ ->filter(fn (string $line) => $line !== '');
+ if ($knownHostsLines->contains(fn (string $line) => ! isValidGitKnownHostsLine($line))) {
+ throw ValidationException::withMessages(['gitSshKnownHosts' => 'Each line must be "host key-type key" (for example "[git.example.com]:2222 ssh-ed25519 AAAA..."). Hashed hosts and comments are not supported.']);
+ }
if (filled($this->gitHttpsUsername) && blank($this->gitHttpsPassword) && ! $this->hasGitHttpsPassword) {
throw ValidationException::withMessages(['gitHttpsPassword' => 'Enter the password or token for this username.']);
}
+ $loadedKnownHostsLines = array_map(fn (string $line) => preg_replace('/\s+/', ' ', trim($line)), preg_split('/\R/', (string) $this->loadedGitSshKnownHosts));
+ if ($knownHostsLines->contains(fn (string $line) => gitKnownHostsLineHasHostWithPublishedKeys($line) && ! in_array($line, $loadedKnownHostsLines, true))) {
+ throw ValidationException::withMessages(['gitSshKnownHosts' => 'Coolify always uses the published host keys of '.implode(', ', gitHostsWithPublishedKeys()).'. Remove the lines of these hosts.']);
+ }
$this->syncData(true);
$this->dispatch('success', 'Application source updated!');
$this->dispatch('configurationChanged');
+ } catch (ValidationException $e) {
+ if (array_key_exists('gitSshKnownHosts', $e->errors())) {
+ $this->dispatch('known-hosts-invalid');
+ }
+
+ if (array_key_exists('gitSshKnownHosts', $e->errors())) {
+ throw $e;
+ }
+
+ handleError($e, $this);
} catch (\Throwable $e) {
return handleError($e, $this);
}
diff --git a/app/Livewire/Project/Database/ImportForm.php b/app/Livewire/Project/Database/ImportForm.php
index cae80d0e97..e983ab5948 100644
--- a/app/Livewire/Project/Database/ImportForm.php
+++ b/app/Livewire/Project/Database/ImportForm.php
@@ -575,13 +575,13 @@ class ImportForm extends Component
$this->authorize('update', $this->resource);
if (! $this->s3StorageId) {
- $this->dispatch('error', 'Please select an S3 storage.');
+ $this->dispatch('error', 'Please select a storage.');
return;
}
if (blank($this->s3Path)) {
- $this->dispatch('error', 'Please provide an S3 path.');
+ $this->dispatch('error', 'Please provide a file path.');
return;
}
@@ -591,7 +591,7 @@ class ImportForm extends Component
// Validate the S3 path early to prevent command injection in subsequent operations
if (! $this->validateS3Path($cleanPath)) {
- $this->dispatch('error', 'Invalid S3 path. Path must contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).');
+ $this->dispatch('error', 'Invalid file path. Path must contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).');
return;
}
@@ -606,30 +606,19 @@ class ImportForm extends Component
$s3Storage = S3Storage::ownedByCurrentTeamAPI((int) $this->resourceTeamId())->findOrFail($this->s3StorageId);
// Validate bucket name early
- if (! $this->validateBucketName($s3Storage->bucket)) {
+ if ($s3Storage->isS3() && ! $this->validateBucketName($s3Storage->bucket)) {
$this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only letters, numbers, dots, and dashes, and must follow S3 bucket naming rules.');
return;
}
- // Test connection
+ // Before testConnection(), which saves the host key of an SFTP storage in memory only.
+ $disk = $s3Storage->filesystem();
$s3Storage->testConnection();
- // Build S3 disk configuration
- $disk = Storage::build([
- 'driver' => 's3',
- 'region' => $s3Storage->region,
- 'key' => $s3Storage->key,
- 'secret' => $s3Storage->secret,
- 'bucket' => $s3Storage->bucket,
- 'endpoint' => $s3Storage->endpoint,
- 'use_path_style_endpoint' => $s3Storage->usesPathStyleEndpoint(),
- 'http' => $s3Storage->httpClientOptions(),
- ]);
-
// Check if file exists
if (! $disk->exists($cleanPath)) {
- $this->dispatch('error', 'File not found in S3. Please check the path.');
+ $this->dispatch('error', 'File not found in the storage. Please check the path.');
return;
}
@@ -638,7 +627,7 @@ class ImportForm extends Component
$this->s3FileSize = $disk->size($cleanPath);
$this->selectSqliteDatabaseFor($cleanPath);
- $this->dispatch('success', 'File found in S3. Size: '.formatBytes($this->s3FileSize));
+ $this->dispatch('success', 'File found in the storage. Size: '.formatBytes($this->s3FileSize));
} catch (\Throwable $e) {
$this->s3FileSize = null;
@@ -661,7 +650,7 @@ class ImportForm extends Component
}
if (! $this->s3StorageId || blank($this->s3Path)) {
- $this->dispatch('error', 'Please select S3 storage and provide a path first.');
+ $this->dispatch('error', 'Please select a storage and provide a path first.');
return true;
}
@@ -694,7 +683,7 @@ class ImportForm extends Component
'replace_existing' => $this->replaceExisting,
'storage_id' => $this->s3StorageId,
]);
- $this->dispatch('info', 'Restoring database from S3. Progress will be shown in the activity monitor...');
+ $this->dispatch('info', 'Restoring database from the storage. Progress will be shown in the activity monitor...');
} catch (DatabaseImportException $e) {
$this->importRunning = false;
$this->dispatch('error', $e->getMessage());
diff --git a/app/Livewire/Project/DeleteProject.php b/app/Livewire/Project/DeleteProject.php
index 598e1f61b2..f6767676e5 100644
--- a/app/Livewire/Project/DeleteProject.php
+++ b/app/Livewire/Project/DeleteProject.php
@@ -4,6 +4,7 @@ namespace App\Livewire\Project;
use App\Models\Project;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
+use Livewire\Attributes\Locked;
use Livewire\Component;
class DeleteProject extends Component
@@ -12,6 +13,7 @@ class DeleteProject extends Component
public array $parameters;
+ #[Locked]
public int $project_id;
public bool $disabled = false;
diff --git a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php
index e14a92fe97..3a7c7f377d 100644
--- a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php
+++ b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php
@@ -273,7 +273,7 @@ class GithubPrivateRepositoryDeployKey extends Component
$this->git_repository = $this->repository_url_parsed->getSegment(1).'/'.$this->repository_url_parsed->getSegment(2);
if ($this->git_host === 'github.com') {
- $this->git_source = GithubApp::where('name', 'Public GitHub')->first();
+ $this->git_source = GithubApp::query()->whereKey(0)->where('is_public', true)->first();
return;
}
diff --git a/app/Livewire/Project/New/PublicGitRepository.php b/app/Livewire/Project/New/PublicGitRepository.php
index 601130cbd6..34aa354841 100644
--- a/app/Livewire/Project/New/PublicGitRepository.php
+++ b/app/Livewire/Project/New/PublicGitRepository.php
@@ -229,7 +229,7 @@ class PublicGitRepository extends Component
$this->git_branch = 'main';
}
if ($this->git_host === 'github.com') {
- $this->git_source = GithubApp::where('name', 'Public GitHub')->first();
+ $this->git_source = GithubApp::query()->whereKey(0)->where('is_public', true)->first();
return;
}
diff --git a/app/Livewire/Project/New/Select.php b/app/Livewire/Project/New/Select.php
index dcc6e9adc4..adf1b6cd31 100644
--- a/app/Livewire/Project/New/Select.php
+++ b/app/Livewire/Project/New/Select.php
@@ -190,8 +190,8 @@ class Select extends Component
],
[
'id' => 'private-gitea-app',
- 'name' => 'Git Repository (with Gitea App)',
- 'description' => 'Deploy public or private Gitea repositories through a Gitea App, with automatic webhooks and pull request previews.',
+ 'name' => 'Git Repository (with Gitea / Forgejo App)',
+ 'description' => 'Deploy public or private Gitea, Forgejo, or Codeberg repositories through a Gitea / Forgejo App, with automatic webhooks and pull request previews.',
'logo' => asset('svgs/gitea.svg'),
],
];
diff --git a/app/Livewire/Project/Resource/Create.php b/app/Livewire/Project/Resource/Create.php
index 21906b5889..a0951330bd 100644
--- a/app/Livewire/Project/Resource/Create.php
+++ b/app/Livewire/Project/Resource/Create.php
@@ -4,6 +4,7 @@ namespace App\Livewire\Project\Resource;
use App\Models\Service;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
+use Livewire\Attributes\Locked;
use Livewire\Component;
class Create extends Component
@@ -12,8 +13,21 @@ class Create extends Component
public $type;
+ #[Locked]
public $project;
+ #[Locked]
+ public ?string $pendingType = null;
+
+ #[Locked]
+ public $environment = null;
+
+ #[Locked]
+ public $destination = null;
+
+ #[Locked]
+ public ?string $databaseImage = null;
+
public function mount()
{
$type = str(request()->query('type'));
@@ -35,107 +49,132 @@ class Create extends Component
if (! $destination) {
return redirect()->route('dashboard');
}
- $services = get_service_templates();
- if (in_array($type, DATABASE_TYPES)) {
- if ($type->value() === 'postgresql') {
- // PostgreSQL requires database_image to be explicitly set
- // If not provided, fall through to Select component for version selection
- if (! $database_image) {
- $this->type = $type->value();
+ $isDatabase = in_array($type, DATABASE_TYPES);
+ // PostgreSQL and Redis (Redis or Valkey) require database_image to be explicitly set
+ // If not provided, fall through to Select component for image selection
+ if ($isDatabase && in_array($type->value(), ['postgresql', 'redis'], true) && ! $database_image) {
+ $this->type = $type->value();
- return;
- }
- $database = create_standalone_postgresql(
- environmentId: $environment->id,
- destination: $destination,
- databaseImage: $database_image
- );
- } elseif ($type->value() === 'redis') {
- // Redis requires database_image (Redis or Valkey) to be explicitly set
- if (! $database_image) {
- $this->type = $type->value();
-
- return;
- }
- $database = create_standalone_redis($environment->id, $destination, ['image' => $database_image]);
- } elseif ($type->value() === 'mongodb') {
- $database = create_standalone_mongodb($environment->id, $destination);
- } elseif ($type->value() === 'mysql') {
- $database = create_standalone_mysql($environment->id, $destination);
- } elseif ($type->value() === 'mariadb') {
- $database = create_standalone_mariadb($environment->id, $destination);
- } elseif ($type->value() === 'keydb') {
- $database = create_standalone_keydb($environment->id, $destination);
- } elseif ($type->value() === 'dragonfly') {
- $database = create_standalone_dragonfly($environment->id, $destination);
- } elseif ($type->value() === 'clickhouse') {
- $database = create_standalone_clickhouse($environment->id, $destination);
- } elseif ($type->value() === 'sqlite') {
- $database = create_standalone_sqlite($environment->id, $destination);
- }
-
- return redirect()->route('project.database.configuration', [
- 'project_uuid' => $project->uuid,
- 'environment_uuid' => $environment->uuid,
- 'database_uuid' => $database->uuid,
- ]);
+ return;
}
- if ($type->startsWith('one-click-service-')) {
+ $isOneClickService = false;
+ if (! $isDatabase && $type->startsWith('one-click-service-')) {
+ $services = get_service_templates();
$oneClickServiceName = resolve_service_template_key($type->after('one-click-service-')->value(), $services);
- $oneClickService = data_get($services, "$oneClickServiceName.compose");
- $oneClickDotEnvs = data_get($services, "$oneClickServiceName.envs", null);
- if ($oneClickDotEnvs) {
- $oneClickDotEnvs = str(base64_decode($oneClickDotEnvs))->split('/\r\n|\r|\n/')->filter(function ($value) {
- return ! empty($value);
- });
- }
- if ($oneClickService) {
- $service_payload = [
- 'docker_compose_raw' => base64_decode($oneClickService),
- 'environment_id' => $environment->id,
- 'service_type' => $oneClickServiceName,
- 'server_id' => $destination->server_id,
- 'destination_id' => $destination->id,
- 'destination_type' => $destination->getMorphClass(),
- ];
- if (in_array($oneClickServiceName, NEEDS_TO_CONNECT_TO_PREDEFINED_NETWORK)) {
- data_set($service_payload, 'connect_to_docker_network', true);
- }
- $service = new Service($service_payload);
- $service->save();
- $service->name = "$oneClickServiceName-".$service->uuid;
- $service->save();
- if ($oneClickDotEnvs?->count() > 0) {
- $oneClickDotEnvs->each(function ($value) use ($service) {
- $key = str()->before($value, '=');
- $value = str(str()->after($value, '='));
- if ($value) {
- $service->environment_variables()->updateOrCreate(['key' => $key], [
- 'value' => $value,
- 'resourceable_id' => $service->id,
- 'resourceable_type' => $service->getMorphClass(),
- 'is_preview' => false,
- ]);
- }
- });
- }
- $service->parse(isNew: true);
+ $isOneClickService = filled(data_get($services, "$oneClickServiceName.compose"));
+ }
+ if ($isDatabase || $isOneClickService) {
+ $this->pendingType = $type->value();
+ $this->environment = $environment;
+ $this->destination = $destination;
+ $this->databaseImage = $database_image;
- // Apply service-specific application prerequisites
- applyServiceApplicationPrerequisites($service);
-
- return redirect()->route('project.service.configuration', [
- 'service_uuid' => $service->uuid,
- 'environment_uuid' => $environment->uuid,
- 'project_uuid' => $project->uuid,
- ]);
- }
+ return;
}
$this->type = $type->value();
}
}
+ public function createResource()
+ {
+ if (! $this->pendingType) {
+ return;
+ }
+ $type = str($this->pendingType);
+ $project = $this->project;
+ $environment = $this->environment;
+ $destination = $this->destination;
+ $this->authorize('createAnyResource', [$environment]);
+ abort_unless((int) $environment->project_id === (int) $project->id, 403);
+ abort_unless((int) $destination->server->team_id === (int) $project->team_id, 403);
+ $this->pendingType = null;
+
+ if (in_array($type, DATABASE_TYPES)) {
+ if ($type->value() === 'postgresql') {
+ $database = create_standalone_postgresql(
+ environmentId: $environment->id,
+ destination: $destination,
+ databaseImage: $this->databaseImage
+ );
+ } elseif ($type->value() === 'redis') {
+ $database = create_standalone_redis($environment->id, $destination, ['image' => $this->databaseImage]);
+ } elseif ($type->value() === 'mongodb') {
+ $database = create_standalone_mongodb($environment->id, $destination);
+ } elseif ($type->value() === 'mysql') {
+ $database = create_standalone_mysql($environment->id, $destination);
+ } elseif ($type->value() === 'mariadb') {
+ $database = create_standalone_mariadb($environment->id, $destination);
+ } elseif ($type->value() === 'keydb') {
+ $database = create_standalone_keydb($environment->id, $destination);
+ } elseif ($type->value() === 'dragonfly') {
+ $database = create_standalone_dragonfly($environment->id, $destination);
+ } elseif ($type->value() === 'clickhouse') {
+ $database = create_standalone_clickhouse($environment->id, $destination);
+ } elseif ($type->value() === 'sqlite') {
+ $database = create_standalone_sqlite($environment->id, $destination);
+ }
+
+ return redirect()->route('project.database.configuration', [
+ 'project_uuid' => $project->uuid,
+ 'environment_uuid' => $environment->uuid,
+ 'database_uuid' => $database->uuid,
+ ]);
+ }
+
+ $services = get_service_templates();
+ $oneClickServiceName = resolve_service_template_key($type->after('one-click-service-')->value(), $services);
+ $oneClickService = data_get($services, "$oneClickServiceName.compose");
+ $oneClickDotEnvs = data_get($services, "$oneClickServiceName.envs", null);
+ if ($oneClickDotEnvs) {
+ $oneClickDotEnvs = str(base64_decode($oneClickDotEnvs))->split('/\r\n|\r|\n/')->filter(function ($value) {
+ return ! empty($value);
+ });
+ }
+ if (! $oneClickService) {
+ return redirect()->route('dashboard');
+ }
+ $service_payload = [
+ 'docker_compose_raw' => base64_decode($oneClickService),
+ 'environment_id' => $environment->id,
+ 'service_type' => $oneClickServiceName,
+ 'server_id' => $destination->server_id,
+ 'destination_id' => $destination->id,
+ 'destination_type' => $destination->getMorphClass(),
+ ];
+ if (in_array($oneClickServiceName, NEEDS_TO_CONNECT_TO_PREDEFINED_NETWORK)) {
+ data_set($service_payload, 'connect_to_docker_network', true);
+ }
+ $service = new Service($service_payload);
+ $service->save();
+ $service->name = "$oneClickServiceName-".$service->uuid;
+ $service->save();
+ if ($oneClickDotEnvs?->count() > 0) {
+ $oneClickDotEnvs->each(function ($value) use ($service) {
+ $key = str()->before($value, '=');
+ $value = str(str()->after($value, '='));
+ if ($value) {
+ $service->environment_variables()->updateOrCreate(['key' => $key], [
+ 'value' => $value,
+ 'resourceable_id' => $service->id,
+ 'resourceable_type' => $service->getMorphClass(),
+ 'is_preview' => false,
+ ]);
+ }
+ });
+ }
+ $service->parse(isNew: true);
+
+ // Apply service-specific application prerequisites
+ applyServiceApplicationPrerequisites($service);
+
+ return redirect()->route('project.service.configuration', [
+ 'service_uuid' => $service->uuid,
+ 'environment_uuid' => $environment->uuid,
+ 'project_uuid' => $project->uuid,
+ ]);
+ }
+
public function render()
{
return view('livewire.project.resource.create');
diff --git a/app/Livewire/Project/Resource/Index.php b/app/Livewire/Project/Resource/Index.php
index 90c42c581d..c17c07c8d5 100644
--- a/app/Livewire/Project/Resource/Index.php
+++ b/app/Livewire/Project/Resource/Index.php
@@ -5,6 +5,8 @@ namespace App\Livewire\Project\Resource;
use App\Livewire\Concerns\RemembersCanvasView;
use App\Models\Environment;
use App\Models\Project;
+use App\Services\ImageScan\ResourceScans;
+use App\Services\ImageScan\VulnerabilityMarkers;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Blade;
use Livewire\Component;
@@ -45,6 +47,9 @@ class Index extends Component
protected Collection $services;
+ /** @var array */
+ private array $markers = [];
+
public function mount(): void
{
$this->applications = $this->postgresqls = $this->redis = $this->mongodbs = $this->mysqls = $this->mariadbs = $this->keydbs = $this->dragonflies = $this->clickhouses = $this->sqlites = $this->services = collect();
@@ -168,6 +173,11 @@ class Index extends Component
public function render()
{
+ $this->markers = VulnerabilityMarkers::for(collect([
+ $this->applications, $this->postgresqls, $this->redis, $this->mongodbs, $this->mysqls, $this->mariadbs,
+ $this->keydbs, $this->dragonflies, $this->clickhouses, $this->sqlites, $this->services,
+ ])->flatten(1));
+
return view('livewire.project.resource.index', [
'applications' => $this->applications,
'postgresqls' => $this->postgresqls,
@@ -221,6 +231,7 @@ class Index extends Component
'id' => $tag->id,
'name' => $tag->name,
])->values()->toArray(),
+ 'security' => $this->markers[ResourceScans::key($item)] ?? null,
])->values()->toArray();
}
}
diff --git a/app/Livewire/Project/Service/BackupExecutions.php b/app/Livewire/Project/Service/BackupExecutions.php
index 2fcc321703..b563b629ea 100644
--- a/app/Livewire/Project/Service/BackupExecutions.php
+++ b/app/Livewire/Project/Service/BackupExecutions.php
@@ -195,7 +195,7 @@ class BackupExecutions extends Component
$schedule = $isDatabase ? $execution->scheduledDatabaseBackup : $execution->scheduledVolumeBackup;
$s3Storages = $execution->s3Replicas
->map(fn ($replica) => $replica->s3?->team_id === $serviceTeamId
- ? $replica->s3->name.' (bucket: '.$replica->s3->bucket.')'
+ ? $replica->s3->name.' ('.($replica->s3->isS3() ? 'bucket: '.$replica->s3->bucket : $replica->s3->type->label()).')'
: 'Unavailable')
->implode(', ');
diff --git a/app/Livewire/Project/Service/EditCompose.php b/app/Livewire/Project/Service/EditCompose.php
index a8652c8509..6be7430e30 100644
--- a/app/Livewire/Project/Service/EditCompose.php
+++ b/app/Livewire/Project/Service/EditCompose.php
@@ -4,6 +4,7 @@ namespace App\Livewire\Project\Service;
use App\Models\Service;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
+use Livewire\Attributes\Locked;
use Livewire\Component;
class EditCompose extends Component
@@ -12,6 +13,7 @@ class EditCompose extends Component
public Service $service;
+ #[Locked]
public $serviceId;
public ?string $dockerComposeRaw = null;
@@ -40,7 +42,7 @@ class EditCompose extends Component
public function refreshEnvs()
{
- $this->service = Service::ownedByCurrentTeam()->find($this->serviceId);
+ $this->service->refresh();
$this->syncData(false);
}
@@ -65,11 +67,16 @@ class EditCompose extends Component
public function validateCompose()
{
- $isValid = validateComposeFile($this->dockerComposeRaw, $this->service->server_id);
- if ($isValid !== 'OK') {
- $this->dispatch('error', "Invalid docker-compose file.\n$isValid");
- } else {
- $this->dispatch('success', 'Docker compose is valid.');
+ try {
+ $this->authorize('update', $this->service);
+ $isValid = validateComposeFile($this->dockerComposeRaw, $this->service->server_id);
+ if ($isValid !== 'OK') {
+ $this->dispatch('error', "Invalid docker-compose file.\n$isValid");
+ } else {
+ $this->dispatch('success', 'Docker compose is valid.');
+ }
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
}
}
diff --git a/app/Livewire/Project/Shared/Danger.php b/app/Livewire/Project/Shared/Danger.php
index 14b2881cf3..d2fb1e92a3 100644
--- a/app/Livewire/Project/Shared/Danger.php
+++ b/app/Livewire/Project/Shared/Danger.php
@@ -123,7 +123,7 @@ class Danger extends Component
$this->delete_connected_networks,
$this->delete_configurations,
$this->docker_cleanup,
- )->afterResponse();
+ );
return redirectRoute($this, 'project.resource.index', [
'project_uuid' => $this->projectUuid,
diff --git a/app/Livewire/Project/Shared/Destination.php b/app/Livewire/Project/Shared/Destination.php
index 570973e64d..0b6ee1d5e1 100644
--- a/app/Livewire/Project/Shared/Destination.php
+++ b/app/Livewire/Project/Shared/Destination.php
@@ -46,11 +46,7 @@ class Destination extends Component
$all_networks = $all_networks->push($this->resource->destination);
$all_networks = $all_networks->merge($this->resource->additional_networks);
- $this->networks = $this->resourceServers()
- ->whereRelation('settings', 'is_reachable', true)
- ->whereRelation('settings', 'is_usable', true)
- ->whereRelation('settings', 'is_swarm_worker', false)
- ->whereRelation('settings', 'force_disabled', false)
+ $this->networks = $this->usableResourceServers()
->get()
->filter(fn (Server $server) => $server->canHostResources())
->map(function ($server) {
@@ -77,11 +73,44 @@ class Destination extends Component
->orderBy('name');
}
+ private function usableResourceServers(): Builder
+ {
+ return $this->resourceServers()
+ ->whereRelation('settings', 'is_reachable', true)
+ ->whereRelation('settings', 'is_usable', true)
+ ->whereRelation('settings', 'is_swarm_worker', false)
+ ->whereRelation('settings', 'force_disabled', false);
+ }
+
+ /**
+ * Find a server of the resource team that can take a deployment.
+ * Shows an error toast and returns null when the server is not usable right now.
+ */
+ private function findUsableServer(int $serverId, string $title): ?Server
+ {
+ $server = $this->resourceServers()->findOrFail($serverId);
+ $settings = $server->settings;
+
+ if (! $settings->is_reachable || ! $settings->is_usable) {
+ $this->dispatch('error', $title, "The server {$server->name} is not reachable. Check the connection on the server page and try again.");
+
+ return null;
+ }
+
+ if ($settings->force_disabled || $settings->is_swarm_worker || ! $server->canHostResources()) {
+ $this->dispatch('error', $title, "The server {$server->name} cannot host resources.");
+
+ return null;
+ }
+
+ return $server;
+ }
+
public function stop($serverId)
{
try {
$this->authorize('deploy', $this->resource);
- $server = $this->resourceServers()->findOrFail($serverId);
+ $server = $this->resourceServers()->findOrFail((int) $serverId);
StopApplicationOneServer::run($this->resource, $server);
auditLog('ui.application.destination_stopped', [
'team_id' => $this->resource->team()?->id,
@@ -105,8 +134,11 @@ class Destination extends Component
return;
}
+ $server = $this->findUsableServer($server_id, 'Failed to deploy.');
+ if (! $server) {
+ return;
+ }
$deployment_uuid = new_public_id();
- $server = $this->resourceServers()->findOrFail($server_id);
$destination = $server->standaloneDockers->where('id', $network_id)->firstOrFail();
$result = queue_application_deployment(
deployment_uuid: $deployment_uuid,
@@ -141,9 +173,15 @@ class Destination extends Component
public function promote(int $network_id, int $server_id)
{
try {
- $server = $this->resourceServers()->findOrFail($server_id);
- $network = StandaloneDocker::query()->where('server_id', $server->id)->findOrFail($network_id);
$this->authorize('update', $this->resource);
+ $server = $this->findUsableServer($server_id, 'Failed to promote.');
+ if (! $server) {
+ return;
+ }
+ $network = StandaloneDocker::query()->where('server_id', $server->id)->findOrFail($network_id);
+ if (! $this->resource->additional_networks()->wherePivot('server_id', $server->id)->whereKey($network->id)->exists()) {
+ abort(404);
+ }
$this->resource->getConnection()->transaction(function () use ($network, $server) {
$mainDestination = $this->resource->destination;
$this->resource->update([
@@ -172,9 +210,12 @@ class Destination extends Component
public function addServer(int $network_id, int $server_id)
{
try {
- $server = $this->resourceServers()->findOrFail($server_id);
- $network = StandaloneDocker::query()->where('server_id', $server->id)->findOrFail($network_id);
$this->authorize('update', $this->resource);
+ $server = $this->findUsableServer($server_id, 'Failed to add server.');
+ if (! $server) {
+ return;
+ }
+ $network = StandaloneDocker::query()->where('server_id', $server->id)->findOrFail($network_id);
$reason = $this->resource instanceof Application
? $this->resource->additionalServersUnavailableReason($server)
: 'Only applications can use multiple servers.';
diff --git a/app/Livewire/Project/Shared/ExecuteContainerCommand.php b/app/Livewire/Project/Shared/ExecuteContainerCommand.php
index a3ddb9dc2e..c2abcdb14d 100644
--- a/app/Livewire/Project/Shared/ExecuteContainerCommand.php
+++ b/app/Livewire/Project/Shared/ExecuteContainerCommand.php
@@ -8,6 +8,7 @@ use App\Models\Service;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
+use Livewire\Attributes\Locked;
use Livewire\Attributes\On;
use Livewire\Component;
@@ -17,6 +18,7 @@ class ExecuteContainerCommand extends Component
public $selected_container = 'default';
+ #[Locked]
public Collection $containers;
public $parameters;
diff --git a/app/Livewire/Project/Shared/Logs.php b/app/Livewire/Project/Shared/Logs.php
index 1add0bb6a0..9097e732f9 100644
--- a/app/Livewire/Project/Shared/Logs.php
+++ b/app/Livewire/Project/Shared/Logs.php
@@ -16,6 +16,7 @@ use App\Models\StandaloneSqlite;
use App\Support\RuntimeLogTargets;
use App\Traits\ListensToTeamChannel;
use Illuminate\Support\Collection;
+use Livewire\Attributes\Locked;
use Livewire\Attributes\Url;
use Livewire\Component;
@@ -31,6 +32,7 @@ class Logs extends Component
public Collection $containers;
+ #[Locked]
public array $serverContainers = [];
/** @var array */
diff --git a/app/Livewire/Project/Shared/Terminal.php b/app/Livewire/Project/Shared/Terminal.php
index daa6754480..e1f175ddc7 100644
--- a/app/Livewire/Project/Shared/Terminal.php
+++ b/app/Livewire/Project/Shared/Terminal.php
@@ -86,6 +86,8 @@ class Terminal extends Component
/**
* Resolve the effective terminal mode. Falls back to shell whenever attach is not available.
+ *
+ * @return 'shell'|'attach'
*/
public function resolveMode(?string $requestedMode): string
{
@@ -114,7 +116,7 @@ class Terminal extends Component
return;
}
- if (! $server->isTerminalEnabled() || $server->isForceDisabled()) {
+ if (! $server->isTerminalEnabled() || $server->isForceDisabled() || $server->isTransferredAway()) {
$this->failTerminalSession('Terminal access is disabled on this server.');
return;
@@ -159,7 +161,7 @@ class Terminal extends Component
$mode = $this->terminalMode = 'shell';
}
- $token = app(TerminalSessionService::class)->issue(auth()->user(), $server, $isContainer ? $identifier : null, $mode);
+ $token = app(TerminalSessionService::class)->issue(auth()->user(), $server, $isContainer ? $identifier : null, $mode, client_ip(request()), request()->userAgent());
$this->dispatch('send-terminal-token', $token);
}
diff --git a/app/Livewire/Security/PrivateKey/Create.php b/app/Livewire/Security/PrivateKey/Create.php
index 8be1011d53..d012c94f9f 100644
--- a/app/Livewire/Security/PrivateKey/Create.php
+++ b/app/Livewire/Security/PrivateKey/Create.php
@@ -71,7 +71,7 @@ class Create extends Component
return;
}
- return $this->redirectAfterCreation($privateKey);
+ return $this->redirectAfterCreation();
} catch (\Throwable $e) {
return handleError($e, $this);
}
@@ -79,18 +79,18 @@ class Create extends Component
private function validatePrivateKey()
{
- $validationResult = PrivateKey::validateAndExtractPublicKey($this->value);
- $this->publicKey = $validationResult['publicKey'];
+ $error = PrivateKey::validationError($this->value);
+ $this->publicKey = $error ? '' : PrivateKey::extractPublicKeyFromPrivate($this->value);
- if (! $validationResult['isValid']) {
- $this->addError('value', 'Invalid private key');
+ if ($error) {
+ $this->addError('value', $error);
}
}
- private function redirectAfterCreation(PrivateKey $privateKey)
+ private function redirectAfterCreation()
{
return $this->from === 'server'
? redirectRoute($this, 'dashboard')
- : redirectRoute($this, 'security.private-key.show', ['private_key_uuid' => $privateKey->uuid]);
+ : redirectRoute($this, 'security.private-key.index');
}
}
diff --git a/app/Livewire/Security/PrivateKey/Rotate.php b/app/Livewire/Security/PrivateKey/Rotate.php
new file mode 100644
index 0000000000..9a70ecca8b
--- /dev/null
+++ b/app/Livewire/Security/PrivateKey/Rotate.php
@@ -0,0 +1,131 @@
+privateKey = PrivateKey::ownedByCurrentTeam()->findOrFail($privateKeyId);
+ $this->authorize('update', $this->privateKey);
+ }
+
+ public function rotate(): void
+ {
+ $this->validate(['keyType' => 'required|in:ed25519,rsa']);
+ try {
+ $this->authorize('update', $this->privateKey);
+ if (! $this->servers()->exists()) {
+ $this->dispatch('error', 'No server uses this key.');
+
+ return;
+ }
+
+ $state = Cache::get(RotatePrivateKeyJob::cacheKey($this->privateKey), []);
+ if (data_get($state, 'status') === 'running' && now()->subHour()->lt(data_get($state, 'started_at'))) {
+ $this->dispatch('error', 'A rotation of this key is already running.');
+
+ return;
+ }
+
+ // A second run reuses the new key of the previous run, so servers that already moved stay on it.
+ $newKey = PrivateKey::where('team_id', $this->privateKey->team_id)->find(data_get($state, 'new_private_key_id'))
+ ?? $this->createNewKey();
+
+ Cache::put(RotatePrivateKeyJob::cacheKey($this->privateKey), [
+ 'status' => 'running',
+ 'started_at' => now()->toIso8601String(),
+ 'new_private_key_id' => $newKey->id,
+ 'servers' => [],
+ ], RotatePrivateKeyJob::RESULT_TTL_SECONDS);
+ RotatePrivateKeyJob::dispatch($this->privateKey->id, $newKey->id, auth()->id());
+
+ auditLog('ui.private_key.rotation_started', [
+ 'team_id' => $this->privateKey->team_id,
+ 'private_key_uuid' => $this->privateKey->uuid,
+ 'private_key_name' => $this->privateKey->name,
+ 'new_private_key_uuid' => $newKey->uuid,
+ ]);
+ $this->wasRunning = true;
+ $this->dispatch('success', 'Key rotation started.');
+ } catch (\Throwable $e) {
+ handleError($e, $this);
+ }
+ }
+
+ private function createNewKey(): PrivateKey
+ {
+ $keyPair = $this->keyType === 'rsa'
+ ? PrivateKey::generateNewKeyPair('rsa', 4096)
+ : PrivateKey::generateNewKeyPair('ed25519');
+ $baseName = preg_replace('/( \(rotated \d{4}-\d{2}-\d{2}\))+$/', '', $this->privateKey->name);
+
+ return PrivateKey::createAndStore([
+ 'name' => Str::limit($baseName, 200, '').' (rotated '.now()->format('Y-m-d').')',
+ 'description' => $this->privateKey->description,
+ 'private_key' => $keyPair['private_key'],
+ 'team_id' => $this->privateKey->team_id,
+ ]);
+ }
+
+ private function servers()
+ {
+ return Server::where('team_id', $this->privateKey->team_id)->where('private_key_id', $this->privateKey->id);
+ }
+
+ public function render()
+ {
+ $state = Cache::get(RotatePrivateKeyJob::cacheKey($this->privateKey));
+ $newKey = $state ? PrivateKey::where('team_id', $this->privateKey->team_id)->find(data_get($state, 'new_private_key_id')) : null;
+ $servers = $this->servers()->with('settings')->get();
+
+ // One row per server: the servers that still use this key, and the servers of the last rotation.
+ $rows = collect(data_get($state, 'servers', []))
+ ->map(fn (array $result) => [...$result, 'reachable' => true]);
+ foreach ($servers as $server) {
+ $rows[$server->uuid] ??= [
+ 'name' => $server->name,
+ 'status' => null,
+ 'reachable' => (bool) data_get($server, 'settings.is_reachable'),
+ 'transferred' => $server->isTransferredAway(),
+ ];
+ }
+
+ $isRunning = data_get($state, 'status') === 'running';
+ if ($this->wasRunning && ! $isRunning) {
+ // The servers moved to the new key, so the "Used by" list of the key changed.
+ $this->dispatch('privateKeyRotated')->to(Show::class);
+ }
+ $this->wasRunning = $isRunning;
+
+ return view('livewire.security.private-key.rotate', [
+ 'rows' => $rows,
+ 'hasServers' => $servers->isNotEmpty(),
+ 'usedByOthers' => $this->privateKey->applications()->exists() || $this->privateKey->githubApps()->exists()
+ || $this->privateKey->gitlabApps()->exists() || $this->privateKey->giteaApps()->exists(),
+ 'state' => $state,
+ 'newKey' => $newKey,
+ 'isRunning' => $isRunning,
+ ]);
+ }
+}
diff --git a/app/Livewire/Security/PrivateKey/Show.php b/app/Livewire/Security/PrivateKey/Show.php
index 1c9277d116..417e52cbc9 100644
--- a/app/Livewire/Security/PrivateKey/Show.php
+++ b/app/Livewire/Security/PrivateKey/Show.php
@@ -2,10 +2,17 @@
namespace App\Livewire\Security\PrivateKey;
+use App\Models\Application;
+use App\Models\GiteaApp;
+use App\Models\GithubApp;
+use App\Models\GitlabApp;
use App\Models\PrivateKey;
+use App\Models\Server;
use App\Support\ValidationPatterns;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
+use Illuminate\Support\Collection;
+use Illuminate\Support\Facades\RateLimiter;
use Livewire\Component;
class Show extends Component
@@ -14,14 +21,17 @@ class Show extends Component
public PrivateKey $private_key;
- public bool $modalMode = false;
-
// Explicit properties
public string $name;
public ?string $description = null;
- public string $privateKeyValue;
+ /**
+ * A new private key that replaces the stored key. The stored key is never sent to the browser.
+ */
+ public string $newPrivateKey = '';
+
+ public ?string $fingerprint = null;
public bool $isGitRelated = false;
@@ -36,7 +46,7 @@ class Show extends Component
return [
'name' => ValidationPatterns::nameRules(),
'description' => ValidationPatterns::descriptionRules(),
- 'privateKeyValue' => 'required|string',
+ 'newPrivateKey' => 'nullable|string',
'isGitRelated' => 'nullable|boolean',
];
}
@@ -47,8 +57,7 @@ class Show extends Component
ValidationPatterns::combinedMessages(),
[
'name.required' => 'The Name field is required.',
- 'privateKeyValue.required' => 'The Private Key field is required.',
- 'privateKeyValue.string' => 'The Private Key must be a valid string.',
+ 'newPrivateKey.string' => 'The Private Key must be a valid string.',
]
);
}
@@ -56,7 +65,7 @@ class Show extends Component
protected $validationAttributes = [
'name' => 'name',
'description' => 'description',
- 'privateKeyValue' => 'private key',
+ 'newPrivateKey' => 'private key',
];
/**
@@ -69,32 +78,38 @@ class Show extends Component
if ($toModel) {
// Sync TO model (before save)
$this->private_key->name = $this->name;
- $this->private_key->description = $this->description;
- $this->private_key->private_key = $this->privateKeyValue;
+ $this->private_key->description = filled($this->description) ? $this->description : null;
+ if (filled(trim($this->newPrivateKey))) {
+ $this->private_key->private_key = $this->newPrivateKey;
+ }
$this->private_key->is_git_related = $this->isGitRelated;
} else {
// Sync FROM model (on load/refresh)
$this->name = $this->private_key->name;
$this->description = $this->private_key->description;
- $this->privateKeyValue = auth()->user()->can('update', $this->private_key)
- ? $this->private_key->private_key
- : '';
+ $this->newPrivateKey = '';
+ $this->fingerprint = $this->private_key->fingerprint;
$this->isGitRelated = $this->private_key->is_git_related;
+ $this->public_key = $this->private_key->getPublicKey();
}
}
- public function mount(?string $private_key_uuid = null, bool $modalMode = false)
+ /** @return array */
+ protected function getListeners(): array
+ {
+ return ['privateKeyRotated' => '$refresh'];
+ }
+
+ public function mount(string $private_key_uuid)
{
- $this->modalMode = $modalMode;
try {
- $this->private_key = PrivateKey::ownedByCurrentTeam(['name', 'description', 'private_key', 'is_git_related', 'team_id'])->whereUuid($private_key_uuid ?? request()->private_key_uuid)->firstOrFail();
+ $this->private_key = PrivateKey::ownedByCurrentTeam(['name', 'description', 'private_key', 'is_git_related', 'team_id', 'fingerprint'])->whereUuid($private_key_uuid)->firstOrFail();
// Explicit authorization check - will throw 403 if not authorized
$this->authorize('view', $this->private_key);
$this->syncData(false);
$this->isInUse = $this->private_key->isInUse();
- $this->public_key = $this->private_key->getPublicKey();
} catch (AuthorizationException $e) {
abort(403, 'You do not have permission to view this private key.');
} catch (\Throwable) {
@@ -115,15 +130,8 @@ class Show extends Component
}
$this->private_key->delete();
- currentTeam()->privateKeys = PrivateKey::where('team_id', currentTeam()->id)->get();
- if ($this->modalMode) {
- $this->dispatch('privateKeyDeleted');
-
- return null;
- }
-
- return redirectRoute($this, 'security.private-key.index');
+ $this->dispatch('privateKeyDeleted');
} catch (\Exception $e) {
$this->dispatch('error', $e->getMessage());
} catch (\Throwable $e) {
@@ -139,18 +147,125 @@ class Show extends Component
$this->validate();
$this->syncData(true);
- $this->private_key->updatePrivateKey([
- 'private_key' => formatPrivateKey($this->private_key->private_key),
- ]);
+ $this->private_key->save();
+ $this->syncData(false);
$this->dispatch('success', 'Private key updated.');
- if ($this->modalMode) {
- $this->dispatch('privateKeyUpdated');
-
- return null;
- }
- $this->dispatch('securityResourceChanged');
+ $this->dispatch('privateKeyUpdated');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
+
+ /** Reveal attempts per user and minute, so a session cannot guess the password without limit. */
+ private const REVEAL_ATTEMPTS_PER_MINUTE = 5;
+
+ /**
+ * Return the stored private key once, after a password confirmation.
+ * The value goes back to the browser only as the action result, not as component state.
+ */
+ public function revealPrivateKey(mixed $password): ?string
+ {
+ try {
+ $this->authorize('update', $this->private_key);
+
+ $rateLimitKey = 'private-key-reveal:'.auth()->id();
+ if (RateLimiter::tooManyAttempts($rateLimitKey, self::REVEAL_ATTEMPTS_PER_MINUTE)) {
+ $this->dispatch('error', 'Too many attempts. Try again in '.RateLimiter::availableIn($rateLimitKey).' seconds.');
+
+ return null;
+ }
+ RateLimiter::hit($rateLimitKey);
+
+ $auditContext = [
+ 'team_id' => $this->private_key->team_id,
+ 'private_key_uuid' => $this->private_key->uuid,
+ 'private_key_name' => $this->private_key->name,
+ ];
+
+ if (! verifyPasswordConfirmation($password, $this)) {
+ auditLog('ui.private_key.reveal_failed', $auditContext, 'warning');
+ $this->dispatch('error', 'The provided password is incorrect.');
+
+ return null;
+ }
+
+ $this->resetErrorBag('password');
+ auditLog('ui.private_key.revealed', $auditContext);
+
+ return $this->private_key->private_key;
+ } catch (\Throwable $e) {
+ handleError($e, $this);
+
+ return null;
+ }
+ }
+
+ /** Rows shown in the "Used by" list. The counts always cover everything. */
+ private const USAGE_LIMIT = 20;
+
+ public string $usageSearch = '';
+
+ /**
+ * What of the key's team uses this key: a count per type, and the first rows that match the search, with a link.
+ *
+ * @return array{counts: array, matches: int, rows: Collection}
+ */
+ private function usages(): array
+ {
+ $keyId = $this->private_key->id;
+ $teamId = $this->private_key->team_id;
+
+ $types = [
+ 'Server' => [
+ Server::where('team_id', $teamId)->where('private_key_id', $keyId),
+ fn (Server $server) => route('server.show', ['server_uuid' => $server->uuid]),
+ ],
+ 'Application' => [
+ Application::where('private_key_id', $keyId)
+ ->whereHas('environment.project', fn ($query) => $query->where('team_id', $teamId))
+ ->with('environment.project'),
+ fn (Application $application) => $application->link(),
+ ],
+ 'GitHub App' => [
+ GithubApp::where('team_id', $teamId)->where('private_key_id', $keyId),
+ fn (GithubApp $app) => route('source.github.show', ['github_app_uuid' => $app->uuid]),
+ ],
+ 'GitLab App' => [
+ GitlabApp::where('team_id', $teamId)->where('private_key_id', $keyId),
+ fn (GitlabApp $app) => route('source.gitlab.show', ['gitlab_app_uuid' => $app->uuid]),
+ ],
+ 'Gitea App' => [
+ GiteaApp::where('team_id', $teamId)->where('private_key_id', $keyId),
+ fn (GiteaApp $app) => route('source.gitea.show', ['gitea_app_uuid' => $app->uuid]),
+ ],
+ ];
+
+ $search = trim($this->usageSearch);
+ $pattern = '%'.addcslashes(mb_strtolower($search), '%_\\').'%';
+ $counts = [];
+ $matches = 0;
+ $rows = collect();
+ foreach ($types as $type => [$query, $url]) {
+ $counts[$type] = (clone $query)->count();
+ if ($search !== '') {
+ $query->whereRaw("LOWER(name) LIKE ? ESCAPE '\\'", [$pattern]);
+ }
+ $typeMatches = $search === '' ? $counts[$type] : (clone $query)->count();
+ $matches += $typeMatches;
+ $missing = self::USAGE_LIMIT - $rows->count();
+ if ($typeMatches > 0 && $missing > 0) {
+ $rows = $rows->concat($query->orderBy('name')->limit($missing)->get()
+ ->map(fn ($model) => ['type' => $type, 'name' => $model->name, 'url' => $url($model)]));
+ }
+ }
+
+ return ['counts' => array_filter($counts), 'matches' => $matches, 'rows' => $rows->values()];
+ }
+
+ public function render()
+ {
+ return view('livewire.security.private-key.show', [
+ ...$this->usages(),
+ ]);
+ }
}
diff --git a/app/Livewire/Server/CloudProviderToken/Show.php b/app/Livewire/Server/CloudProviderToken/Show.php
index 9d27340013..13112c3805 100644
--- a/app/Livewire/Server/CloudProviderToken/Show.php
+++ b/app/Livewire/Server/CloudProviderToken/Show.php
@@ -186,6 +186,7 @@ class Show extends Component
public function validateToken()
{
try {
+ $this->authorize('update', $this->server);
$token = $this->server->cloudProviderToken;
if (! $token) {
$this->dispatch('error', "No {$this->providerName} token is associated with this server.");
diff --git a/app/Livewire/Server/Delete.php b/app/Livewire/Server/Delete.php
index 1a224b676f..1a10e4cc61 100644
--- a/app/Livewire/Server/Delete.php
+++ b/app/Livewire/Server/Delete.php
@@ -2,6 +2,7 @@
namespace App\Livewire\Server;
+use App\Actions\Server\AuthorizedKeys;
use App\Actions\Server\DeleteServer;
use App\Jobs\DeleteResourceJob;
use App\Models\Server;
@@ -25,6 +26,8 @@ class Delete extends Component
public bool $force_delete_resources = false;
+ public bool $remove_ssh_key = false;
+
public function mount(string $server_uuid)
{
try {
@@ -46,6 +49,7 @@ class Delete extends Component
$this->delete_from_digitalocean = in_array('delete_from_digitalocean', $selectedActions);
$this->delete_from_hostinger = in_array('delete_from_hostinger', $selectedActions);
$this->force_delete_resources = in_array('force_delete_resources', $selectedActions);
+ $this->remove_ssh_key = in_array('remove_ssh_key', $selectedActions);
}
// Another Coolify instance manages a transferred server. This instance deletes only its own records:
// never the VM, and never the containers that the other instance runs.
@@ -56,6 +60,7 @@ class Delete extends Component
$this->delete_from_digitalocean = false;
$this->delete_from_hostinger = false;
$this->force_delete_resources = true;
+ $this->remove_ssh_key = false;
}
try {
$this->authorize('delete', $this->server);
@@ -71,6 +76,10 @@ class Delete extends Component
return;
}
+ if ($this->remove_ssh_key && ! $this->deletesCloudServer() && ! $this->removeSshKey()) {
+ return;
+ }
+
if ($isArchive) {
app(ServerTransferClaimer::class)->forgetDnsRecords($this->server);
}
@@ -104,6 +113,42 @@ class Delete extends Component
}
}
+ private function deletesCloudServer(): bool
+ {
+ return $this->delete_from_hetzner || $this->delete_from_vultr || $this->delete_from_digitalocean || $this->delete_from_hostinger;
+ }
+
+ /**
+ * The key is removed before the server record, while the connection still works.
+ * Resources must be deleted first, because their delete jobs need the connection.
+ */
+ private function removeSshKey(): bool
+ {
+ if ($this->server->definedResources()->isNotEmpty()) {
+ $this->dispatch('error', 'Delete the resources of the server first, then delete the server and remove the SSH key.');
+
+ return false;
+ }
+
+ $context = [
+ 'team_id' => $this->server->team_id,
+ 'server_uuid' => $this->server->uuid,
+ 'server_name' => $this->server->name,
+ 'private_key_uuid' => $this->server->privateKey?->uuid,
+ ];
+ try {
+ $removed = AuthorizedKeys::remove($this->server, $this->server->privateKey);
+ } catch (\Throwable $e) {
+ auditLog('ui.server.ssh_key_removed', [...$context, 'result' => 'failed'], 'warning');
+ $this->dispatch('error', 'The SSH key was not removed from the server, so the server was not deleted. Clear the option to delete the server without removing the key. Error: '.htmlspecialchars(remove_iip($e->getMessage()), ENT_QUOTES, 'UTF-8'));
+
+ return false;
+ }
+ auditLog('ui.server.ssh_key_removed', [...$context, 'result' => $removed ? 'removed' : 'kept_for_other_server']);
+
+ return true;
+ }
+
public function render()
{
$checkboxes = [];
@@ -121,6 +166,14 @@ class Delete extends Component
$canDeleteFromProvider = ! $this->server->isTransferredAway();
+ if ($canDeleteFromProvider && $this->server->definedResources()->isEmpty() && AuthorizedKeys::canRunOn($this->server)) {
+ $checkboxes[] = [
+ 'id' => 'remove_ssh_key',
+ 'label' => 'Remove the public key of this server\'s SSH key from ~/.ssh/authorized_keys on the server',
+ 'default_warning' => 'The public key stays on the server, so this SSH key can still log in.',
+ ];
+ }
+
if ($canDeleteFromProvider && $this->server->hetzner_server_id) {
$checkboxes[] = [
'id' => 'delete_from_hetzner',
diff --git a/app/Livewire/Server/Navbar.php b/app/Livewire/Server/Navbar.php
index 4117a449ad..59fcd61175 100644
--- a/app/Livewire/Server/Navbar.php
+++ b/app/Livewire/Server/Navbar.php
@@ -171,6 +171,7 @@ class Navbar extends Component
}
try {
+ $this->authorize('manageProxy', $this->server);
$this->isChecking = true;
CheckProxy::run($this->server, true);
} catch (\Throwable $e) {
diff --git a/app/Livewire/Server/New/ByDigitalOcean.php b/app/Livewire/Server/New/ByDigitalOcean.php
index 6c6f4a4fcd..d248e225d1 100644
--- a/app/Livewire/Server/New/ByDigitalOcean.php
+++ b/app/Livewire/Server/New/ByDigitalOcean.php
@@ -4,6 +4,7 @@ namespace App\Livewire\Server\New;
use App\Enums\ProxyTypes;
use App\Jobs\ValidateNewCloudServerJob;
+use App\Livewire\Server\New\Concerns\SetsImageScanning;
use App\Models\CloudInitScript;
use App\Models\CloudProviderToken;
use App\Models\PrivateKey;
@@ -16,12 +17,14 @@ use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
+use Illuminate\Validation\Rule;
use Livewire\Attributes\Locked;
use Livewire\Component;
class ByDigitalOcean extends Component
{
use AuthorizesRequests;
+ use SetsImageScanning;
public int $current_step = 1;
@@ -154,7 +157,7 @@ class ByDigitalOcean extends Component
protected function rules(): array
{
$rules = [
- 'selected_token_id' => 'required|integer|exists:cloud_provider_tokens,id',
+ 'selected_token_id' => ['required', 'integer', Rule::exists('cloud_provider_tokens', 'id')->where('team_id', currentTeam()->id)],
];
if ($this->current_step === 2) {
@@ -172,7 +175,7 @@ class ByDigitalOcean extends Component
'cloud_init_script' => ['nullable', 'string', new ValidCloudInitYaml],
'save_cloud_init_script' => 'boolean',
'cloud_init_script_name' => 'nullable|string|max:255',
- 'selected_cloud_init_script_id' => 'nullable|integer|exists:cloud_init_scripts,id',
+ 'selected_cloud_init_script_id' => ['nullable', 'integer', Rule::exists('cloud_init_scripts', 'id')->where('team_id', currentTeam()->id)],
]);
}
@@ -224,7 +227,7 @@ class ByDigitalOcean extends Component
public function nextStep()
{
$this->validate([
- 'selected_token_id' => 'required|integer|exists:cloud_provider_tokens,id',
+ 'selected_token_id' => ['required', 'integer', Rule::exists('cloud_provider_tokens', 'id')->where('team_id', currentTeam()->id)],
]);
try {
@@ -502,6 +505,7 @@ class ByDigitalOcean extends Component
$server->proxy->set('status', 'exited');
$server->proxy->set('type', ProxyTypes::TRAEFIK->value);
$server->save();
+ $this->applyImageScanning($server);
return $server;
});
diff --git a/app/Livewire/Server/New/ByHetzner.php b/app/Livewire/Server/New/ByHetzner.php
index 81ed3269ad..f17476681e 100644
--- a/app/Livewire/Server/New/ByHetzner.php
+++ b/app/Livewire/Server/New/ByHetzner.php
@@ -5,6 +5,7 @@ namespace App\Livewire\Server\New;
use App\Enums\ProxyTypes;
use App\Exceptions\CloudProviderApiException;
use App\Jobs\ValidateNewCloudServerJob;
+use App\Livewire\Server\New\Concerns\SetsImageScanning;
use App\Models\CloudInitScript;
use App\Models\CloudProviderToken;
use App\Models\PrivateKey;
@@ -16,6 +17,7 @@ use App\Services\HetznerService;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Http;
+use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Locked;
use Livewire\Component;
@@ -23,6 +25,7 @@ use Livewire\Component;
class ByHetzner extends Component
{
use AuthorizesRequests;
+ use SetsImageScanning;
// Step tracking
public int $current_step = 1;
@@ -182,7 +185,7 @@ class ByHetzner extends Component
protected function rules(): array
{
$rules = [
- 'selected_token_id' => 'required|integer|exists:cloud_provider_tokens,id',
+ 'selected_token_id' => ['required', 'integer', Rule::exists('cloud_provider_tokens', 'id')->where('team_id', currentTeam()->id)],
];
if ($this->current_step === 2) {
@@ -205,7 +208,7 @@ class ByHetzner extends Component
'cloud_init_script' => ['nullable', 'string', new ValidCloudInitYaml],
'save_cloud_init_script' => 'boolean',
'cloud_init_script_name' => 'nullable|string|max:255',
- 'selected_cloud_init_script_id' => 'nullable|integer|exists:cloud_init_scripts,id',
+ 'selected_cloud_init_script_id' => ['nullable', 'integer', Rule::exists('cloud_init_scripts', 'id')->where('team_id', currentTeam()->id)],
]);
}
@@ -271,7 +274,7 @@ class ByHetzner extends Component
{
// Validate step 1 - just need a token selected
$this->validate([
- 'selected_token_id' => 'required|integer|exists:cloud_provider_tokens,id',
+ 'selected_token_id' => ['required', 'integer', Rule::exists('cloud_provider_tokens', 'id')->where('team_id', currentTeam()->id)],
]);
try {
@@ -750,6 +753,7 @@ class ByHetzner extends Component
$server->proxy->set('status', 'exited');
$server->proxy->set('type', ProxyTypes::TRAEFIK->value);
$server->save();
+ $this->applyImageScanning($server);
if ($this->enable_backups) {
try {
diff --git a/app/Livewire/Server/New/ByHostinger.php b/app/Livewire/Server/New/ByHostinger.php
index f12db08511..66a8d1d94d 100644
--- a/app/Livewire/Server/New/ByHostinger.php
+++ b/app/Livewire/Server/New/ByHostinger.php
@@ -4,6 +4,7 @@ namespace App\Livewire\Server\New;
use App\Enums\ProxyTypes;
use App\Jobs\ValidateNewCloudServerJob;
+use App\Livewire\Server\New\Concerns\SetsImageScanning;
use App\Models\CloudProviderToken;
use App\Models\PrivateKey;
use App\Models\Server;
@@ -12,12 +13,14 @@ use App\Rules\ValidHostname;
use App\Services\HostingerService;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
+use Illuminate\Validation\Rule;
use Livewire\Attributes\Locked;
use Livewire\Component;
class ByHostinger extends Component
{
use AuthorizesRequests;
+ use SetsImageScanning;
public int $current_step = 1;
@@ -106,7 +109,7 @@ class ByHostinger extends Component
protected function rules(): array
{
$rules = [
- 'selected_token_id' => 'required|integer|exists:cloud_provider_tokens,id',
+ 'selected_token_id' => ['required', 'integer', Rule::exists('cloud_provider_tokens', 'id')->where('team_id', currentTeam()->id)],
];
if ($this->current_step === 2) {
@@ -165,7 +168,7 @@ class ByHostinger extends Component
public function nextStep(): mixed
{
$this->validate([
- 'selected_token_id' => 'required|integer|exists:cloud_provider_tokens,id',
+ 'selected_token_id' => ['required', 'integer', Rule::exists('cloud_provider_tokens', 'id')->where('team_id', currentTeam()->id)],
]);
try {
@@ -361,6 +364,7 @@ class ByHostinger extends Component
$server->proxy->set('status', 'exited');
$server->proxy->set('type', ProxyTypes::TRAEFIK->value);
$server->save();
+ $this->applyImageScanning($server);
try {
if ($this->selected_public_key_ids) {
diff --git a/app/Livewire/Server/New/ByIp.php b/app/Livewire/Server/New/ByIp.php
index 44985ecdd2..8bb40ada22 100644
--- a/app/Livewire/Server/New/ByIp.php
+++ b/app/Livewire/Server/New/ByIp.php
@@ -4,6 +4,7 @@ namespace App\Livewire\Server\New;
use App\Enums\ProxyTypes;
use App\Enums\ServerRole;
+use App\Livewire\Server\New\Concerns\SetsImageScanning;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\Team;
@@ -17,6 +18,7 @@ use Livewire\Component;
class ByIp extends Component
{
use AuthorizesRequests;
+ use SetsImageScanning;
#[Locked]
public $private_keys;
@@ -173,6 +175,7 @@ class ByIp extends Component
$server->settings->server_role = ServerRole::from($this->server_role);
$server->settings->is_build_server = $this->server_role === ServerRole::BUILD->value;
$server->settings->save();
+ $this->applyImageScanning($server);
return redirectRoute($this, 'server.show', [$server->uuid]);
} catch (\Throwable $e) {
diff --git a/app/Livewire/Server/New/ByVultr.php b/app/Livewire/Server/New/ByVultr.php
index 5e27585b44..ae6deaf2b9 100644
--- a/app/Livewire/Server/New/ByVultr.php
+++ b/app/Livewire/Server/New/ByVultr.php
@@ -4,6 +4,7 @@ namespace App\Livewire\Server\New;
use App\Enums\ProxyTypes;
use App\Jobs\ValidateNewCloudServerJob;
+use App\Livewire\Server\New\Concerns\SetsImageScanning;
use App\Models\CloudInitScript;
use App\Models\CloudProviderToken;
use App\Models\PrivateKey;
@@ -16,12 +17,14 @@ use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
+use Illuminate\Validation\Rule;
use Livewire\Attributes\Locked;
use Livewire\Component;
class ByVultr extends Component
{
use AuthorizesRequests;
+ use SetsImageScanning;
public int $current_step = 1;
@@ -146,7 +149,7 @@ class ByVultr extends Component
protected function rules(): array
{
$rules = [
- 'selected_token_id' => 'required|integer|exists:cloud_provider_tokens,id',
+ 'selected_token_id' => ['required', 'integer', Rule::exists('cloud_provider_tokens', 'id')->where('team_id', currentTeam()->id)],
];
if ($this->current_step === 2) {
@@ -163,7 +166,7 @@ class ByVultr extends Component
'cloud_init_script' => ['nullable', 'string', new ValidCloudInitYaml],
'save_cloud_init_script' => 'boolean',
'cloud_init_script_name' => 'nullable|string|max:255',
- 'selected_cloud_init_script_id' => 'nullable|integer|exists:cloud_init_scripts,id',
+ 'selected_cloud_init_script_id' => ['nullable', 'integer', Rule::exists('cloud_init_scripts', 'id')->where('team_id', currentTeam()->id)],
]);
}
@@ -204,7 +207,7 @@ class ByVultr extends Component
public function nextStep(): mixed
{
$this->validate([
- 'selected_token_id' => 'required|integer|exists:cloud_provider_tokens,id',
+ 'selected_token_id' => ['required', 'integer', Rule::exists('cloud_provider_tokens', 'id')->where('team_id', currentTeam()->id)],
]);
try {
@@ -470,6 +473,7 @@ class ByVultr extends Component
$server->proxy->set('status', 'exited');
$server->proxy->set('type', ProxyTypes::TRAEFIK->value);
$server->save();
+ $this->applyImageScanning($server);
return $server;
});
diff --git a/app/Livewire/Server/New/Concerns/SetsImageScanning.php b/app/Livewire/Server/New/Concerns/SetsImageScanning.php
new file mode 100644
index 0000000000..ab45a49b4c
--- /dev/null
+++ b/app/Livewire/Server/New/Concerns/SetsImageScanning.php
@@ -0,0 +1,33 @@
+scan_images = currentTeam()?->scan_images_on_new_servers ? 'on' : 'off';
+ }
+
+ protected function applyImageScanning(Server $server): void
+ {
+ if ($this->scan_images !== 'on' || ! InstanceSettings::get()->is_image_scanning_allowed) {
+ return;
+ }
+
+ $settings = $server->settings;
+ $settings->is_image_scan_enabled = true;
+ $settings->saveQuietly();
+ }
+}
diff --git a/app/Livewire/Server/Proxy/NewDynamicConfiguration.php b/app/Livewire/Server/Proxy/NewDynamicConfiguration.php
index 481d89c789..5da29de16f 100644
--- a/app/Livewire/Server/Proxy/NewDynamicConfiguration.php
+++ b/app/Livewire/Server/Proxy/NewDynamicConfiguration.php
@@ -6,6 +6,7 @@ use App\Enums\ProxyTypes;
use App\Models\Server;
use App\Rules\ValidProxyConfigFilename;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
+use Livewire\Attributes\Locked;
use Livewire\Component;
use Symfony\Component\Yaml\Yaml;
@@ -19,15 +20,22 @@ class NewDynamicConfiguration extends Component
public bool $newFile = false;
- public Server $server;
+ public ?Server $server = null;
+ #[Locked]
public $server_id;
+ #[Locked]
public $parameters = [];
public function mount()
{
$this->server = Server::ownedByCurrentTeam()->whereId($this->server_id)->first();
+ if (is_null($this->server)) {
+ $this->redirectRoute('server.index');
+
+ return;
+ }
$this->parameters = get_route_parameters();
if ($this->fileName !== '') {
$this->fileName = str_replace('|', '.', $this->fileName);
@@ -37,6 +45,9 @@ class NewDynamicConfiguration extends Component
public function addDynamicConfiguration()
{
try {
+ if (is_null($this->server)) {
+ return redirect()->route('server.index');
+ }
$this->authorize('update', $this->server);
$this->validate([
'fileName' => ['required', new ValidProxyConfigFilename],
@@ -45,13 +56,6 @@ class NewDynamicConfiguration extends Component
validateFilenameSafe($this->fileName, 'proxy configuration filename');
- if (data_get($this->parameters, 'server_uuid')) {
- $this->server = Server::ownedByCurrentTeam()->whereUuid(data_get($this->parameters, 'server_uuid'))->first();
- }
-
- if (is_null($this->server)) {
- return redirect()->route('server.index');
- }
$proxy_type = $this->server->proxyType();
if ($proxy_type === ProxyTypes::TRAEFIK->value) {
if (! str($this->fileName)->endsWith('.yaml') && ! str($this->fileName)->endsWith('.yml')) {
diff --git a/app/Livewire/Server/Resources.php b/app/Livewire/Server/Resources.php
index a0822c26f4..e640237cae 100644
--- a/app/Livewire/Server/Resources.php
+++ b/app/Livewire/Server/Resources.php
@@ -4,6 +4,7 @@ namespace App\Livewire\Server;
use App\Livewire\Concerns\RemembersCanvasView;
use App\Models\Server;
+use App\Services\ImageScan\VulnerabilityMarkers;
use App\Support\ValidationPatterns;
use App\Traits\ListensToTeamChannel;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
@@ -215,13 +216,16 @@ class Resources extends Component
$this->setPage($page);
}
+ $pageResources = $resources->forPage($page, $this->perPage)->values();
+
return view('livewire.server.resources', [
'resources' => new LengthAwarePaginator(
- $resources->forPage($page, $this->perPage)->values(),
+ $pageResources,
$resources->count(),
$this->perPage,
$page,
),
+ 'markers' => $this->activeTab === 'managed' ? VulnerabilityMarkers::for($pageResources) : [],
]);
}
}
diff --git a/app/Livewire/Server/Security/HostKey.php b/app/Livewire/Server/Security/HostKey.php
new file mode 100644
index 0000000000..02a0f6e5d6
--- /dev/null
+++ b/app/Livewire/Server/Security/HostKey.php
@@ -0,0 +1,123 @@
+server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail();
+ $this->authorize('update', $this->server);
+ $this->parameters = get_route_parameters();
+ } catch (\Throwable) {
+ return redirect()->route('server.index');
+ }
+ }
+
+ /**
+ * @param string $pendingKeyHash SHA-256 of the pending key that the page showed, so that a key that changed later is not trusted.
+ */
+ public function trustPendingKey(string $pendingKeyHash, ?string $password): bool|string|Event
+ {
+ return $this->changeHostKey($password, 'trusted', function (ServerSetting $settings) use ($pendingKeyHash) {
+ if (blank($settings->ssh_host_key_pending)) {
+ throw new \RuntimeException('There is no new host key to trust.');
+ }
+ if (! hash_equals(hash('sha256', $settings->ssh_host_key_pending), $pendingKeyHash)) {
+ throw new \RuntimeException('The server sent another new host key. Reload the page and compare the key again.');
+ }
+ $settings->forceFill([
+ 'ssh_host_key' => $settings->ssh_host_key_pending,
+ 'ssh_host_key_source' => 'manual',
+ 'ssh_host_key_trusted_at' => now(),
+ 'ssh_host_key_pending' => null,
+ ]);
+ });
+ }
+
+ public function resetHostKey(?string $password): bool|string|Event
+ {
+ return $this->changeHostKey($password, 'reset', fn (ServerSetting $settings) => $settings->forceFill([
+ 'ssh_host_key' => null,
+ 'ssh_host_key_source' => null,
+ 'ssh_host_key_trusted_at' => null,
+ 'ssh_host_key_pending' => null,
+ ]));
+ }
+
+ /**
+ * @param bool $disabled The state that the button showed, so that a page open in another tab cannot turn the check back on.
+ */
+ public function setHostKeyCheckDisabled(bool $disabled, ?string $password): bool|string|Event
+ {
+ return $this->changeHostKey($password, $disabled ? 'check_disabled' : 'check_enabled', fn (ServerSetting $settings) => $settings->forceFill([
+ 'is_ssh_host_key_check_disabled' => $disabled,
+ ]));
+ }
+
+ /**
+ * @param callable(ServerSetting): mixed $change
+ */
+ private function changeHostKey(?string $password, string $action, callable $change): bool|string|Event
+ {
+ try {
+ $this->authorize('update', $this->server);
+ if (! $this->server->refresh()->canBeValidated()) {
+ throw new \RuntimeException('This server was transferred to another Coolify instance.');
+ }
+ if (! verifyPasswordConfirmation($password, $this)) {
+ return 'The provided password is incorrect.';
+ }
+
+ $settings = $this->server->settings->refresh();
+ $previousFingerprints = ServerSetting::sshHostKeyFingerprints($settings->ssh_host_key);
+ $change($settings);
+ $settings->save();
+
+ auditLog("ui.server.ssh_host_key.{$action}", [
+ 'team_id' => $this->server->team_id,
+ 'server_uuid' => $this->server->uuid,
+ 'server_name' => $this->server->name,
+ 'previous_fingerprints' => $previousFingerprints,
+ 'fingerprints' => ServerSetting::sshHostKeyFingerprints($settings->ssh_host_key),
+ ]);
+
+ SshMultiplexingHelper::removeMuxFile($this->server);
+ // The check can take up to a minute on a slow server, so it runs in the queue and the page polls for the result.
+ ServerConnectionCheckJob::dispatch($this->server);
+ $this->dispatch('hostKeySettingsChanged')->to(Index::class);
+ $this->dispatch('success', 'The host key settings were saved. Coolify now checks the connection again.');
+
+ return true;
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
+ }
+ }
+
+ public function render(): View
+ {
+ $settings = $this->server->settings;
+
+ return view('livewire.server.security.host-key', [
+ 'mode' => SshMultiplexingHelper::hostKeyCheckingMode($this->server),
+ 'fingerprints' => ServerSetting::sshHostKeyFingerprints($settings->ssh_host_key),
+ 'pendingFingerprints' => ServerSetting::sshHostKeyFingerprints($settings->ssh_host_key_pending),
+ ]);
+ }
+}
diff --git a/app/Livewire/Server/Security/Index.php b/app/Livewire/Server/Security/Index.php
new file mode 100644
index 0000000000..3e423fced0
--- /dev/null
+++ b/app/Livewire/Server/Security/Index.php
@@ -0,0 +1,78 @@
+server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail();
+ } catch (\Throwable) {
+ return redirect()->route('server.index');
+ }
+ $this->authorize('update', $this->server);
+ }
+
+ public bool $isWaitingForConnectionCheck = false;
+
+ #[Locked]
+ public ?int $connectionCheckDeadline = null;
+
+ #[Locked]
+ public bool $wasFunctionalBeforeConnectionCheck = false;
+
+ /**
+ * A host key change queues a connection check. Poll until it changes the server state, at most for one minute.
+ */
+ #[On('hostKeySettingsChanged')]
+ public function waitForConnectionCheck(): void
+ {
+ $this->authorize('update', $this->server);
+ $this->refreshServer();
+ $this->wasFunctionalBeforeConnectionCheck = $this->server->isFunctional();
+ $this->connectionCheckDeadline = now()->addMinute()->timestamp;
+ $this->isWaitingForConnectionCheck = true;
+ }
+
+ public function pollConnectionCheck(): void
+ {
+ $this->authorize('update', $this->server);
+ if (! $this->isWaitingForConnectionCheck) {
+ return;
+ }
+ $this->refreshServer();
+ if ($this->server->isFunctional() !== $this->wasFunctionalBeforeConnectionCheck) {
+ // Load the page again: the morph cannot insert the patching section, and the navbar shows the old status.
+ $this->redirectRoute('server.security', ['server_uuid' => $this->server->uuid], navigate: true);
+
+ return;
+ }
+ if (now()->timestamp >= $this->connectionCheckDeadline) {
+ $this->isWaitingForConnectionCheck = false;
+ $this->connectionCheckDeadline = null;
+ }
+ }
+
+ private function refreshServer(): void
+ {
+ $this->server->refresh();
+ $this->server->settings->refresh();
+ }
+
+ public function render(): View
+ {
+ return view('livewire.server.security.index');
+ }
+}
diff --git a/app/Livewire/Server/Sentinel.php b/app/Livewire/Server/Sentinel.php
index 22a6074866..fca139af63 100644
--- a/app/Livewire/Server/Sentinel.php
+++ b/app/Livewire/Server/Sentinel.php
@@ -202,6 +202,19 @@ class Sentinel extends Component
}
}
+ /**
+ * Sentinel sends its token in the Authorization header of every push. Over plain HTTP the token
+ * is readable on the network. Only the known local internal endpoint stays on the coolify Docker network.
+ */
+ public function isPushUrlInsecure(): bool
+ {
+ if (($this->server->isLocalhost() && $this->sentinelCustomUrl === 'http://coolify:8080') || ! str_starts_with(strtolower((string) $this->sentinelCustomUrl), 'http://')) {
+ return false;
+ }
+
+ return true;
+ }
+
public function render()
{
return view('livewire.server.sentinel');
diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php
index ccb2b86eb4..5c034fa747 100644
--- a/app/Livewire/Server/Show.php
+++ b/app/Livewire/Server/Show.php
@@ -936,7 +936,7 @@ class Show extends Component
public function loadHostingerTokens(): void
{
- $this->availableHostingerTokens = CloudProviderToken::ownedByCurrentTeam()
+ $this->availableHostingerTokens = CloudProviderToken::where('team_id', $this->server->team_id)
->where('provider', 'hostinger')
->get();
}
@@ -1326,7 +1326,7 @@ class Show extends Component
}
$updates = [
- 'cloud_provider_token_id' => $this->selectedHostingerTokenId,
+ 'cloud_provider_token_id' => $token->id,
'hostinger_virtual_machine_id' => $virtualMachine['id'],
'hostinger_virtual_machine_status' => $virtualMachine['state'] ?? null,
];
diff --git a/app/Livewire/Settings/Advanced.php b/app/Livewire/Settings/Advanced.php
index 17f9efc36d..cc69102c6e 100644
--- a/app/Livewire/Settings/Advanced.php
+++ b/app/Livewire/Settings/Advanced.php
@@ -129,6 +129,7 @@ class Advanced extends Component
$this->avatar_storage_options = [
['value' => 'local', 'label' => 'Local storage'],
...S3Storage::query()
+ ->s3Only()
->whereTeamId(0)
->where('is_usable', true)
->orderBy('name')
@@ -271,6 +272,7 @@ class Advanced extends Component
$storageId = (int) str($this->avatar_storage)->after('s3:')->value();
$storage = S3Storage::query()
+ ->s3Only()
->whereTeamId(0)
->where('is_usable', true)
->find($storageId);
@@ -302,6 +304,7 @@ class Advanced extends Component
}
$storage = S3Storage::query()
+ ->s3Only()
->whereTeamId(0)
->where('is_usable', true)
->find($storageId);
diff --git a/app/Livewire/SettingsEmail.php b/app/Livewire/SettingsEmail.php
index c25dfe2440..bb7083dcb9 100644
--- a/app/Livewire/SettingsEmail.php
+++ b/app/Livewire/SettingsEmail.php
@@ -2,19 +2,24 @@
namespace App\Livewire;
+use App\Livewire\Concerns\ManagesSmtpServers;
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Notifications\TransactionalEmails\Test;
use App\Rules\ValidHostname;
+use App\Support\SmtpPresets;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\RateLimiter;
+use Illuminate\Validation\Rule;
+use Illuminate\Validation\ValidationException;
+use Livewire\Attributes\Computed;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Validate;
use Livewire\Component;
class SettingsEmail extends Component
{
- use AuthorizesRequests;
+ use AuthorizesRequests, ManagesSmtpServers;
public InstanceSettings $settings;
@@ -66,6 +71,15 @@ class SettingsEmail extends Component
#[Validate(['nullable', 'string'])]
public ?string $resendApiKey = null;
+ #[Validate(['nullable', 'email'])]
+ public ?string $resendFromAddress = null;
+
+ #[Validate(['nullable', 'string'])]
+ public ?string $resendFromName = null;
+
+ #[Validate(['required', 'in:none,smtp,resend'])]
+ public string $emailProvider = 'none';
+
#[Validate(['nullable', 'email'])]
public ?string $testEmailAddress = null;
@@ -88,21 +102,15 @@ class SettingsEmail extends Component
$this->validate();
$this->validate(['smtpEhloDomain' => ['nullable', 'string', new ValidHostname]]);
$this->settings->smtp_enabled = $this->smtpEnabled;
- $this->settings->smtp_host = $this->smtpHost;
- $this->settings->smtp_port = $this->smtpPort;
- $this->settings->smtp_encryption = $this->smtpEncryption;
- $this->settings->smtp_username = $this->smtpUsername;
- $this->settings->smtp_password = $this->smtpPassword;
- $this->settings->smtp_timeout = $this->smtpTimeout;
- $this->settings->smtp_ehlo_domain = $this->smtpEhloDomain;
- $this->settings->smtp_from_address = $this->smtpFromAddress;
- $this->settings->smtp_from_name = $this->smtpFromName;
+ $this->fillSmtpServerSettings();
$this->settings->notification_from_address = $this->notificationFromAddress;
$this->settings->notification_from_name = $this->notificationFromName;
$this->settings->resend_enabled = $this->resendEnabled;
$this->settings->resend_api_key = $this->resendApiKey;
- $this->saveAndAudit();
+ $this->settings->resend_from_address = $this->resendFromAddress;
+ $this->settings->resend_from_name = $this->resendFromName;
+ $this->saveEmailSettings();
} else {
$this->smtpEnabled = $this->settings->smtp_enabled;
$this->smtpHost = $this->settings->smtp_host;
@@ -118,58 +126,89 @@ class SettingsEmail extends Component
$this->notificationFromName = $this->settings->notification_from_name;
$this->resendEnabled = $this->settings->resend_enabled;
+ $this->resendFromAddress = $this->settings->resend_from_address;
+ $this->resendFromName = $this->settings->resend_from_name;
$this->resendApiKey = $this->settings->resend_api_key;
+
+ $this->smtpServerName = $this->settings->smtp_server_name;
+ $this->selectedSmtpServer = 'active';
+ $this->emailProvider = $this->activeEmailProvider();
}
}
+ /**
+ * The provider that sends email now. It differs from $emailProvider until the selected provider is saved.
+ */
+ #[Computed]
+ public function activeEmailProvider(): string
+ {
+ return enabled_email_provider($this->settings) ?? 'none';
+ }
+
public function submit()
{
try {
$this->authorize('update', $this->settings);
$this->resetErrorBag();
+ $this->enableSelectedEmailProvider();
$this->syncData(true);
$this->dispatch('success', 'Transactional email settings updated.');
} catch (\Throwable $e) {
+ $this->restoreSavedEmailProvider();
+
return handleError($e, $this);
}
}
- public function instantSave(string $type)
+ /**
+ * Turn on the provider selected in the provider list and save. When a required field of the
+ * provider is empty, nothing is saved and the page only shows the fields to fill in.
+ */
+ public function selectEmailProvider()
{
try {
- $this->authorize('update', $this->settings);
- $currentSmtpEnabled = $this->settings->smtp_enabled;
- $currentResendEnabled = $this->settings->resend_enabled;
+ $this->enableSelectedEmailProvider();
+ } catch (ValidationException) {
+ // Not complete yet: show the fields of the provider and the "Not active yet" notice, without an error.
$this->resetErrorBag();
- if ($type === 'SMTP') {
- $this->submitSmtp();
- $this->resendEnabled = $this->settings->resend_enabled = false;
- } elseif ($type === 'Resend') {
- $this->submitResend();
- $this->smtpEnabled = $this->settings->smtp_enabled = false;
- }
- $this->saveAndAudit();
+ return null;
+ }
- } catch (\Throwable $e) {
- if ($type === 'SMTP') {
- $this->smtpEnabled = $currentSmtpEnabled;
- } elseif ($type === 'Resend') {
- $this->resendEnabled = $currentResendEnabled;
- }
+ return $this->submit();
+ }
- return handleError($e, $this);
+ /**
+ * Turn on the provider selected in the provider list. When its settings are not valid, the saved
+ * provider stays on, so that a later save of another field cannot turn off the working provider.
+ */
+ private function enableSelectedEmailProvider(): void
+ {
+ $this->smtpEnabled = $this->emailProvider === 'smtp';
+ $this->resendEnabled = $this->emailProvider === 'resend';
+
+ try {
+ $this->validateEnabledEmailProvider();
+ } catch (ValidationException $e) {
+ $this->restoreSavedEmailProvider();
+
+ throw $e;
}
}
- public function instantSaveSmtp(): void
+ private function restoreSavedEmailProvider(): void
{
- $this->instantSave('SMTP');
+ $this->smtpEnabled = (bool) $this->settings->getOriginal('smtp_enabled');
+ $this->resendEnabled = (bool) $this->settings->getOriginal('resend_enabled');
}
- public function instantSaveResend(): void
+ private function validateEnabledEmailProvider(): void
{
- $this->instantSave('Resend');
+ match (true) {
+ $this->smtpEnabled => $this->validateSmtpSettings(),
+ $this->resendEnabled => $this->validateResendSettings(),
+ default => null,
+ };
}
public function toggleSmtp()
@@ -185,7 +224,6 @@ class SettingsEmail extends Component
} else {
$this->validateSmtpSettings();
$this->smtpEnabled = true;
- $this->resendEnabled = false;
$this->submitSmtp();
}
} catch (\Throwable $e) {
@@ -208,7 +246,6 @@ class SettingsEmail extends Component
} else {
$this->validateResendSettings();
$this->resendEnabled = true;
- $this->smtpEnabled = false;
$this->submitResend();
}
} catch (\Throwable $e) {
@@ -225,21 +262,13 @@ class SettingsEmail extends Component
$this->validateSmtpSettings();
if ($this->smtpEnabled) {
- $this->settings->resend_enabled = $this->resendEnabled = false;
+ $this->disableEmailProvidersExcept('smtp');
}
$this->settings->smtp_enabled = $this->smtpEnabled;
- $this->settings->smtp_host = $this->smtpHost;
- $this->settings->smtp_port = $this->smtpPort;
- $this->settings->smtp_encryption = $this->smtpEncryption;
- $this->settings->smtp_username = $this->smtpUsername;
- $this->settings->smtp_password = $this->smtpPassword;
- $this->settings->smtp_timeout = $this->smtpTimeout;
- $this->settings->smtp_ehlo_domain = $this->smtpEhloDomain;
- $this->settings->smtp_from_address = $this->smtpFromAddress;
- $this->settings->smtp_from_name = $this->smtpFromName;
+ $this->fillSmtpServerSettings();
- $this->saveAndAudit();
+ $this->saveEmailSettings();
$this->dispatch('success', 'SMTP settings updated.');
} catch (\Throwable $e) {
@@ -256,15 +285,15 @@ class SettingsEmail extends Component
$this->validateResendSettings();
if ($this->resendEnabled) {
- $this->settings->smtp_enabled = $this->smtpEnabled = false;
+ $this->disableEmailProvidersExcept('resend');
}
$this->settings->resend_enabled = $this->resendEnabled;
$this->settings->resend_api_key = $this->resendApiKey;
- $this->settings->smtp_from_address = $this->smtpFromAddress;
- $this->settings->smtp_from_name = $this->smtpFromName;
+ $this->settings->resend_from_address = $this->resendFromAddress;
+ $this->settings->resend_from_name = $this->resendFromName;
- $this->saveAndAudit();
+ $this->saveEmailSettings();
$this->dispatch('success', 'Resend settings updated.');
} catch (\Throwable $e) {
@@ -274,13 +303,27 @@ class SettingsEmail extends Component
}
}
+ /**
+ * Turn off every email provider except the given one, so only one provider sends email.
+ */
+ private function disableEmailProvidersExcept(string $provider): void
+ {
+ if ($provider !== 'smtp') {
+ $this->settings->smtp_enabled = $this->smtpEnabled = false;
+ }
+ if ($provider !== 'resend') {
+ $this->settings->resend_enabled = $this->resendEnabled = false;
+ }
+ }
+
/**
* Save the instance email settings and record the changed field names (never their values).
*/
- private function saveAndAudit(): void
+ protected function saveEmailSettings(): void
{
$changedFields = auditChangedFields($this->settings);
$this->settings->save();
+ $this->emailProvider = $this->activeEmailProvider();
if ($changedFields === []) {
return;
@@ -294,17 +337,23 @@ class SettingsEmail extends Component
]);
}
+ protected function loadEmailSettings(): void
+ {
+ $this->syncData();
+ }
+
private function validateSmtpSettings(): void
{
$this->validate([
'smtpEnabled' => 'boolean',
+ 'smtpServerName' => 'nullable|string|max:255',
'smtpFromAddress' => 'required|email',
'smtpFromName' => 'required|string',
'smtpHost' => 'required|string',
'smtpPort' => 'required|numeric',
'smtpEncryption' => 'required|string|in:starttls,tls,none',
- 'smtpUsername' => 'nullable|string',
- 'smtpPassword' => 'nullable|string',
+ 'smtpUsername' => [Rule::requiredIf(SmtpPresets::requiresLogin($this->smtpHost)), 'nullable', 'string'],
+ 'smtpPassword' => [Rule::requiredIf(SmtpPresets::requiresLogin($this->smtpHost)), 'nullable', 'required_with:smtpUsername', 'string'],
'smtpTimeout' => 'nullable|numeric',
'smtpEhloDomain' => ['nullable', 'string', new ValidHostname],
], [
@@ -314,6 +363,9 @@ class SettingsEmail extends Component
'smtpHost.required' => 'SMTP Host is required.',
'smtpPort.required' => 'SMTP Port is required.',
'smtpPort.numeric' => 'SMTP Port must be a number.',
+ 'smtpUsername.required' => 'This provider needs a username.',
+ 'smtpPassword.required' => 'This provider needs a password.',
+ 'smtpPassword.required_with' => 'Enter the password for this username.',
'smtpEncryption.required' => 'Encryption type is required.',
]);
}
@@ -323,13 +375,13 @@ class SettingsEmail extends Component
$this->validate([
'resendEnabled' => 'boolean',
'resendApiKey' => $this->resendEnabled ? 'required|string' : 'nullable|string',
- 'smtpFromAddress' => 'required|email',
- 'smtpFromName' => 'required|string',
+ 'resendFromAddress' => $this->resendEnabled ? 'required|email' : 'nullable|email',
+ 'resendFromName' => $this->resendEnabled ? 'required|string' : 'nullable|string',
], [
'resendApiKey.required' => 'Resend API Key is required.',
- 'smtpFromAddress.required' => 'From Address is required.',
- 'smtpFromAddress.email' => 'Please enter a valid email address.',
- 'smtpFromName.required' => 'From Name is required.',
+ 'resendFromAddress.required' => 'From Address is required.',
+ 'resendFromAddress.email' => 'Please enter a valid email address.',
+ 'resendFromName.required' => 'From Name is required.',
]);
}
@@ -339,24 +391,27 @@ class SettingsEmail extends Component
$this->authorize('update', $this->settings);
$this->validate([
'testEmailAddress' => 'required|email',
- 'smtpFromAddress' => 'required|email',
- 'smtpFromName' => 'required|string',
'notificationFromAddress' => $this->testUsesNotificationSender ? 'required|email' : 'nullable|email',
'notificationFromName' => 'nullable|string|max:255',
], [
'notificationFromAddress.required' => 'Notification from address is required to test the notification sender.',
'testEmailAddress.required' => 'Test email address is required.',
'testEmailAddress.email' => 'Please enter a valid email address.',
- 'smtpFromAddress.required' => 'From Address is required.',
- 'smtpFromAddress.email' => 'Please enter a valid email address.',
- 'smtpFromName.required' => 'From Name is required.',
]);
- $this->settings->smtp_from_address = $this->smtpFromAddress;
- $this->settings->smtp_from_name = $this->smtpFromName;
+ // Send with the sender on the page, so that a changed sender can be tested before Save.
+ if ($this->settings->resend_enabled) {
+ $this->validateResendSettings();
+ $this->settings->resend_from_address = $this->resendFromAddress;
+ $this->settings->resend_from_name = $this->resendFromName;
+ } else {
+ $this->validateSmtpSettings();
+ $this->settings->smtp_from_address = $this->smtpFromAddress;
+ $this->settings->smtp_from_name = $this->smtpFromName;
+ }
$this->settings->notification_from_address = $this->notificationFromAddress;
$this->settings->notification_from_name = $this->notificationFromName;
- $this->saveAndAudit();
+ $this->saveEmailSettings();
$executed = RateLimiter::attempt(
'test-email:'.$this->team->id,
diff --git a/app/Livewire/Source/Gitea/Change.php b/app/Livewire/Source/Gitea/Change.php
index 6db104020e..130ed7ca50 100644
--- a/app/Livewire/Source/Gitea/Change.php
+++ b/app/Livewire/Source/Gitea/Change.php
@@ -152,11 +152,14 @@ class Change extends Component
$this->isConnected = $this->gitea_app->isConnected();
$this->fqdn = $settings->fqdn;
- if ($settings->public_ipv4) {
- $this->ipv4 = 'http://'.$settings->public_ipv4.':'.config('app.port');
- }
- if ($settings->public_ipv6) {
- $this->ipv6 = 'http://'.$settings->public_ipv6.':'.config('app.port');
+ // On cloud, the instance IPs are the addresses of the Coolify hosts. Do not send them to the browser.
+ if (! isCloud()) {
+ if ($settings->public_ipv4) {
+ $this->ipv4 = 'http://'.$settings->public_ipv4.':'.config('app.port');
+ }
+ if ($settings->public_ipv6) {
+ $this->ipv6 = 'http://'.$settings->public_ipv6.':'.config('app.port');
+ }
}
$this->parameters = get_route_parameters();
@@ -281,7 +284,7 @@ class Change extends Component
$this->syncData(true);
$this->gitea_app->save();
- $this->dispatch('success', 'Gitea App updated.');
+ $this->dispatch('success', 'Gitea / Forgejo App updated.');
$this->dispatch('name-changed', uuid: $this->gitea_app->uuid, name: $this->gitea_app->name);
} catch (\Throwable $e) {
return handleError($e, $this);
@@ -298,7 +301,7 @@ class Change extends Component
$this->gitea_app->is_system_wide = $this->isSystemWide;
$this->gitea_app->save();
- $this->dispatch('success', 'Gitea App updated.');
+ $this->dispatch('success', 'Gitea / Forgejo App updated.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
@@ -310,7 +313,7 @@ class Change extends Component
$this->authorize('view', $this->gitea_app);
if (! $this->gitea_app->isConnected()) {
- $this->dispatch('error', 'Gitea App is not connected. Please complete the OAuth flow first.');
+ $this->dispatch('error', 'Gitea / Forgejo App is not connected. Please complete the OAuth flow first.');
return;
}
diff --git a/app/Livewire/Source/Gitea/Create.php b/app/Livewire/Source/Gitea/Create.php
index b119b948bf..51463f6272 100644
--- a/app/Livewire/Source/Gitea/Create.php
+++ b/app/Livewire/Source/Gitea/Create.php
@@ -13,8 +13,16 @@ class Create extends Component
{
use AuthorizesRequests;
+ /** @var array Hosted instances that the provider choice fills in. */
+ public const HOSTED_PROVIDERS = [
+ 'gitea.com' => 'https://gitea.com',
+ 'codeberg.org' => 'https://codeberg.org',
+ ];
+
public string $name;
+ public string $provider = 'gitea.com';
+
public string $html_url = 'https://gitea.com';
public string $api_url = 'https://gitea.com/api/v1';
@@ -34,6 +42,20 @@ class Create extends Component
$this->name = substr(generate_random_name(), 0, 30);
}
+ public function selectProvider(string $provider): void
+ {
+ $this->provider = $provider;
+
+ if (! isset(self::HOSTED_PROVIDERS[$provider])) {
+ return;
+ }
+
+ $this->html_url = self::HOSTED_PROVIDERS[$provider];
+ $this->api_url = $this->giteaApiUrlFromHtmlUrl($this->html_url);
+ $this->custom_user = 'git';
+ $this->custom_port = 22;
+ }
+
public function updatingHtmlUrl(): void
{
$this->shouldDeriveApiUrlAfterHtmlUrlUpdate = blank($this->api_url)
diff --git a/app/Livewire/Source/Github/Change.php b/app/Livewire/Source/Github/Change.php
index 1f8596374f..6758bdaac5 100644
--- a/app/Livewire/Source/Github/Change.php
+++ b/app/Livewire/Source/Github/Change.php
@@ -12,6 +12,7 @@ use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
+use Livewire\Attributes\Locked;
use Livewire\Component;
class Change extends Component
@@ -34,6 +35,8 @@ class Change extends Component
public ?bool $preview_deployment_permissions = true;
+ public ?bool $deployment_status_permissions = true;
+
public ?bool $administration = false;
public ?bool $github_runners = false;
@@ -79,6 +82,7 @@ class Change extends Component
public $privateKeys;
+ #[Locked]
public string $manifestState = '';
public string $activeTab = 'general';
@@ -198,7 +202,7 @@ class Change extends Component
public function checkPermissions()
{
try {
- $this->authorize('view', $this->github_app);
+ $this->authorize('update', $this->github_app);
// Validate required fields before attempting to fetch permissions
$missingFields = [];
@@ -251,7 +255,7 @@ class Change extends Component
public function testConnection()
{
try {
- $this->authorize('view', $this->github_app);
+ $this->authorize('update', $this->github_app);
if (! $this->github_app->isConnected()) {
$this->dispatch('error', 'GitHub App is not fully set up. Please complete installation first.');
@@ -320,13 +324,18 @@ class Change extends Component
// Override name with kebab case for display
$this->name = str($this->github_app->name)->kebab();
$this->fqdn = $settings->fqdn;
- $this->manifestState = $this->createGithubAppSetupState('manifest');
-
- if ($settings->public_ipv4) {
- $this->ipv4 = 'http://'.$settings->public_ipv4.':'.config('app.port');
+ if (auth()->user()->can('update', $this->github_app)) {
+ $this->manifestState = $this->createGithubAppSetupState('manifest');
}
- if ($settings->public_ipv6) {
- $this->ipv6 = 'http://'.formatHostForUrl($settings->public_ipv6).':'.config('app.port');
+
+ // On cloud, the instance IPs are the addresses of the Coolify hosts. Do not send them to the browser.
+ if (! isCloud()) {
+ if ($settings->public_ipv4) {
+ $this->ipv4 = 'http://'.$settings->public_ipv4.':'.config('app.port');
+ }
+ if ($settings->public_ipv6) {
+ $this->ipv6 = 'http://'.formatHostForUrl($settings->public_ipv6).':'.config('app.port');
+ }
}
if ($this->github_app->installation_id && session('from')) {
$source_id = data_get(session('from'), 'source_id');
diff --git a/app/Livewire/Storage/Concerns/SelectsSftpLogin.php b/app/Livewire/Storage/Concerns/SelectsSftpLogin.php
new file mode 100644
index 0000000000..5b84b3b7a0
--- /dev/null
+++ b/app/Livewire/Storage/Concerns/SelectsSftpLogin.php
@@ -0,0 +1,74 @@
+ $rules
+ * @return array
+ */
+ protected function withSftpLoginRules(array $rules): array
+ {
+ if (! array_key_exists('private_key_id', $rules)) {
+ return $rules;
+ }
+
+ $usesKey = $this->sftpAuth === 'key';
+ $keyRules = $rules['private_key_id'];
+ unset($rules['private_key_id']);
+ $rules['sftpAuth'] = ['required', 'in:password,key'];
+ $rules['privateKeyId'] = [$usesKey ? 'required' : 'nullable', ...array_slice($keyRules, 1)];
+ $rules['secret'] = [$usesKey ? 'nullable' : 'required', 'string', 'max:255'];
+
+ return $rules;
+ }
+
+ /**
+ * The private key ID to save: only when the storage logs in with a key.
+ */
+ protected function selectedPrivateKeyId(): ?int
+ {
+ return $this->sftpAuth === 'key' ? $this->privateKeyId : null;
+ }
+
+ /**
+ * @return array
+ */
+ protected function privateKeyOptions(int $teamId): array
+ {
+ return PrivateKey::query()
+ ->where('team_id', $teamId)
+ ->where('is_git_related', false)
+ ->orderBy('name')
+ ->get(['id', 'name'])
+ ->map(fn (PrivateKey $key): array => ['value' => $key->id, 'label' => $key->name])
+ ->all();
+ }
+
+ /**
+ * The public key of the selected private key, to add to authorized_keys on the SFTP server.
+ */
+ protected function selectedPublicKey(int $teamId): ?string
+ {
+ if ($this->sftpAuth !== 'key' || $this->privateKeyId === null) {
+ return null;
+ }
+
+ return PrivateKey::query()->where('team_id', $teamId)->find($this->privateKeyId)?->getPublicKey();
+ }
+}
diff --git a/app/Livewire/Storage/Create.php b/app/Livewire/Storage/Create.php
index 790304fa86..c876385e9c 100644
--- a/app/Livewire/Storage/Create.php
+++ b/app/Livewire/Storage/Create.php
@@ -2,18 +2,23 @@
namespace App\Livewire\Storage;
+use App\Enums\BackupStorageType;
+use App\Livewire\Storage\Concerns\SelectsSftpLogin;
use App\Models\S3Storage;
-use App\Rules\SafeWebhookUrl;
-use App\Rules\ValidS3BucketName;
use App\Support\DomainUrlParts;
use App\Support\ValidationPatterns;
+use Illuminate\Contracts\View\View;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Uri;
+use Illuminate\Validation\Rule;
use Livewire\Component;
class Create extends Component
{
use AuthorizesRequests;
+ use SelectsSftpLogin;
+
+ public string $type = 's3';
public string $name;
@@ -23,9 +28,9 @@ class Create extends Component
public string $key;
- public string $secret;
+ public string $secret = '';
- public string $bucket;
+ public string $bucket = '';
public string $endpoint = '';
@@ -39,18 +44,22 @@ class Create extends Component
public bool $endpointPartsChanged = false;
+ /**
+ * Host, port, and directory of an SFTP or FTPS storage.
+ *
+ * @var array{host: string, port: string, path: string}
+ */
+ public array $serverParts = ['host' => '', 'port' => '', 'path' => ''];
+
public S3Storage $storage;
protected function rules(): array
{
return [
+ 'type' => ['required', Rule::enum(BackupStorageType::class)],
'name' => ValidationPatterns::nameRules(),
'description' => ValidationPatterns::descriptionRules(),
- 'region' => 'required|max:255',
- 'key' => 'required|max:255',
- 'secret' => 'required|max:255',
- 'bucket' => ['required', new ValidS3BucketName],
- 'endpoint' => ['required', 'max:255', new SafeWebhookUrl],
+ ...$this->withSftpLoginRules(S3Storage::connectionRules($this->storageType(), teamId: currentTeam()->id)),
'usePathStyleEndpoint' => 'boolean',
'skipTlsVerification' => 'boolean',
];
@@ -58,6 +67,13 @@ class Create extends Component
protected function messages(): array
{
+ if ($this->storageType() !== BackupStorageType::S3) {
+ return array_merge(ValidationPatterns::combinedMessages(), [
+ 'key.regex' => 'The Username must not contain line breaks or quotes.',
+ 'privateKeyId.required' => 'Select the private key to log in with.',
+ ]);
+ }
+
return array_merge(
ValidationPatterns::combinedMessages(),
[
@@ -74,34 +90,52 @@ class Create extends Component
);
}
- protected $validationAttributes = [
- 'name' => 'Name',
- 'description' => 'Description',
- 'region' => 'Region',
- 'key' => 'Key',
- 'secret' => 'Secret',
- 'bucket' => 'Bucket',
- 'endpoint' => 'Endpoint',
- 'usePathStyleEndpoint' => 'Use path-style URLs',
- 'skipTlsVerification' => 'Skip TLS certificate verification',
- ];
+ protected function validationAttributes(): array
+ {
+ $isS3 = $this->storageType() === BackupStorageType::S3;
+
+ return [
+ 'name' => 'Name',
+ 'description' => 'Description',
+ 'region' => 'Region',
+ 'key' => $isS3 ? 'Key' : 'Username',
+ 'secret' => $isS3 ? 'Secret' : 'Password',
+ 'bucket' => 'Bucket',
+ 'endpoint' => $isS3 ? 'Endpoint' : 'Server',
+ 'usePathStyleEndpoint' => 'Use path-style URLs',
+ 'skipTlsVerification' => 'Skip TLS certificate verification',
+ 'privateKeyId' => 'Private key',
+ ];
+ }
public function submit()
{
try {
$this->authorize('create', S3Storage::class);
- if ($this->endpointPartsChanged) {
- $this->endpoint = DomainUrlParts::compose(...$this->endpointParts);
+ $type = $this->storageType();
+ if ($type === BackupStorageType::Sftp || $type === BackupStorageType::Ftps) {
+ $this->endpoint = S3Storage::serverEndpoint($type, ...$this->serverParts);
+ } else {
+ if ($this->endpointPartsChanged) {
+ $this->endpoint = DomainUrlParts::compose(...$this->endpointParts);
+ }
+ $this->endpoint = $this->normalizeEndpoint($this->endpoint);
}
- $this->endpoint = $this->normalizeEndpoint($this->endpoint);
$this->validate();
$this->storage = new S3Storage;
+ $this->storage->type = $type;
+ if ($type !== BackupStorageType::S3) {
+ $this->bucket = '';
+ $this->region = 'us-east-1';
+ $this->skipTlsVerification = false;
+ }
$this->storage->name = $this->name;
$this->storage->description = $this->description ?? null;
$this->storage->region = $this->region;
$this->storage->key = $this->key;
- $this->storage->secret = $this->secret;
+ $this->storage->private_key_id = $type === BackupStorageType::Sftp ? $this->selectedPrivateKeyId() : null;
+ $this->storage->secret = $this->storage->private_key_id ? '' : $this->secret;
$this->storage->bucket = $this->bucket;
$this->storage->use_path_style_endpoint = $this->usePathStyleEndpoint;
$this->storage->skip_tls_verification = $this->skipTlsVerification;
@@ -111,7 +145,7 @@ class Create extends Component
$this->storage->endpoint = $this->endpoint;
}
$this->storage->team_id = currentTeam()->id;
- $this->storage->testConnection();
+ $this->storage->testConnection(acceptNewHostKey: true);
$this->storage->save();
return redirectRoute($this, 'storage.show', [$this->storage->uuid]);
@@ -121,6 +155,27 @@ class Create extends Component
}
}
+ public function render(): View
+ {
+ $teamId = currentTeam()?->id;
+ $listsPrivateKeys = $teamId !== null && $this->type === BackupStorageType::Sftp->value;
+
+ return view('livewire.storage.create', [
+ 'privateKeys' => $listsPrivateKeys ? $this->privateKeyOptions($teamId) : [],
+ 'publicKey' => $listsPrivateKeys ? $this->selectedPublicKey($teamId) : null,
+ ]);
+ }
+
+ public function storageType(): BackupStorageType
+ {
+ return BackupStorageType::tryFrom($this->type) ?? BackupStorageType::S3;
+ }
+
+ public function updatedType(): void
+ {
+ $this->resetValidation();
+ }
+
public function updatedEndpointParts(): void
{
$this->endpointPartsChanged = true;
diff --git a/app/Livewire/Storage/Form.php b/app/Livewire/Storage/Form.php
index 58511597ba..37246cd9a9 100644
--- a/app/Livewire/Storage/Form.php
+++ b/app/Livewire/Storage/Form.php
@@ -2,11 +2,12 @@
namespace App\Livewire\Storage;
+use App\Enums\BackupStorageType;
+use App\Livewire\Storage\Concerns\SelectsSftpLogin;
use App\Models\S3Storage;
-use App\Rules\SafeWebhookUrl;
-use App\Rules\ValidS3BucketName;
use App\Support\DomainUrlParts;
use App\Support\ValidationPatterns;
+use Illuminate\Contracts\View\View;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\On;
@@ -15,6 +16,7 @@ use Livewire\Component;
class Form extends Component
{
use AuthorizesRequests;
+ use SelectsSftpLogin;
public S3Storage $storage;
@@ -29,7 +31,14 @@ class Form extends Component
public bool $endpointPartsChanged = false;
- public string $bucket;
+ /**
+ * Host, port, and directory of an SFTP or FTPS storage.
+ *
+ * @var array{host: string, port: string, path: string}
+ */
+ public array $serverParts = ['host' => '', 'port' => '', 'path' => ''];
+
+ public ?string $bucket = null;
public string $region;
@@ -41,7 +50,12 @@ class Form extends Component
public string $key;
- public string $secret;
+ /**
+ * A new secret key or password. The saved one never goes to the browser; empty keeps it.
+ */
+ public string $secret = '';
+
+ public bool $hasSavedSecret = false;
public ?bool $isUsable = null;
@@ -49,22 +63,38 @@ class Form extends Component
protected function rules(): array
{
+ $connectionRules = $this->withSftpLoginRules(S3Storage::connectionRules($this->storage->type, trustedInternalHosts: $this->storage->trustedInternalHosts(), teamId: $this->storage->team_id, savedEndpoint: $this->storage->getOriginal('endpoint')));
+ if ($this->hasSavedSecret) {
+ $connectionRules['secret'] = ['nullable', 'string', 'max:255'];
+ }
+
return [
'isUsable' => 'nullable|boolean',
'name' => ValidationPatterns::nameRules(required: false),
'description' => ValidationPatterns::descriptionRules(),
- 'region' => 'required|max:255',
- 'key' => 'required|max:255',
- 'secret' => 'required|max:255',
- 'bucket' => ['required', new ValidS3BucketName],
- 'endpoint' => ['required', 'max:255', new SafeWebhookUrl],
+ ...$connectionRules,
'usePathStyleEndpoint' => 'boolean',
'skipTlsVerification' => 'boolean',
];
}
+ /**
+ * The secret to save or test: the new one from the form, else the saved one.
+ */
+ private function secretToUse(): string
+ {
+ return filled($this->secret) ? $this->secret : (string) $this->storage->secret;
+ }
+
protected function messages(): array
{
+ if (! $this->storage->isS3()) {
+ return array_merge(ValidationPatterns::combinedMessages(), [
+ 'key.regex' => 'The Username must not contain line breaks or quotes.',
+ 'privateKeyId.required' => 'Select the private key to log in with.',
+ ]);
+ }
+
return array_merge(
ValidationPatterns::combinedMessages(),
[
@@ -81,18 +111,37 @@ class Form extends Component
);
}
- protected $validationAttributes = [
- 'isUsable' => 'Is Usable',
- 'name' => 'Name',
- 'description' => 'Description',
- 'region' => 'Region',
- 'key' => 'Key',
- 'secret' => 'Secret',
- 'bucket' => 'Bucket',
- 'endpoint' => 'Endpoint',
- 'usePathStyleEndpoint' => 'Use path-style URLs',
- 'skipTlsVerification' => 'Skip TLS certificate verification',
- ];
+ protected function validationAttributes(): array
+ {
+ $isS3 = $this->storage->isS3();
+
+ return [
+ 'isUsable' => 'Is Usable',
+ 'name' => 'Name',
+ 'description' => 'Description',
+ 'region' => 'Region',
+ 'key' => $isS3 ? 'Key' : 'Username',
+ 'secret' => $isS3 ? 'Secret' : 'Password',
+ 'bucket' => 'Bucket',
+ 'endpoint' => $isS3 ? 'Endpoint' : 'Server',
+ 'usePathStyleEndpoint' => 'Use path-style URLs',
+ 'skipTlsVerification' => 'Skip TLS certificate verification',
+ 'privateKeyId' => 'Private key',
+ ];
+ }
+
+ /**
+ * Builds the endpoint from the form fields of the storage type.
+ */
+ private function composeEndpoint(): void
+ {
+ $type = $this->storage->type;
+ if ($type === BackupStorageType::Sftp || $type === BackupStorageType::Ftps) {
+ $this->endpoint = S3Storage::serverEndpoint($type, ...$this->serverParts);
+ } elseif ($this->endpointPartsChanged) {
+ $this->endpoint = DomainUrlParts::compose(...$this->endpointParts);
+ }
+ }
/**
* Sync data between component properties and model
@@ -109,9 +158,10 @@ class Form extends Component
$this->storage->bucket = $this->bucket;
$this->storage->region = $this->region;
$this->storage->use_path_style_endpoint = $this->usePathStyleEndpoint;
- $this->storage->skip_tls_verification = $this->skipTlsVerification;
+ $this->storage->skip_tls_verification = $this->storage->isS3() && $this->skipTlsVerification;
$this->storage->key = $this->key;
- $this->storage->secret = $this->secret;
+ $this->storage->private_key_id = $this->storage->type === BackupStorageType::Sftp ? $this->selectedPrivateKeyId() : null;
+ $this->storage->secret = $this->storage->private_key_id ? '' : $this->secretToUse();
$this->storage->is_usable = $this->isUsable;
} else {
// Sync FROM model (on load/refresh)
@@ -120,6 +170,7 @@ class Form extends Component
$this->endpoint = $this->storage->endpoint;
$this->endpointParts = DomainUrlParts::split($this->endpoint);
$this->endpointPartsChanged = false;
+ $this->serverParts = S3Storage::splitServerEndpoint($this->endpoint);
$this->bucket = $this->storage->bucket;
$this->region = $this->storage->region;
$this->usePathStyleEndpoint = $this->storage->usesPathStyleEndpoint();
@@ -128,11 +179,24 @@ class Form extends Component
$this->skipTlsVerification = (bool) $this->storage->skip_tls_verification;
$canSeeCredentials = auth()->user()?->can('update', $this->storage) ?? false;
$this->key = $canSeeCredentials ? $this->storage->key : '';
- $this->secret = $canSeeCredentials ? $this->storage->secret : '';
+ $this->secret = '';
+ $this->hasSavedSecret = $canSeeCredentials && filled($this->storage->secret);
+ $this->privateKeyId = $this->storage->private_key_id;
+ $this->sftpAuth = $this->privateKeyId ? 'key' : 'password';
$this->isUsable = $this->storage->is_usable;
}
}
+ public function render(): View
+ {
+ $isSftp = $this->storage->type === BackupStorageType::Sftp;
+
+ return view('livewire.storage.form', [
+ 'privateKeys' => $isSftp ? $this->privateKeyOptions($this->storage->team_id) : [],
+ 'publicKey' => $isSftp ? $this->selectedPublicKey($this->storage->team_id) : null,
+ ]);
+ }
+
public function mount()
{
$this->syncData(false);
@@ -146,24 +210,27 @@ class Form extends Component
try {
$this->authorize('validateConnection', $this->storage);
- if ($this->endpointPartsChanged) {
- $this->endpoint = DomainUrlParts::compose(...$this->endpointParts);
- }
+ $this->composeEndpoint();
$testedStorage = new S3Storage;
+ $testedStorage->type = $this->storage->type;
$testedStorage->uuid = $this->storage->uuid;
$testedStorage->team_id = $this->storage->team_id;
$testedStorage->unusable_email_sent = $this->storage->unusable_email_sent;
$testedStorage->name = $this->name;
$testedStorage->description = $this->description;
+ // The saved host key applies while the host and the port stay the same; the endpoint setter removes it otherwise.
+ $testedStorage->endpoint = $this->storage->endpoint;
+ $testedStorage->sftp_host_key = $this->storage->sftp_host_key;
$testedStorage->endpoint = $this->endpoint;
$testedStorage->bucket = $this->bucket;
$testedStorage->region = $this->region;
$testedStorage->use_path_style_endpoint = $this->usePathStyleEndpoint;
- $testedStorage->skip_tls_verification = $this->skipTlsVerification;
+ $testedStorage->skip_tls_verification = $testedStorage->isS3() && $this->skipTlsVerification;
$testedStorage->key = $this->key;
- $testedStorage->secret = $this->secret;
+ $testedStorage->private_key_id = $testedStorage->type === BackupStorageType::Sftp ? $this->selectedPrivateKeyId() : null;
+ $testedStorage->secret = $testedStorage->private_key_id ? '' : $this->secretToUse();
- $testedStorage->testConnection();
+ $testedStorage->testConnection(acceptNewHostKey: true);
$switchedToVirtualHostedStyle = $this->usePathStyleEndpoint && ! $testedStorage->usesPathStyleEndpoint();
$this->usePathStyleEndpoint = $testedStorage->usesPathStyleEndpoint();
@@ -171,12 +238,17 @@ class Form extends Component
$this->isUsable = $testedStorage->is_usable;
$this->storage->is_usable = $testedStorage->is_usable;
$this->storage->unusable_email_sent = $testedStorage->unusable_email_sent;
+ if ($testedStorage->endpoint === $this->storage->endpoint) {
+ $this->storage->sftp_host_key = $testedStorage->sftp_host_key;
+ }
$this->storage->save();
$this->dispatch('storage-status-changed', isUsable: $this->isUsable);
- return $this->dispatch('success', 'Connection is working.', $switchedToVirtualHostedStyle
- ? 'The provider rejects path-style URLs, so "Use path-style URLs" is now off. Save to keep it.'
- : 'Tested with "ListObjectsV2" action.');
+ return $this->dispatch('success', 'Connection is working.', match (true) {
+ $switchedToVirtualHostedStyle => 'The provider rejects path-style URLs, so "Use path-style URLs" is now off. Save to keep it.',
+ $testedStorage->isS3() => 'Tested with "ListObjectsV2" action.',
+ default => 'Tested by listing the backup directory.',
+ });
} catch (\Throwable $e) {
if ($testedStorage) {
$this->isUsable = $testedStorage->is_usable;
@@ -195,9 +267,7 @@ class Form extends Component
{
try {
$this->authorize('update', $this->storage);
- if ($this->endpointPartsChanged) {
- $this->endpoint = DomainUrlParts::compose(...$this->endpointParts);
- }
+ $this->composeEndpoint();
DB::transaction(function () {
$this->validate();
@@ -207,7 +277,7 @@ class Form extends Component
$this->storage->save();
// Test connection with new values - if this fails, transaction will rollback
- $this->storage->testConnection(shouldSave: false);
+ $this->storage->testConnection(shouldSave: false, acceptNewHostKey: true);
// If we get here, the connection test succeeded
$this->storage->is_usable = true;
@@ -216,6 +286,8 @@ class Form extends Component
// Update local properties to reflect success
$this->isUsable = true;
+ $this->secret = '';
+ $this->hasSavedSecret = filled($this->storage->secret);
$this->usePathStyleEndpoint = $this->storage->usesPathStyleEndpoint();
});
$this->dispatch('name-changed', uuid: $this->storage->uuid, name: $this->storage->name);
@@ -230,6 +302,20 @@ class Form extends Component
}
}
+ /**
+ * Removes the saved SFTP host key, for a server whose key changed on purpose. The next connection test that an admin
+ * starts (Validate) saves the key that the server presents then; backups do not.
+ */
+ public function resetHostKey(): void
+ {
+ $this->authorize('update', $this->storage);
+
+ $this->storage->forceFill(['sftp_host_key' => null, 'is_usable' => false])->save();
+ $this->isUsable = false;
+ $this->dispatch('storage-status-changed', isUsable: false);
+ $this->dispatch('success', 'Host key removed.', 'Validate the connection to save the current host key of the server.');
+ }
+
public function updatedEndpointParts(): void
{
$this->endpointPartsChanged = true;
diff --git a/app/Livewire/Subscription/Index.php b/app/Livewire/Subscription/Index.php
index 31f2e9141d..e8c05c00d0 100644
--- a/app/Livewire/Subscription/Index.php
+++ b/app/Livewire/Subscription/Index.php
@@ -44,6 +44,8 @@ class Index extends Component
public function stripeCustomerPortal()
{
+ abort_unless(auth()->user()?->isAdminOfTeam(currentTeam()->id), 403);
+
$session = getStripeCustomerPortalSession(currentTeam());
if (is_null($session)) {
return;
diff --git a/app/Livewire/Team/InviteLink.php b/app/Livewire/Team/InviteLink.php
index ad5434f4b7..6d950245a9 100644
--- a/app/Livewire/Team/InviteLink.php
+++ b/app/Livewire/Team/InviteLink.php
@@ -3,6 +3,7 @@
namespace App\Livewire\Team;
use App\Enums\AccessScope;
+use App\Enums\Role;
use App\Livewire\Team\Member\Access;
use App\Models\TeamInvitation;
use App\Models\User;
@@ -13,6 +14,7 @@ use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
+use Illuminate\Validation\Rule;
use Livewire\Attributes\On;
use Livewire\Component;
@@ -24,6 +26,14 @@ class InviteLink extends Component
public string $role = 'member';
+ protected function rules(): array
+ {
+ return [
+ 'email' => 'required|email',
+ 'role' => ['required', Rule::enum(Role::class)],
+ ];
+ }
+
/**
* Grants for an invited member, set by the access editor. Checked again on invite.
*
@@ -31,11 +41,6 @@ class InviteLink extends Component
*/
public array $grants = [];
- protected $rules = [
- 'email' => 'required|email',
- 'role' => 'required|string',
- ];
-
public function mount()
{
$this->email = isDev() ? 'test3@example.com' : '';
@@ -78,11 +83,11 @@ class InviteLink extends Component
$this->validate();
// Prevent privilege escalation: users cannot invite someone with higher privileges
- $userRole = auth()->user()->role();
- if (is_null($userRole) || ($userRole === 'member' && in_array($this->role, ['admin', 'owner']))) {
+ $userRole = Role::tryFrom((string) auth()->user()->role());
+ if (is_null($userRole) || ($userRole === Role::MEMBER && Role::from($this->role)->gt(Role::MEMBER))) {
throw new \Exception('Members cannot invite admins or owners.');
}
- if ($userRole === 'admin' && $this->role === 'owner') {
+ if ($userRole === Role::ADMIN && Role::from($this->role) === Role::OWNER) {
throw new \Exception('Admins cannot invite owners.');
}
@@ -121,7 +126,7 @@ class InviteLink extends Component
$user = User::whereEmail($this->email)->first();
// A user who never set a password cannot log in, so they get a magic link with a new temporary password.
- if (is_null($user) || $user->isUnusedInvitedAccount()) {
+ if (is_null($user) || $user->canReceiveInvitationLoginFrom(currentTeam()->id)) {
$password = Str::password();
if (is_null($user)) {
$user = User::create([
diff --git a/app/Livewire/Terminal/Index.php b/app/Livewire/Terminal/Index.php
index 116db1eed1..45a0cff4f0 100644
--- a/app/Livewire/Terminal/Index.php
+++ b/app/Livewire/Terminal/Index.php
@@ -3,11 +3,14 @@
namespace App\Livewire\Terminal;
use App\Models\Server;
+use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\On;
use Livewire\Component;
class Index extends Component
{
+ use AuthorizesRequests;
+
public $selected_uuid = 'default';
public $servers = [];
@@ -18,6 +21,7 @@ class Index extends Component
public function mount()
{
+ $this->authorize('canAccessTerminal');
$this->servers = Server::isReachable()->get()->filter(function ($server) {
return $server->isTerminalEnabled();
});
@@ -26,6 +30,7 @@ class Index extends Component
public function loadContainers()
{
try {
+ $this->authorize('canAccessTerminal');
$this->containers = $this->getAllActiveContainers();
} catch (\Exception $e) {
return handleError($e, $this);
@@ -71,6 +76,7 @@ class Index extends Component
#[On('connectToContainer')]
public function connectToContainer()
{
+ $this->authorize('canAccessTerminal');
if ($this->selected_uuid === 'default') {
$this->dispatch('error', 'Please select a server or a container.');
diff --git a/app/Livewire/Upgrade.php b/app/Livewire/Upgrade.php
index cfcab71f56..b4a8daee4a 100644
--- a/app/Livewire/Upgrade.php
+++ b/app/Livewire/Upgrade.php
@@ -84,6 +84,11 @@ class Upgrade extends Component
UpdateCoolify::run(manual_update: true);
} catch (\Throwable $e) {
report($e);
+ try {
+ CoolifyUpgradeStatus::writeError(Server::findOrFail(0), $e->getMessage());
+ } catch (\Throwable $statusException) {
+ report($statusException);
+ }
}
})->afterResponse();
diff --git a/app/Mcp/Servers/CoolifyServer.php b/app/Mcp/Servers/CoolifyServer.php
index 0978a78e66..e434911f53 100644
--- a/app/Mcp/Servers/CoolifyServer.php
+++ b/app/Mcp/Servers/CoolifyServer.php
@@ -34,6 +34,7 @@ use App\Mcp\Tools\GetServerResources;
use App\Mcp\Tools\GetService;
use App\Mcp\Tools\GetServiceApplication;
use App\Mcp\Tools\GetServiceDatabase;
+use App\Mcp\Tools\GetVulnerabilities;
use App\Mcp\Tools\ListApplicationPreviews;
use App\Mcp\Tools\ListApplications;
use App\Mcp\Tools\ListAuditEvents;
@@ -70,6 +71,7 @@ use App\Mcp\Tools\ManageDestination;
use App\Mcp\Tools\ManageDns;
use App\Mcp\Tools\ManageEnv;
use App\Mcp\Tools\ManageGitSource;
+use App\Mcp\Tools\ManageImageScanning;
use App\Mcp\Tools\ManageIntegrationTokens;
use App\Mcp\Tools\ManageNotifications;
use App\Mcp\Tools\ManagePreview;
@@ -118,6 +120,7 @@ Then: list_*/get_* for details. Debug is DB-first:
- list_deployments → get_deployment(include_log_summary=true)
- list_env_keys / list_shared_env_keys (names only, never values)
- get_logs only if status is running; on failure use reason + next_tools (do not loop)
+- get_vulnerabilities — image vulnerability scan results (list for the team, resource for one app/service/database); manage_image_scanning turns scanning on or off
Lifecycle (requires token ability **deploy**):
- control (start|stop|restart; stop needs confirm=true)
@@ -128,7 +131,7 @@ Changes (requires token ability **write**, or **delete** for delete actions): ma
Prompts: troubleshoot_application, explain_failed_deploy.
Resources: coolify://overview, coolify://application/{uuid}.
-Responses: `{ data, _actions?, _pagination? }`. Private keys, S3 secrets, tokens, passwords, and webhook URLs are never returned. Env values only with get_env_values (read:sensitive). Full deploy logs are never returned; optional log summaries are best-effort redacted only.
+Responses: `{ data, _actions?, _pagination? }`. Private keys, storage secrets, tokens, passwords, and webhook URLs are never returned. Env values only with get_env_values (read:sensitive). Full deploy logs are never returned; optional log summaries are best-effort redacted only.
MD;
/**
@@ -218,6 +221,8 @@ MD;
ManageNotifications::class,
ListAuditEvents::class,
GetServerMetrics::class,
+ GetVulnerabilities::class,
+ ManageImageScanning::class,
];
/**
@@ -244,6 +249,7 @@ MD;
ManageTags::class,
ManageServer::class,
ManageProxy::class,
+ ManageImageScanning::class,
ManageCloudflareTunnel::class,
DockerCleanup::class,
ManageDestination::class,
diff --git a/app/Mcp/Tools/CancelDeployment.php b/app/Mcp/Tools/CancelDeployment.php
index a7cb96ef84..80863d9678 100644
--- a/app/Mcp/Tools/CancelDeployment.php
+++ b/app/Mcp/Tools/CancelDeployment.php
@@ -4,6 +4,7 @@ namespace App\Mcp\Tools;
use App\Actions\Application\CleanupCancelledDeployment;
use App\Enums\ApplicationDeploymentStatus;
+use App\Jobs\SyncGithubDeploymentJob;
use App\Mcp\Concerns\BuildsResponse;
use App\Mcp\Concerns\ChecksTokenAbilities;
use App\Mcp\Concerns\ResolvesTeam;
@@ -88,6 +89,13 @@ class CancelDeployment extends Tool
// Cancellation is still recorded even if the helper container cleanup fails.
}
+ try {
+ // The query update above fires no model event.
+ SyncGithubDeploymentJob::dispatchFor($deployment);
+ } catch (\Throwable $e) {
+ \Log::warning("Failed to queue the GitHub deployment sync after cancelling deployment {$deployment->id}: {$e->getMessage()}");
+ }
+
auditLog('mcp.deployment.cancelled', [
'team_id' => $teamId,
'deployment_uuid' => $uuid,
diff --git a/app/Mcp/Tools/CoolifyHelp.php b/app/Mcp/Tools/CoolifyHelp.php
index 1753e315c3..9b7b8f7b23 100644
--- a/app/Mcp/Tools/CoolifyHelp.php
+++ b/app/Mcp/Tools/CoolifyHelp.php
@@ -91,9 +91,9 @@ class CoolifyHelp extends Tool
],
],
'backup_database' => [
- 'description' => 'Back up a database to S3',
+ 'description' => 'Back up a database to an S3, SFTP, FTPS, or WebDAV storage',
'steps' => [
- ['tool' => 'manage_s3_storage', 'action' => 'create', 'note' => 'Add the S3 storage (the secret is never returned).'],
+ ['tool' => 'manage_s3_storage', 'action' => 'create', 'note' => 'Add the storage with its type (s3, sftp, ftps, webdav; bucket and region only for s3). The secret is never returned.'],
['tool' => 'manage_s3_storage', 'action' => 'validate', 'note' => 'Check the credentials.'],
['tool' => 'manage_backup', 'action' => 'create', 'note' => 'Set frequency, s3_storage_uuid, save_s3=true; backup_now=true runs it at once.'],
['tool' => 'list_backup_executions', 'note' => 'Check the result.'],
diff --git a/app/Mcp/Tools/GetVulnerabilities.php b/app/Mcp/Tools/GetVulnerabilities.php
new file mode 100644
index 0000000000..6fce2983e0
--- /dev/null
+++ b/app/Mcp/Tools/GetVulnerabilities.php
@@ -0,0 +1,37 @@
+ ['method' => 'GET', 'path' => 'vulnerabilities', 'ability' => 'read', 'description' => 'every resource with its security status, worst first (data: status, comma-separated: update_now, update_available, no_fix, check_needed, ok, not_checked)'],
+ 'resource' => ['method' => 'GET', 'paths' => [
+ 'application' => 'applications/{uuid}/vulnerabilities',
+ 'service' => 'services/{uuid}/vulnerabilities',
+ 'database' => 'databases/{uuid}/vulnerabilities',
+ ], 'ability' => 'read', 'description' => 'status, scanned images, and vulnerabilities of one resource, worst first (data: state fixed|no_fix|ignored, severity, image, search, page, per_page max 200; state and severity are comma-separated)'],
+ ];
+ }
+
+ protected function pathParams(): array
+ {
+ return [
+ 'uuid' => 'Resource UUID (resource action).',
+ ];
+ }
+
+ protected function dataDescription(): string
+ {
+ return 'Filters, sent as query parameters: status (list); state, severity, image, search, page, per_page (resource).';
+ }
+}
diff --git a/app/Mcp/Tools/ManageApplication.php b/app/Mcp/Tools/ManageApplication.php
index ea3d0b593e..0029362365 100644
--- a/app/Mcp/Tools/ManageApplication.php
+++ b/app/Mcp/Tools/ManageApplication.php
@@ -16,7 +16,7 @@ class ManageApplication extends RestActionTool
protected function actions(): array
{
return [
- 'update' => ['method' => 'PATCH', 'path' => 'applications/{uuid}', 'ability' => 'write', 'description' => 'update application settings (data: same fields as the REST update, e.g. name, domains, git_branch, ports_exposes, build_command, is_container_label_readonly_enabled, include_coolify_default_variables; instant_deploy and manual_webhook_secret_* need deploy ability)'],
+ 'update' => ['method' => 'PATCH', 'path' => 'applications/{uuid}', 'ability' => 'write', 'description' => 'update application settings (data: same fields as the REST update, e.g. name, domains, git_branch, ports_exposes, build_command, is_container_label_readonly_enabled, include_coolify_default_variables, is_image_scan_enabled; instant_deploy and manual_webhook_secret_* need deploy ability)'],
'delete' => ['method' => 'DELETE', 'path' => 'applications/{uuid}', 'ability' => 'delete', 'confirm' => true, 'description' => 'delete the application and its containers (data: delete_volumes, delete_connected_networks, delete_configurations, docker_cleanup)'],
'clone' => ['method' => 'POST', 'path' => 'applications/{uuid}/clone', 'ability' => 'write', 'description' => 'clone the application to a destination of the team (data: destination_uuid, name, clone_volumes; clone_volumes needs deploy ability)'],
'move' => ['method' => 'POST', 'path' => 'applications/{uuid}/move', 'ability' => 'write', 'confirm' => true, 'description' => 'move the application to another environment of the team (data: environment_uuid)'],
diff --git a/app/Mcp/Tools/ManageImageScanning.php b/app/Mcp/Tools/ManageImageScanning.php
new file mode 100644
index 0000000000..b7a1ff11d7
--- /dev/null
+++ b/app/Mcp/Tools/ManageImageScanning.php
@@ -0,0 +1,34 @@
+ ['method' => 'GET', 'path' => 'servers/{uuid}/image-scanning', 'ability' => 'read', 'description' => 'whether the instance allows scanning, whether the server scans, and the scanner state on the server'],
+ 'update_server' => ['method' => 'PATCH', 'path' => 'servers/{uuid}/image-scanning', 'ability' => 'write', 'description' => 'turn scanning of the server on or off (data: is_image_scan_enabled); turning it on gets the scanner on the server'],
+ 'update_team' => ['method' => 'PATCH', 'path' => 'team', 'ability' => 'write', 'description' => 'turn scanning on or off for servers added to the team from now on (data: scan_images_on_new_servers)'],
+ ];
+ }
+
+ protected function pathParams(): array
+ {
+ return [
+ 'uuid' => 'Server UUID (get_server and update_server).',
+ ];
+ }
+
+ protected function dataDescription(): string
+ {
+ return 'Request body: is_image_scan_enabled (update_server), scan_images_on_new_servers (update_team).';
+ }
+}
diff --git a/app/Mcp/Tools/ManageS3Storage.php b/app/Mcp/Tools/ManageS3Storage.php
index 2e8c863fd7..57fb215f77 100644
--- a/app/Mcp/Tools/ManageS3Storage.php
+++ b/app/Mcp/Tools/ManageS3Storage.php
@@ -11,29 +11,29 @@ class ManageS3Storage extends RestActionTool
{
protected string $name = 'manage_s3_storage';
- protected string $description = 'List, get, add, update, delete, or test S3 storages of the authenticated team. The secret key is never returned. Requires read ability for list/get, write ability for changes. Same validation and permissions as the REST API.';
+ protected string $description = 'List, get, add, update, delete, or test backup storages of the authenticated team: S3, SFTP, FTPS, or WebDAV (type, default s3; it cannot change after create). The secret key or password is never returned. Requires read ability for list/get, write ability for changes. Same validation and permissions as the REST API.';
protected function actions(): array
{
return [
- 'list' => ['method' => 'GET', 'path' => 's3-storages', 'ability' => 'read', 'description' => 'S3 storages of the team'],
- 'get' => ['method' => 'GET', 'path' => 's3-storages/{uuid}', 'ability' => 'read', 'description' => 'one S3 storage'],
- 'create' => ['method' => 'POST', 'path' => 's3-storages', 'ability' => 'write', 'description' => 'add an S3 storage (data: name, endpoint, bucket, region, key, secret, description, is_usable)'],
- 'update' => ['method' => 'PATCH', 'path' => 's3-storages/{uuid}', 'ability' => 'write', 'description' => 'update an S3 storage (data: same fields as create, all optional)'],
- 'delete' => ['method' => 'DELETE', 'path' => 's3-storages/{uuid}', 'ability' => 'delete', 'confirm' => true, 'description' => 'delete an S3 storage'],
- 'validate' => ['method' => 'POST', 'path' => 's3-storages/{uuid}/validate', 'ability' => 'write', 'description' => 'test the connection and save the result'],
+ 'list' => ['method' => 'GET', 'path' => 's3-storages', 'ability' => 'read', 'description' => 'backup storages of the team'],
+ 'get' => ['method' => 'GET', 'path' => 's3-storages/{uuid}', 'ability' => 'read', 'description' => 'one backup storage (SFTP: private_key_uuid and the pinned sftp_host_key)'],
+ 'create' => ['method' => 'POST', 'path' => 's3-storages', 'ability' => 'write', 'description' => 'add a storage (data: type s3 | sftp | ftps | webdav, name, endpoint, key, secret, description, is_usable; S3 only: bucket, region, use_path_style_endpoint; SFTP only: private_key_uuid instead of secret)'],
+ 'update' => ['method' => 'PATCH', 'path' => 's3-storages/{uuid}', 'ability' => 'write', 'description' => 'update a storage (data: same fields as create except type, all optional; only the fields of the storage type)'],
+ 'delete' => ['method' => 'DELETE', 'path' => 's3-storages/{uuid}', 'ability' => 'delete', 'confirm' => true, 'description' => 'delete a storage'],
+ 'validate' => ['method' => 'POST', 'path' => 's3-storages/{uuid}/validate', 'ability' => 'write', 'description' => 'test the connection and save the result (SFTP: the first success pins the host key)'],
];
}
protected function pathParams(): array
{
return [
- 'uuid' => 'S3 storage UUID (not needed for list and create).',
+ 'uuid' => 'Storage UUID (not needed for list and create).',
];
}
protected function dataDescription(): string
{
- return 'Request body, same fields as the REST API: name, description, endpoint, bucket, region, key, secret, is_usable.';
+ return 'Request body, same fields as the REST API: type (s3, sftp, ftps, webdav; create only, default s3), name, description, endpoint, key, secret, is_usable. S3 only: bucket, region, use_path_style_endpoint. SFTP only: private_key_uuid (a private key of the team, instead of the password). Endpoint: S3 URL; sftp://host:port/directory; ftps://host:port/directory; WebDAV https URL of the backup directory. key is the access key (S3) or the username; secret is the secret key (S3) or the password.';
}
}
diff --git a/app/Models/Application.php b/app/Models/Application.php
index 5e67f0110e..d74fe35d0d 100644
--- a/app/Models/Application.php
+++ b/app/Models/Application.php
@@ -17,6 +17,7 @@ use App\Traits\Auditable;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasComposeVolumeWarnings;
use App\Traits\HasConfiguration;
+use App\Traits\HasImageScans;
use App\Traits\HasMaintenancePage;
use App\Traits\HasMetrics;
use App\Traits\HasNoindexDomains;
@@ -55,6 +56,7 @@ use Symfony\Component\Yaml\Yaml;
'git_branch' => ['type' => 'string', 'description' => 'Git branch.'],
'git_commit_sha' => ['type' => 'string', 'description' => 'Git commit SHA.'],
'git_full_url' => ['type' => 'string', 'nullable' => true, 'description' => 'Git full URL.'],
+ 'git_ssh_known_hosts' => ['type' => 'string', 'nullable' => true, 'description' => 'SSH host keys (known_hosts lines) of self-hosted Git hosts that git over SSH accepts. Saved on the first deployment.'],
'git_https_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTPS access to a private repository on a custom Git host. The password or token is never returned.'],
'docker_registry_image_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Docker registry image name.'],
'docker_registry_image_tag' => ['type' => 'string', 'nullable' => true, 'description' => 'Docker registry image tag.'],
@@ -140,7 +142,7 @@ use Symfony\Component\Yaml\Yaml;
class Application extends BaseModel
{
/** @use HasFactory */
- use Auditable, ClearsGlobalSearchCache, HasComposeVolumeWarnings, HasConfiguration, HasFactory, HasMaintenancePage, HasMetrics, HasNoindexDomains, HasSafeStringAttribute, HasSecretManager, ReleasesManagedDnsRecords, ScopedByAccessGrants, SoftDeletes;
+ use Auditable, ClearsGlobalSearchCache, HasComposeVolumeWarnings, HasConfiguration, HasFactory, HasImageScans, HasMaintenancePage, HasMetrics, HasNoindexDomains, HasSafeStringAttribute, HasSecretManager, ReleasesManagedDnsRecords, ScopedByAccessGrants, SoftDeletes;
public const MAX_DOCKER_COMPOSE_SIZE_BYTES = 5 * 1024 * 1024;
@@ -162,6 +164,7 @@ class Application extends BaseModel
'git_branch',
'git_commit_sha',
'git_full_url',
+ 'git_ssh_known_hosts',
'git_https_username',
'git_https_password',
'docker_registry_image_name',
@@ -215,6 +218,7 @@ class Application extends BaseModel
'docker_compose',
'docker_compose_raw',
'docker_compose_domains',
+ 'docker_compose_image_overrides',
'domain_dns_statuses',
'docker_compose_custom_start_command',
'docker_compose_custom_build_command',
@@ -311,6 +315,7 @@ class Application extends BaseModel
'noindex_domains' => 'array',
'domain_dns_statuses' => 'array',
'domain_port_overrides' => 'array',
+ 'docker_compose_image_overrides' => 'array',
'restart_count' => 'integer',
'max_restart_count' => 'integer',
'restart_limit_reached' => 'boolean',
@@ -1636,6 +1641,90 @@ class Application extends BaseModel
return convertGitUrl($this->git_repository, $this->deploymentType(), $this->source);
}
+ /**
+ * The host and port that git connects to over SSH, or null when the repository does not use SSH.
+ *
+ * @return array{host: string, port: int}|null
+ */
+ public function gitSshHost(): ?array
+ {
+ ['repository' => $repository, 'port' => $port] = $this->customRepository();
+ if (str_contains($repository, '://')) {
+ $url = parse_url($repository) ?: [];
+ if (($url['scheme'] ?? null) !== 'ssh') {
+ return null;
+ }
+ $host = $url['host'] ?? null;
+ } else {
+ $host = parseScpStyleGitUrl($repository)['host'] ?? null;
+ }
+ if ($this->deploymentType() === 'source' && filled($this->source?->custom_port)) {
+ $port = $this->source->custom_port;
+ }
+ $host = trim((string) $host, '[]');
+ if (preg_match('/\A[A-Za-z0-9._:][A-Za-z0-9._:-]*\z/', $host) !== 1 || ! is_numeric($port)) {
+ return null;
+ }
+
+ return ['host' => $host, 'port' => (int) $port];
+ }
+
+ /**
+ * The SSH hosts whose keys "Fetch current host key" reads: the repository host and the hosts of the
+ * saved keys (for example submodule hosts), without the hosts of gitHostsWithPublishedKeys().
+ *
+ * @return list
+ */
+ public function gitSshHostsToScan(): array
+ {
+ $hosts = array_filter([$this->gitSshHost()]);
+ foreach (gitKnownHostsLines($this->git_ssh_known_hosts) as $line) {
+ $entry = explode(',', explode(' ', $line)[0])[0];
+ $hosts[] = preg_match('/\A\[(.+)\]:([0-9]+)\z/', $entry, $match) === 1
+ ? ['host' => $match[1], 'port' => (int) $match[2]]
+ : ['host' => $entry, 'port' => 22];
+ }
+
+ return collect($hosts)
+ ->filter(fn (array $host) => preg_match('/\A[A-Za-z0-9._:][A-Za-z0-9._:-]*\z/', $host['host']) === 1
+ && ! in_array(strtolower($host['host']), gitHostsWithPublishedKeys(), true))
+ ->unique(fn (array $host) => strtolower($host['host']).':'.$host['port'])
+ ->values()
+ ->all();
+ }
+
+ /**
+ * Reads the SSH host keys that the hosts of gitSshHostsToScan() send now, from where the deployment helper
+ * container runs: a build server when the application builds on one, else the server of the application
+ * on the Docker network of the destination. The first server that reads keys is used.
+ *
+ * @return list known_hosts lines
+ */
+ public function fetchGitSshHostKeys(): array
+ {
+ $hosts = $this->gitSshHostsToScan();
+ if ($hosts === []) {
+ return [];
+ }
+ $image = escapeshellarg(coolifyHelperImage().':'.getHelperVersion());
+ $server = $this->destination->server;
+ $targets = $this->buildServerCandidates($server)
+ ->map(fn (Server $buildServer) => [$buildServer, ''])
+ ->push([$server, '--network '.escapeshellarg($this->destination->network).' ']);
+ foreach ($targets as [$target, $network]) {
+ $commands = array_map(
+ fn (array $host) => "docker run --rm {$network}--entrypoint ssh-keyscan {$image} -T 10 -p {$host['port']} -- ".escapeshellarg($host['host']).' 2>/dev/null || true',
+ $hosts,
+ );
+ $lines = gitKnownHostsLines(instant_remote_process($commands, $target, false));
+ if ($lines !== []) {
+ return $lines;
+ }
+ }
+
+ return [];
+ }
+
public function generateBaseDir(string $uuid)
{
return "/artifacts/{$uuid}";
@@ -1659,7 +1748,7 @@ class Application extends BaseModel
? (str_starts_with($resolvedGitSshCommand, 'GIT_SSH_COMMAND=')
? $resolvedGitSshCommand
: 'GIT_SSH_COMMAND="'.$resolvedGitSshCommand.'"')
- : 'GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"';
+ : 'GIT_SSH_COMMAND="'.gitSshCommand($deployment_uuid).'"';
$git_clone_command = $this->gitLfsSkipSmudgePrefix($onlyCheckout).$git_clone_command;
@@ -1710,7 +1799,7 @@ class Application extends BaseModel
{
try {
['commands' => $lsRemoteCommand] = $this->generateGitLsRemoteCommands(deployment_uuid: $deployment_uuid, exec_in_docker: false);
- instant_remote_process([$this->gitCommandsAsShellCommand($lsRemoteCommand)], $this->destination->server, true);
+ instant_remote_process([$this->serverGitSshSetupPrefix($deployment_uuid).$this->gitCommandsAsShellCommand($lsRemoteCommand)], $this->destination->server, true);
return [
'is_accessible' => true,
@@ -1810,7 +1899,7 @@ class Application extends BaseModel
$private_key = base64_encode($private_key);
$gitlabPort = $oauthSource->custom_port ?? 22;
$escapedCustomRepository = str_replace("'", "'\\''", $customRepository);
- $sshCommand = gitSshCommand($gitlabPort, $customSshKeyLocation);
+ $sshCommand = gitSshCommand($deployment_uuid, $gitlabPort, $customSshKeyLocation);
$base_command = "GIT_SSH_COMMAND=\"{$sshCommand}\" {$base_command} '{$escapedCustomRepository}'";
$commands = $this->gitSshKeySetupCommands($deployment_uuid, $private_key, $exec_in_docker);
@@ -1856,7 +1945,7 @@ class Application extends BaseModel
// When used with executeInDocker (which uses bash -c '...'), we need to escape for bash context
// Replace ' with '\'' to safely escape within single-quoted bash strings
$escapedCustomRepository = str_replace("'", "'\\''", $customRepository);
- $sshCommand = gitSshCommand($customPort, $customSshKeyLocation);
+ $sshCommand = gitSshCommand($deployment_uuid, $customPort, $customSshKeyLocation);
$base_command = "GIT_SSH_COMMAND=\"{$sshCommand}\" {$base_command} '{$escapedCustomRepository}'";
$commands = $this->gitSshKeySetupCommands($deployment_uuid, $private_key, $exec_in_docker);
@@ -1881,6 +1970,10 @@ class Application extends BaseModel
if ($gitConfigOption) {
$base_command = "git {$gitConfigOption} ls-remote";
}
+ if ($this->gitUsesSsh()) {
+ $sshCommand = gitSshCommand($deployment_uuid, $customPort, '/root/.ssh/id_rsa', identitiesOnly: false);
+ $base_command = "GIT_SSH_COMMAND=\"{$sshCommand}\" {$base_command}";
+ }
$base_command = "{$base_command} {$escapedCustomRepository}";
if ($exec_in_docker) {
@@ -1979,7 +2072,7 @@ class Application extends BaseModel
$commands = collect([]);
if (! $execInDocker) {
- $commands->push($this->gitCommand("trap 'rm -f {$customSshKeyLocation}' EXIT"));
+ $commands->push($this->gitCommand("trap 'rm -f {$customSshKeyLocation} ".gitSshConfigFile($deploymentUuid).' '.gitKnownHostsFile($deploymentUuid)."' EXIT"));
}
$commands->push($this->gitCommand($execInDocker ? executeInDocker($deploymentUuid, 'mkdir -p /root/.ssh') : 'mkdir -p /root/.ssh'));
@@ -1995,6 +2088,42 @@ class Application extends BaseModel
return $commands;
}
+ /**
+ * gitSshSetupCommand() and ' && ' for git commands that run on the server, not in a helper container.
+ * The files are removed when the script exits. Git over HTTP(S) gets no setup: for a non-root SSH user
+ * the setup makes the sudo parser run the whole line with bash, which the server may not have.
+ */
+ private function serverGitSshSetupPrefix(string $uuid): string
+ {
+ if (! $this->gitUsesSsh()) {
+ return '';
+ }
+
+ return "trap 'rm -f ".gitSshConfigFile($uuid).' '.gitKnownHostsFile($uuid)."' EXIT && ".gitSshSetupCommand($uuid, $this->git_ssh_known_hosts, onServer: true).' && ';
+ }
+
+ /**
+ * Whether git can reach the repository over SSH. Only an http(s) URL without a deploy key, a GitHub App,
+ * and a connected GitLab or Gitea source (which clone with a token over HTTPS) never use SSH.
+ */
+ private function gitUsesSsh(): bool
+ {
+ $deploymentType = $this->deploymentType();
+ if ($deploymentType === 'deploy_key') {
+ return true;
+ }
+ if ($deploymentType === 'source') {
+ if ($this->source instanceof GithubApp) {
+ return false;
+ }
+ if (($this->source instanceof GitlabApp || $this->source instanceof GiteaApp) && $this->source->isConnected()) {
+ return false;
+ }
+ }
+
+ return preg_match('#\Ahttps?://#i', (string) $this->git_repository) !== 1;
+ }
+
/**
* The COOLIFY_BRANCH value of a deployment. Preview deployments use the pull request ref.
*/
@@ -2009,6 +2138,7 @@ class Application extends BaseModel
public function generateGitImportCommands(string $deployment_uuid, int $pull_request_id = 0, ?string $git_type = null, bool $exec_in_docker = true, bool $only_checkout = false, ?string $custom_base_dir = null, ?string $commit = null)
{
+ $commit = $commit ?: ($this->git_commit_sha ?: 'HEAD');
$branch = $this->git_branch;
['repository' => $customRepository, 'port' => $customPort] = $this->customRepository();
$baseDir = $custom_base_dir ?? $this->generateBaseDir($deployment_uuid);
@@ -2085,7 +2215,7 @@ class Application extends BaseModel
if ($pull_request_id !== 0) {
$branch = "pull/{$pull_request_id}/head:$pr_branch_name";
- $git_checkout_command = $this->buildGitCheckoutCommand($pr_branch_name, gitConfigOptions: $gitConfigOption ?? null);
+ $git_checkout_command = $this->buildGitCheckoutCommand($pr_branch_name, gitSshCommand($deployment_uuid), $gitConfigOption ?? null);
$escapedPrBranch = escapeshellarg($branch);
$git_pr_command = "{$this->gitLfsSkipSmudgePrefix($only_checkout)}cd {$escapedBaseDir} && git fetch origin {$escapedPrBranch} && $git_checkout_command";
if ($exec_in_docker) {
@@ -2131,7 +2261,7 @@ class Application extends BaseModel
} else {
$commands->push("echo 'Checking out {$branch}'");
}
- $git_checkout_command = $this->buildGitCheckoutCommand($pr_branch_name, gitConfigOptions: $gitConfigOption);
+ $git_checkout_command = $this->buildGitCheckoutCommand($pr_branch_name, gitSshCommand($deployment_uuid), $gitConfigOption);
$escapedPrBranch = escapeshellarg($branch);
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && git {$gitConfigOption} fetch origin {$escapedPrBranch} && {$git_checkout_command}";
}
@@ -2157,7 +2287,7 @@ class Application extends BaseModel
$private_key = base64_encode($private_key);
$gitlabPort = $oauthSource->custom_port ?? 22;
$escapedCustomRepository = escapeshellarg($customRepository);
- $gitlabSshCommand = gitSshCommand($gitlabPort, $customSshKeyLocation);
+ $gitlabSshCommand = gitSshCommand($deployment_uuid, $gitlabPort, $customSshKeyLocation);
$gitlabGitSshCommand = "GIT_SSH_COMMAND=\"{$gitlabSshCommand}\"";
$git_clone_command_base = "{$gitlabGitSshCommand} {$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}";
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base, commit: $commit, gitSshCommand: $gitlabSshCommand, baseDir: $baseDir, onlyCheckout: $only_checkout);
@@ -2214,7 +2344,7 @@ class Application extends BaseModel
}
$private_key = base64_encode($private_key);
$escapedCustomRepository = escapeshellarg($customRepository);
- $deployKeySshCommand = gitSshCommand($customPort, $customSshKeyLocation);
+ $deployKeySshCommand = gitSshCommand($deployment_uuid, $customPort, $customSshKeyLocation);
$deployKeyGitSshCommand = "GIT_SSH_COMMAND=\"{$deployKeySshCommand}\"";
$git_clone_command_base = "{$deployKeyGitSshCommand} {$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}";
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base, commit: $commit, gitSshCommand: $deployKeySshCommand, baseDir: $baseDir, onlyCheckout: $only_checkout);
@@ -2267,7 +2397,7 @@ class Application extends BaseModel
$git_clone_command = str_replace('git clone', "{$gitCommand} clone", $git_clone_command);
$git_clone_command = "{$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}";
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true, commit: $commit, gitConfigOptions: $gitConfigOption, baseDir: $baseDir, onlyCheckout: $only_checkout);
- $otherSshCommand = gitSshCommand($customPort, '/root/.ssh/id_rsa', identitiesOnly: false);
+ $otherSshCommand = gitSshCommand($deployment_uuid, $customPort, '/root/.ssh/id_rsa', identitiesOnly: false);
if ($pull_request_id !== 0) {
if ($git_type === 'gitlab') {
@@ -2392,6 +2522,75 @@ class Application extends BaseModel
return is_array($services) ? $services : [];
}
+ /**
+ * The image that a security update set for a Compose service, while the compose file still names the image it
+ * replaced. When the user changes that image in the compose file (in Git), the compose file wins again.
+ */
+ public function composeImageOverride(string $serviceName, ?string $composeImage): ?string
+ {
+ $override = data_get($this->docker_compose_image_overrides, $serviceName);
+ if (! is_array($override) || ! is_string($override['from'] ?? null) || ! is_string($override['to'] ?? null)) {
+ return null;
+ }
+
+ return $composeImage !== null && trim($composeImage) === trim($override['from']) ? $override['to'] : null;
+ }
+
+ /**
+ * The overrides that still apply to the compose file, keyed by the service name.
+ *
+ * @return array
+ */
+ public function activeComposeImageOverrides(): array
+ {
+ try {
+ $services = data_get(Yaml::parse((string) $this->docker_compose_raw), 'services', []);
+ } catch (\Throwable) {
+ return [];
+ }
+
+ $active = [];
+ foreach ((array) $this->docker_compose_image_overrides as $serviceName => $override) {
+ $image = is_array($services) ? ($services[$serviceName]['image'] ?? null) : null;
+ if ($this->composeImageOverride((string) $serviceName, is_string($image) ? $image : null) !== null) {
+ $active[(string) $serviceName] = ['from' => (string) $override['from'], 'to' => (string) $override['to']];
+ }
+ }
+
+ return $active;
+ }
+
+ /**
+ * Removes the overrides whose image the compose file no longer names, so a later compose file that names that
+ * image again does not turn them on. Keeps them all when the compose file cannot be read.
+ */
+ public function forgetStaleComposeImageOverrides(): void
+ {
+ $stored = (array) $this->docker_compose_image_overrides;
+ if ($stored === []) {
+ return;
+ }
+
+ try {
+ $services = data_get(Yaml::parse((string) $this->docker_compose_raw), 'services');
+ } catch (\Throwable) {
+ return;
+ }
+ if (! is_array($services)) {
+ return;
+ }
+
+ $active = $this->activeComposeImageOverrides();
+ if (count($active) === count($stored)) {
+ return;
+ }
+
+ $value = $active === [] ? null : $active;
+ static::query()->whereKey($this->getKey())->update(['docker_compose_image_overrides' => $value === null ? null : json_encode($value)]);
+ $this->docker_compose_image_overrides = $value;
+ $this->syncOriginalAttribute('docker_compose_image_overrides');
+ }
+
public function parse(int $pull_request_id = 0, ?int $preview_id = null, ?string $commit = null)
{
if ((int) $this->compose_parsing_version >= 3) {
@@ -2404,7 +2603,8 @@ class Application extends BaseModel
}
/**
- * Logs a failed git command on the server and returns its last error line for the user.
+ * Logs a failed git command on the server and returns its last error line for the user, or a hint
+ * to review the SSH host key when the host key check failed.
* Credentials in URLs (such as GitHub App or GitLab tokens) are removed from both, and the
* line is HTML-escaped because error toasts render HTML.
*/
@@ -2417,6 +2617,13 @@ class Application extends BaseModel
'error' => $errorOutput,
]);
+ if (str_contains($errorOutput, 'you have requested strict checking')) {
+ return ' Reason: '.e('No SSH host key is saved for this Git host, and the SSH client on the server (OpenSSH older than 7.6) cannot trust a new host key on first use. Open Source > SSH host keys and use Fetch current host key. Compare the fingerprint with the Git host administrator before you accept it.');
+ }
+ if (str_contains($errorOutput, 'Host key verification failed')) {
+ return ' Reason: '.e('The Git host sent an SSH host key that is not the saved or published key. Open Source > SSH host keys and use Fetch current host key. Compare the fingerprint with the Git host administrator before you accept it.');
+ }
+
$reason = collect(explode("\n", $errorOutput))->map(fn (string $line) => trim($line))->filter()->last();
return $reason ? ' Reason: '.e(str($reason)->limit(300)->value()) : '';
@@ -2442,7 +2649,7 @@ class Application extends BaseModel
// Callers read data from stdout after the checkout, so progress lines of the checkout go to stderr.
return [
- 'command' => 'exec >&2 && '.str_replace(' clone ', ' clone --quiet ', $this->gitCommandsAsShellCommand($cloneCommand)),
+ 'command' => 'exec >&2 && '.$this->serverGitSshSetupPrefix($uuid).str_replace(' clone ', ' clone --quiet ', $this->gitCommandsAsShellCommand($cloneCommand)),
'git' => $git,
];
}
@@ -2658,15 +2865,14 @@ class Application extends BaseModel
);
}
- protected function buildGitCheckoutCommand($target, ?string $gitSshCommand = null, ?string $gitConfigOptions = null): string
+ protected function buildGitCheckoutCommand(string $target, string $gitSshCommand, ?string $gitConfigOptions = null): string
{
$escapedTarget = escapeshellarg($target);
$gitCommand = $gitConfigOptions ? "git {$gitConfigOptions}" : 'git';
$command = "{$gitCommand} checkout {$escapedTarget}";
if ($this->settings->is_git_submodules_enabled) {
- $sshCommand = $gitSshCommand ?? 'ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null';
- $command .= " && GIT_SSH_COMMAND=\"{$sshCommand}\" {$gitCommand} submodule update --init --recursive";
+ $command .= " && GIT_SSH_COMMAND=\"{$gitSshCommand}\" {$gitCommand} submodule update --init --recursive";
}
return $command;
@@ -2974,4 +3180,15 @@ class Application extends BaseModel
throw new \Exception('Failed to update application settings');
}
}
+
+ /**
+ * The server of a deployment when it is the destination server or an additional server of the application.
+ */
+ public function deploymentServer(ApplicationDeploymentQueue $deployment): ?Server
+ {
+ return collect([$this->destination?->server])
+ ->concat($this->additional_servers()->get())
+ ->filter(fn ($server): bool => $server instanceof Server)
+ ->first(fn (Server $server): bool => (int) $server->id === (int) $deployment->server_id);
+ }
}
diff --git a/app/Models/ApplicationDeploymentQueue.php b/app/Models/ApplicationDeploymentQueue.php
index ccd53a8dbd..13e18120cb 100644
--- a/app/Models/ApplicationDeploymentQueue.php
+++ b/app/Models/ApplicationDeploymentQueue.php
@@ -4,6 +4,7 @@ namespace App\Models;
use App\Casts\EncryptedArrayCast;
use App\Enums\ApplicationDeploymentStatus;
+use App\Jobs\SyncGithubDeploymentJob;
use App\Services\Access\AccessResolver;
use App\Services\Security\SensitiveDataRedactor;
use Illuminate\Database\Eloquent\Builder;
@@ -105,6 +106,22 @@ class ApplicationDeploymentQueue extends Model
]);
});
+ static::updated(function (ApplicationDeploymentQueue $deployment): void {
+ if (! $deployment->wasChanged('status')) {
+ return;
+ }
+
+ // A failed GitHub sync must not stop the status transition or the deployment queue.
+ try {
+ SyncGithubDeploymentJob::dispatchFor($deployment);
+ } catch (\Throwable $e) {
+ Log::warning('Failed to queue the GitHub deployment sync.', [
+ 'deployment_uuid' => $deployment->deployment_uuid,
+ 'error' => $e->getMessage(),
+ ]);
+ }
+ });
+
static::updated(function (ApplicationDeploymentQueue $deployment): void {
if (! auth()->check()
|| ! $deployment->wasChanged('status')
@@ -155,6 +172,8 @@ class ApplicationDeploymentQueue extends Model
'horizon_job_id',
'horizon_job_worker',
'finished_at',
+ 'scan_gate_result',
+ 'scan_gate_bypassed_by',
'deploy_started_at',
];
@@ -175,6 +194,7 @@ class ApplicationDeploymentQueue extends Model
protected $casts = [
'pull_request_id' => 'integer',
'finished_at' => 'datetime',
+ 'scan_gate_result' => 'array',
'deploy_started_at' => 'datetime',
'configuration_snapshot' => EncryptedArrayCast::class,
'configuration_diff' => EncryptedArrayCast::class,
@@ -250,6 +270,22 @@ class ApplicationDeploymentQueue extends Model
};
}
+ /**
+ * The image scan gate stopped this deployment. The status stays failed, the UI shows "Blocked".
+ */
+ public function isImageScanBlocked(): bool
+ {
+ return (bool) data_get($this->scan_gate_result, 'blocked', false);
+ }
+
+ /**
+ * The image scan could not run and the deployment went on without it.
+ */
+ public function isImageScanSkipped(): bool
+ {
+ return ! $this->isImageScanBlocked() && filled(data_get($this->scan_gate_result, 'error'));
+ }
+
public function commitMessage()
{
if (empty($this->commit_message) || is_null($this->commit_message)) {
diff --git a/app/Models/ApplicationSetting.php b/app/Models/ApplicationSetting.php
index ad56d6d846..43b8cc8c48 100644
--- a/app/Models/ApplicationSetting.php
+++ b/app/Models/ApplicationSetting.php
@@ -67,6 +67,10 @@ class ApplicationSetting extends Model
public const GPU_COUNT_PATTERN = '/^(all|\d+)$/';
+ protected $attributes = [
+ 'is_image_scan_enabled' => true,
+ ];
+
protected $casts = [
'is_static' => 'boolean',
'is_spa' => 'boolean',
@@ -103,12 +107,16 @@ class ApplicationSetting extends Model
'include_coolify_default_variables' => 'boolean',
'redact_all_env_values_in_logs' => 'boolean',
'disable_build_cache' => 'boolean',
+ 'is_image_scan_enabled' => 'boolean',
+ 'image_builds_by_team' => 'boolean',
'is_auto_cancel_queued_deployments_enabled' => 'boolean',
'is_auto_cancel_building_deployments_enabled' => 'boolean',
];
protected $fillable = [
'application_id',
+ 'is_image_scan_enabled',
+ 'image_builds_by_team',
'is_static',
'is_git_submodules_enabled',
'is_git_lfs_enabled',
diff --git a/app/Models/AuditEvent.php b/app/Models/AuditEvent.php
index f90e9f947b..53f83e2ecc 100644
--- a/app/Models/AuditEvent.php
+++ b/app/Models/AuditEvent.php
@@ -135,17 +135,21 @@ class AuditEvent extends Model
try {
$attributes = self::attributesFor($event, $context, $changes, $level);
+ // Write at commit, not with defer(): PHP skips the terminate phase when it stops a
+ // request early (client abort, fatal error), and the committed change would have no row.
DB::afterCommit(function () use ($attributes): void {
- defer(function () use ($attributes): void {
+ try {
+ self::query()->create($attributes);
+ } catch (Throwable $exception) {
try {
- self::query()->create($attributes);
- } catch (Throwable $exception) {
Log::warning('Audit event persistence failed', [
'event' => $attributes['event'],
'exception' => $exception::class,
]);
+ } catch (Throwable) {
+ // Audit failures must not interrupt the action.
}
- })->always();
+ }
});
} catch (Throwable $exception) {
Log::warning('Audit event preparation failed', [
@@ -203,10 +207,12 @@ class AuditEvent extends Model
'resource_name' => $resourceName,
'description' => data_get($context, 'audit_description')
?? trim(($resourceName ?? Str::headline((string) $resourceType)).' '.Str::headline($action)),
- 'metadata' => self::redact(Arr::except($context, ['audit_changes', 'actor_email'])),
+ 'metadata' => self::redact(Arr::except($context, ['audit_changes', 'actor_email', 'actor_ip', 'actor_user_agent'])),
'changes' => $changes,
- 'ip_address' => app()->bound('request') ? (client_ip(request()) ?: null) : null,
- 'user_agent' => app()->bound('request') ? Str::limit((string) request()->userAgent(), 200, '') : null,
+ 'ip_address' => data_get($context, 'actor_ip')
+ ?? (app()->bound('request') ? (client_ip(request()) ?: null) : null),
+ 'user_agent' => Str::limit((string) (data_get($context, 'actor_user_agent')
+ ?? (app()->bound('request') ? request()->userAgent() : null)), 200, '') ?: null,
];
}
diff --git a/app/Models/DiscordNotificationSettings.php b/app/Models/DiscordNotificationSettings.php
index 4bf3343527..68eab447e1 100644
--- a/app/Models/DiscordNotificationSettings.php
+++ b/app/Models/DiscordNotificationSettings.php
@@ -33,6 +33,7 @@ class DiscordNotificationSettings extends Model
'server_unreachable_discord_notifications',
'server_patch_discord_notifications',
'traefik_outdated_discord_notifications',
+ 'image_vulnerabilities_discord_notifications',
'discord_ping_enabled',
];
@@ -58,6 +59,7 @@ class DiscordNotificationSettings extends Model
'server_unreachable_discord_notifications' => 'boolean',
'server_patch_discord_notifications' => 'boolean',
'traefik_outdated_discord_notifications' => 'boolean',
+ 'image_vulnerabilities_discord_notifications' => 'boolean',
'discord_ping_enabled' => 'boolean',
];
diff --git a/app/Models/EmailNotificationSettings.php b/app/Models/EmailNotificationSettings.php
index 98e530ce84..9d6554f089 100644
--- a/app/Models/EmailNotificationSettings.php
+++ b/app/Models/EmailNotificationSettings.php
@@ -2,12 +2,13 @@
namespace App\Models;
+use App\Traits\HasSmtpServers;
use App\Traits\TracksDeliveryFailures;
use Illuminate\Database\Eloquent\Model;
class EmailNotificationSettings extends Model
{
- use TracksDeliveryFailures;
+ use HasSmtpServers, TracksDeliveryFailures;
public $timestamps = false;
@@ -28,6 +29,8 @@ class EmailNotificationSettings extends Model
'resend_enabled',
'resend_api_key',
+ 'resend_from_address',
+ 'resend_from_name',
'use_instance_email_settings',
@@ -47,6 +50,7 @@ class EmailNotificationSettings extends Model
'server_unreachable_email_notifications',
'server_patch_email_notifications',
'traefik_outdated_email_notifications',
+ 'image_vulnerabilities_email_notifications',
];
protected $hidden = [
@@ -57,6 +61,8 @@ class EmailNotificationSettings extends Model
'smtp_username',
'smtp_password',
'resend_api_key',
+ 'resend_from_address',
+ 'resend_from_name',
];
protected $casts = [
@@ -72,6 +78,8 @@ class EmailNotificationSettings extends Model
'resend_enabled' => 'boolean',
'resend_api_key' => 'encrypted',
+ 'resend_from_address' => 'encrypted',
+ 'resend_from_name' => 'encrypted',
'use_instance_email_settings' => 'boolean',
@@ -87,6 +95,7 @@ class EmailNotificationSettings extends Model
'server_disk_usage_email_notifications' => 'boolean',
'server_patch_email_notifications' => 'boolean',
'traefik_outdated_email_notifications' => 'boolean',
+ 'image_vulnerabilities_email_notifications' => 'boolean',
];
public function team()
@@ -96,7 +105,7 @@ class EmailNotificationSettings extends Model
public function isEnabled()
{
- return $this->smtp_enabled || $this->resend_enabled || $this->use_instance_email_settings;
+ return filled(enabled_email_provider($this)) || $this->use_instance_email_settings;
}
public function notificationChannelName(): string
diff --git a/app/Models/GithubApp.php b/app/Models/GithubApp.php
index 6de5989747..41ffb79eb6 100644
--- a/app/Models/GithubApp.php
+++ b/app/Models/GithubApp.php
@@ -35,6 +35,7 @@ class GithubApp extends BaseModel
'contents',
'metadata',
'pull_requests',
+ 'deployments',
'administration',
'organization_self_hosted_runners',
'actions',
@@ -66,7 +67,7 @@ class GithubApp extends BaseModel
*/
private ?array $installationTokenInMemory = null;
- public function installationToken(): string
+ public function installationToken(?int $timeout = null): string
{
$credentials = implode('|', [$this->api_url, $this->app_id, $this->installation_id, $this->private_key_id]);
$cached = $this->installationTokenInMemory;
@@ -75,7 +76,7 @@ class GithubApp extends BaseModel
return $cached['token'];
}
- $token = generateGithubToken($this, 'installation');
+ $token = generateGithubToken($this, 'installation', $timeout);
// GitHub installation tokens expire after one hour; renew them well before that.
$this->installationTokenInMemory = [
@@ -149,6 +150,24 @@ class GithubApp extends BaseModel
return $this->hasMany(GithubRunnerConfig::class);
}
+ /**
+ * Whether Coolify can create GitHub Deployments and their statuses with this App.
+ */
+ public function canSyncDeployments(): bool
+ {
+ return ! $this->is_public && $this->missingDeploymentRequirements() === [];
+ }
+
+ /**
+ * Permissions that GitHub deployments need but the App does not have.
+ *
+ * @return array
+ */
+ public function missingDeploymentRequirements(): array
+ {
+ return $this->deployments === 'write' ? [] : ['Repository permission "Deployments": write'];
+ }
+
/**
* Permissions and webhook events that GitHub Actions runners need but the App does not have.
*
diff --git a/app/Models/ImageSbom.php b/app/Models/ImageSbom.php
new file mode 100644
index 0000000000..0e6c9a7594
--- /dev/null
+++ b/app/Models/ImageSbom.php
@@ -0,0 +1,46 @@
+ 'array',
+ 'packages_truncated' => 'boolean',
+ 'last_used_at' => 'datetime',
+ ];
+ }
+
+ public function server(): BelongsTo
+ {
+ return $this->belongsTo(Server::class);
+ }
+
+ public function scans(): HasMany
+ {
+ return $this->hasMany(ImageScan::class);
+ }
+}
diff --git a/app/Models/ImageScan.php b/app/Models/ImageScan.php
new file mode 100644
index 0000000000..331d0d844d
--- /dev/null
+++ b/app/Models/ImageScan.php
@@ -0,0 +1,149 @@
+ ImageScanStatus::class,
+ 'error_code' => ImageScanErrorCode::class,
+ 'error_context' => 'array',
+ 'summary' => 'array',
+ 'findings' => 'array',
+ 'candidates' => 'array',
+ 'builds_image' => 'boolean',
+ 'is_baseline' => 'boolean',
+ 'grype_db_built_at' => 'datetime',
+ 'scanned_at' => 'datetime',
+ ];
+ }
+
+ /**
+ * Entries of `candidates`. Written as `{status, checked_at, entries}`; older rows hold a bare list of entries.
+ *
+ * @return list>
+ */
+ public function candidateEntries(): array
+ {
+ $candidates = $this->candidates;
+ if (! is_array($candidates)) {
+ return [];
+ }
+
+ $entries = array_is_list($candidates) ? $candidates : ($candidates['entries'] ?? []);
+
+ return is_array($entries) ? array_values(array_filter($entries, 'is_array')) : [];
+ }
+
+ /**
+ * Result of the last candidate check: ok, no_better, cannot_compare, auth_required, or failed. Null before the first check.
+ */
+ public function candidateStatus(): ?string
+ {
+ $status = is_array($this->candidates) && ! array_is_list($this->candidates) ? ($this->candidates['status'] ?? null) : null;
+
+ return is_string($status) ? $status : null;
+ }
+
+ /**
+ * The better candidates Coolify can switch to: newer tags of the same line first, then a newer build of the same
+ * tag. A major version is never one of them.
+ *
+ * @return list>
+ */
+ public function updateCandidates(): array
+ {
+ try {
+ $current = ImageRef::parse((string) $this->image_ref);
+ } catch (InvalidArgumentException) {
+ return [];
+ }
+ if ($current->digest !== null) {
+ return [];
+ }
+
+ $currentTag = $current->tag ?? 'latest';
+ $line = [];
+ $sameTag = [];
+ foreach ($this->candidateEntries() as $entry) {
+ if (($entry['better'] ?? false) !== true || ! is_string($entry['ref'] ?? null)) {
+ continue;
+ }
+
+ try {
+ $ref = ImageRef::parse($entry['ref']);
+ } catch (InvalidArgumentException) {
+ continue;
+ }
+ if ($ref->qualifiedName() !== $current->qualifiedName() || $ref->digest !== null || $ref->tag === null) {
+ continue;
+ }
+
+ $kind = $entry['kind'] ?? null;
+ if ($kind === 'line' && $ref->tag !== $currentTag) {
+ $line[] = $entry;
+ } elseif ($kind === 'same_tag' && $ref->tag === $currentTag) {
+ $sameTag[] = $entry;
+ }
+ }
+
+ return [...$line, ...$sameTag];
+ }
+
+ public function resource(): MorphTo
+ {
+ return $this->morphTo('resource');
+ }
+
+ public function server(): BelongsTo
+ {
+ return $this->belongsTo(Server::class);
+ }
+
+ public function sbom(): BelongsTo
+ {
+ return $this->belongsTo(ImageSbom::class, 'image_sbom_id');
+ }
+
+ public function deploymentQueue(): BelongsTo
+ {
+ return $this->belongsTo(ApplicationDeploymentQueue::class, 'deployment_queue_id');
+ }
+}
diff --git a/app/Models/ImageScanIgnore.php b/app/Models/ImageScanIgnore.php
new file mode 100644
index 0000000000..7884902c56
--- /dev/null
+++ b/app/Models/ImageScanIgnore.php
@@ -0,0 +1,66 @@
+ 'datetime',
+ ];
+ }
+
+ /**
+ * Rules for the editable fields of an ignore. A vulnerability ID or a package is required.
+ *
+ * @return array>
+ */
+ public static function validationRules(): array
+ {
+ return [
+ 'vulnerability_id' => ['nullable', 'regex:/^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/', 'required_without:package'],
+ 'package' => ['nullable', 'string', 'max:214', 'required_without:vulnerability_id'],
+ 'reason' => ['required', 'string', 'max:500'],
+ 'expires_at' => ['nullable', 'date'],
+ ];
+ }
+
+ public function team(): BelongsTo
+ {
+ return $this->belongsTo(Team::class);
+ }
+
+ public function resource(): MorphTo
+ {
+ return $this->morphTo('resource');
+ }
+
+ public function scopeActive(Builder $query): Builder
+ {
+ return $query->where(function (Builder $query) {
+ $query->whereNull('expires_at')->orWhere('expires_at', '>', now());
+ });
+ }
+}
diff --git a/app/Models/ImageScanNotificationState.php b/app/Models/ImageScanNotificationState.php
new file mode 100644
index 0000000000..1cbb31839e
--- /dev/null
+++ b/app/Models/ImageScanNotificationState.php
@@ -0,0 +1,53 @@
+ 'array',
+ 'notified_candidates' => 'array',
+ 'digest_ids' => 'array',
+ 'check_reported' => 'boolean',
+ 'held_digests' => 'integer',
+ 'is_baseline' => 'boolean',
+ 'baseline_deployment_queue_id' => 'integer',
+ ];
+ }
+
+ public function resource(): MorphTo
+ {
+ return $this->morphTo('resource');
+ }
+
+ public function team(): BelongsTo
+ {
+ return $this->belongsTo(Team::class);
+ }
+}
diff --git a/app/Models/ImageScanPolicy.php b/app/Models/ImageScanPolicy.php
new file mode 100644
index 0000000000..df4a024c9f
--- /dev/null
+++ b/app/Models/ImageScanPolicy.php
@@ -0,0 +1,54 @@
+ ImageScanGateMode::class,
+ 'on_scan_error' => ImageScanOnError::class,
+ 'count_medium' => 'boolean',
+ ];
+ }
+
+ /**
+ * Rules for the editable fields of a policy. A null field inherits.
+ *
+ * @return array>
+ */
+ public static function validationRules(): array
+ {
+ return [
+ 'gate_mode' => ['nullable', Rule::enum(ImageScanGateMode::class)],
+ 'on_scan_error' => ['nullable', Rule::enum(ImageScanOnError::class)],
+ 'count_medium' => ['nullable', 'boolean'],
+ ];
+ }
+
+ public function scope(): MorphTo
+ {
+ return $this->morphTo('scope');
+ }
+}
diff --git a/app/Models/InstanceSettings.php b/app/Models/InstanceSettings.php
index bce3938bd4..4eb56e12da 100644
--- a/app/Models/InstanceSettings.php
+++ b/app/Models/InstanceSettings.php
@@ -2,6 +2,7 @@
namespace App\Models;
+use App\Traits\HasSmtpServers;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Once;
@@ -9,11 +10,16 @@ use Spatie\Url\Url;
class InstanceSettings extends Model
{
+ use HasSmtpServers;
+
protected $attributes = [
'is_dashboard_force_https_enabled' => true,
+ 'is_image_scanning_allowed' => true,
];
protected $fillable = [
+ 'is_image_scanning_allowed',
+ 'image_scan_db_url',
'public_ipv4',
'public_ipv6',
'fqdn',
@@ -22,6 +28,8 @@ class InstanceSettings extends Model
'do_not_track',
'is_auto_update_enabled',
'is_backup_before_update_enabled',
+ 'auto_update_target_version',
+ 'auto_update_started_at',
'is_registration_enabled',
'disable_registration_when_oauth_enabled',
'next_channel',
@@ -40,6 +48,8 @@ class InstanceSettings extends Model
'smtp_ehlo_domain',
'resend_enabled',
'resend_api_key',
+ 'resend_from_address',
+ 'resend_from_name',
'is_dns_validation_enabled',
'custom_dns_servers',
'domain_connect_private_key',
@@ -79,6 +89,8 @@ class InstanceSettings extends Model
'smtp_username',
'smtp_password',
'resend_api_key',
+ 'resend_from_address',
+ 'resend_from_name',
'domain_connect_private_key',
'sentinel_token',
];
@@ -98,11 +110,14 @@ class InstanceSettings extends Model
'resend_enabled' => 'boolean',
'resend_api_key' => 'encrypted',
+ 'resend_from_address' => 'encrypted',
+ 'resend_from_name' => 'encrypted',
'domain_connect_private_key' => 'encrypted',
'allowed_ip_ranges' => 'array',
'is_auto_update_enabled' => 'boolean',
'is_backup_before_update_enabled' => 'boolean',
+ 'auto_update_started_at' => 'datetime',
'is_registration_enabled' => 'boolean',
'disable_registration_when_oauth_enabled' => 'boolean',
'auto_update_frequency' => 'string',
@@ -113,6 +128,7 @@ class InstanceSettings extends Model
'webhook_allowed_internal_hosts' => 'array',
'webhook_allow_localhost' => 'boolean',
'is_dashboard_force_https_enabled' => 'boolean',
+ 'is_image_scanning_allowed' => 'boolean',
'is_passkey_enabled' => 'boolean',
'server_validation_rate_limit_seconds' => 'integer',
'server_validation_max_failures_per_ip' => 'integer',
diff --git a/app/Models/PrivateKey.php b/app/Models/PrivateKey.php
index 6b25353038..9d220b4bad 100644
--- a/app/Models/PrivateKey.php
+++ b/app/Models/PrivateKey.php
@@ -2,16 +2,20 @@
namespace App\Models;
+use App\Helpers\SshMultiplexingHelper;
use App\Traits\Auditable;
use App\Traits\HasSafeStringAttribute;
use DanHarrin\LivewireRateLimiting\WithRateLimiting;
use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\ValidationException;
use OpenApi\Attributes as OA;
+use phpseclib3\Crypt\DSA;
use phpseclib3\Crypt\PublicKeyLoader;
+use phpseclib3\Crypt\RSA;
#[OA\Schema(
description: 'Private Key model',
@@ -49,18 +53,29 @@ class PrivateKey extends BaseModel
protected $casts = [
'private_key' => 'encrypted',
+ 'is_git_related' => 'boolean',
];
+ /**
+ * Accept any key that can be loaded. A server transfer sets it, because the key already works on the source.
+ */
+ public bool $allowLegacyKey = false;
+
protected $appends = ['public_key'];
protected static function booted()
{
static::saving(function ($key) {
+ $storedPrivateKey = $key->exists ? $key->getOriginal('private_key') : null;
$key->private_key = formatPrivateKey($key->private_key);
- if (! self::validatePrivateKey($key->private_key)) {
+ // Check only new or changed keys, so older keys can still be renamed.
+ $normalize = fn (string $privateKey): string => str_replace("\r\n", "\n", formatPrivateKey($privateKey));
+ $isChanged = $storedPrivateKey === null || $normalize($key->private_key) !== $normalize($storedPrivateKey);
+ $error = $isChanged ? self::validationError($key->private_key, ! $key->allowLegacyKey) : null;
+ if ($error) {
throw ValidationException::withMessages([
- 'private_key' => ['The private key is invalid.'],
+ 'private_key' => [$error],
]);
}
@@ -133,15 +148,41 @@ class PrivateKey extends BaseModel
->select($selectArray->all());
}
- public static function validatePrivateKey($privateKey)
+ /**
+ * Returns why a private key cannot be used, or null when it is valid.
+ * Without $strict, only checks that the key can be loaded.
+ */
+ public static function validationError(?string $privateKey, bool $strict = true): ?string
{
try {
- PublicKeyLoader::load($privateKey);
-
- return true;
+ $key = PublicKeyLoader::loadPrivateKey((string) $privateKey);
} catch (\Throwable $e) {
- return false;
+ return 'The private key is invalid. Paste an unencrypted private key, not a public key or a certificate.';
}
+
+ if (! $strict) {
+ return null;
+ }
+
+ if (! str_starts_with(trim((string) $privateKey), '-----BEGIN ')) {
+ return 'PuTTY and other key formats are not supported. Paste the key in OpenSSH or PEM format. It starts with "-----BEGIN".';
+ }
+
+ if ($key instanceof DSA) {
+ return 'DSA keys are not supported. Use an Ed25519 or RSA key.';
+ }
+
+ if ($key instanceof RSA && $key->getLength() < 2048) {
+ return 'RSA keys must have at least 2048 bits.';
+ }
+
+ try {
+ $key->getPublicKey()->toString('OpenSSH');
+ } catch (\Throwable $e) {
+ return 'This key type is not supported. Use an Ed25519, ECDSA (nistp256, nistp384, or nistp521), or RSA key.';
+ }
+
+ return null;
}
public static function createAndStore(array $data)
@@ -160,14 +201,25 @@ class PrivateKey extends BaseModel
});
}
- public static function generateNewKeyPair($type = 'rsa')
+ /**
+ * @param int|null $rsaKeyLength The RSA key length in bits; null keeps the default of generateSSHKey().
+ */
+ public static function generateNewKeyPair($type = 'ed25519', ?int $rsaKeyLength = null)
{
try {
$instance = new self;
$instance->rateLimit(10);
$name = generate_random_name();
$description = 'Created by Coolify';
- $keyPair = generateSSHKey($type === 'ed25519' ? 'ed25519' : 'rsa');
+ if ($type === 'rsa' && $rsaKeyLength !== null) {
+ $key = RSA::createKey($rsaKeyLength);
+ $keyPair = [
+ 'private' => $key->toString('PKCS1'),
+ 'public' => $key->getPublicKey()->toString('OpenSSH', ['comment' => 'coolify-generated-ssh-key']),
+ ];
+ } else {
+ $keyPair = generateSSHKey($type === 'rsa' ? 'rsa' : 'ed25519');
+ }
return [
'name' => $name,
@@ -191,15 +243,28 @@ class PrivateKey extends BaseModel
}
}
- public static function validateAndExtractPublicKey($privateKey)
+ /**
+ * The key type and key data, without a comment. Lines in authorized_keys are found by this value.
+ */
+ public function publicKeyBody(): string
{
- $isValid = self::validatePrivateKey($privateKey);
- $publicKey = $isValid ? self::extractPublicKeyFromPrivate($privateKey) : '';
+ $publicKey = self::extractPublicKeyFromPrivate($this->private_key);
+ if (is_null($publicKey)) {
+ throw new \RuntimeException('The private key is invalid.');
+ }
- return [
- 'isValid' => $isValid,
- 'publicKey' => $publicKey,
- ];
+ return implode(' ', array_slice(preg_split('/\s+/', trim($publicKey)), 0, 2));
+ }
+
+ /**
+ * The authorized_keys line that Coolify adds. The comment names the instance and the key,
+ * and does not contain the word "coolify", which old install scripts remove.
+ */
+ public function authorizedKeysLine(): string
+ {
+ $instance = substr(hash('sha256', (string) (config('app.id') ?: config('app.url'))), 0, 12);
+
+ return "{$this->publicKeyBody()} cpk:{$instance}:{$this->uuid}";
}
public function storeInFileSystem()
@@ -248,6 +313,7 @@ class PrivateKey extends BaseModel
'path' => $keyLocation,
]);
}
+ SshMultiplexingHelper::giveToSshDirectoryOwner($keyLocation, $lockFile);
return $keyLocation;
} finally {
@@ -295,15 +361,6 @@ class PrivateKey extends BaseModel
return Storage::disk('ssh-keys')->path("ssh_key@{$this->uuid}");
}
- public function updatePrivateKey(array $data)
- {
- return DB::transaction(function () use ($data) {
- $this->update($data);
-
- return $this;
- });
- }
-
public function servers()
{
return $this->hasMany(Server::class);
@@ -329,9 +386,15 @@ class PrivateKey extends BaseModel
return $this->hasMany(GiteaApp::class);
}
+ public function s3Storages(): HasMany
+ {
+ return $this->hasMany(S3Storage::class);
+ }
+
public function isInUse()
{
return $this->servers()->exists()
+ || $this->s3Storages()->exists()
|| $this->applications()->exists()
|| $this->githubApps()->exists()
|| $this->gitlabApps()->exists()
diff --git a/app/Models/PushoverNotificationSettings.php b/app/Models/PushoverNotificationSettings.php
index 9a8be46626..388ff52b1a 100644
--- a/app/Models/PushoverNotificationSettings.php
+++ b/app/Models/PushoverNotificationSettings.php
@@ -34,6 +34,7 @@ class PushoverNotificationSettings extends Model
'server_unreachable_pushover_notifications',
'server_patch_pushover_notifications',
'traefik_outdated_pushover_notifications',
+ 'image_vulnerabilities_pushover_notifications',
];
protected $hidden = [
@@ -60,6 +61,7 @@ class PushoverNotificationSettings extends Model
'server_unreachable_pushover_notifications' => 'boolean',
'server_patch_pushover_notifications' => 'boolean',
'traefik_outdated_pushover_notifications' => 'boolean',
+ 'image_vulnerabilities_pushover_notifications' => 'boolean',
];
public function team()
diff --git a/app/Models/S3Storage.php b/app/Models/S3Storage.php
index ba1d33a587..0a2c977fbf 100644
--- a/app/Models/S3Storage.php
+++ b/app/Models/S3Storage.php
@@ -2,20 +2,30 @@
namespace App\Models;
+use App\Enums\BackupStorageType;
use App\Helpers\SshMultiplexingHelper;
use App\Helpers\SshRetryHandler;
use App\Rules\SafeWebhookUrl;
use App\Rules\ValidS3BucketName;
+use App\Support\FtpsFilesystemAdapter;
+use App\Support\PinnedSftpConnectionProvider;
+use App\Support\SftpFilesystemAdapter;
+use App\Support\WebdavFilesystemAdapter;
use App\Traits\Auditable;
use App\Traits\HasSafeStringAttribute;
+use GuzzleHttp\Client;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Filesystem\FilesystemAdapter;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Validator;
+use Illuminate\Validation\Rule;
+use League\Flysystem\Filesystem;
+use League\Flysystem\FilesystemAdapter as FlysystemAdapter;
class S3Storage extends BaseModel
{
@@ -66,6 +76,19 @@ class S3Storage extends BaseModel
*/
private const REMOTE_COMMAND_TIMEOUT_EXIT_CODE = 124;
+ /**
+ * The name of the rclone remote in rcloneConfig().
+ */
+ public const RCLONE_REMOTE = 'storage';
+
+ public const SFTP_HOST_KEY_MISSING_MESSAGE = 'The SFTP host key of this storage is not saved yet. Validate the connection in the storage settings to save it.';
+
+ /**
+ * The key of the rclone password obscuring (fs/config/obscure in rclone). It only hides the password from a casual
+ * look at the config file; it is not encryption.
+ */
+ private const RCLONE_OBSCURE_KEY = '9c935b48730a554d6bfd7c63c886a92bd390198eb8128afbf4de162b8b95f638';
+
/**
* How long the helper container waits for a TCP connection to one IP of the endpoint.
*/
@@ -73,6 +96,7 @@ class S3Storage extends BaseModel
protected $fillable = [
'team_id',
+ 'type',
'name',
'description',
'region',
@@ -81,6 +105,7 @@ class S3Storage extends BaseModel
'bucket',
'endpoint',
'use_path_style_endpoint',
+ 'private_key_id',
'skip_tls_verification',
'is_usable',
'unusable_email_sent',
@@ -91,7 +116,12 @@ class S3Storage extends BaseModel
'secret',
];
+ protected $attributes = [
+ 'type' => 's3',
+ ];
+
protected $casts = [
+ 'type' => BackupStorageType::class,
'is_usable' => 'boolean',
'use_path_style_endpoint' => 'boolean',
'skip_tls_verification' => 'boolean',
@@ -115,9 +145,8 @@ class S3Storage extends BaseModel
if ($storage->key !== null) {
$storage->key = trim($storage->key);
}
- if ($storage->secret !== null) {
- $storage->secret = trim($storage->secret);
- }
+ // An SFTP storage that logs in with a private key has no password. The column is not nullable.
+ $storage->secret = trim((string) $storage->secret);
});
static::deleting(function (S3Storage $storage) {
@@ -185,11 +214,225 @@ class S3Storage extends BaseModel
return $this->is_usable;
}
+ /**
+ * Storages that serve presigned URLs: file downloads and avatars need them, so they only use S3 storages.
+ */
+ public function scopeS3Only(Builder $query): Builder
+ {
+ return $query->where('type', BackupStorageType::S3->value);
+ }
+
+ public function isS3(): bool
+ {
+ return $this->storageType() === BackupStorageType::S3;
+ }
+
+ /**
+ * The type of the storage. A model without the `type` attribute (a partial select, or raw attributes) is an S3
+ * storage, as every storage was before other types existed.
+ */
+ public function storageType(): BackupStorageType
+ {
+ return $this->type ?? BackupStorageType::S3;
+ }
+
+ /**
+ * Validation rules for the connection fields of a storage of this type. `$partial` makes every field optional, for
+ * updates that only send some fields.
+ *
+ * `$savedEndpoint` is the endpoint that is saved now: it may keep a username or password from before this check.
+ *
+ * @param array $trustedInternalHosts
+ * @return array>
+ */
+ public static function connectionRules(BackupStorageType $type, bool $partial = false, array $trustedInternalHosts = [], ?int $teamId = null, ?string $savedEndpoint = null): array
+ {
+ $required = $partial ? 'sometimes' : 'required';
+ $endpoint = [
+ $required,
+ 'string',
+ 'max:255',
+ // Team members and read-only API tokens see the endpoint, so it must not hold credentials.
+ function (string $attribute, mixed $value, \Closure $fail) use ($savedEndpoint): void {
+ $parts = is_string($value) ? (parse_url(trim($value)) ?: []) : [];
+ if ((isset($parts['user']) || isset($parts['pass'])) && trim((string) $value) !== trim((string) $savedEndpoint)) {
+ $fail('The :attribute must not contain a username or password. Enter the credentials in their own fields.');
+ }
+ if (self::hasControlCharacters(rawurldecode((string) ($parts['path'] ?? '')))) {
+ $fail('The :attribute path must not contain control characters.');
+ }
+ },
+ new SafeWebhookUrl(trustedInternalHosts: $trustedInternalHosts, schemes: $type->endpointSchemes()),
+ ];
+
+ if ($type === BackupStorageType::S3) {
+ return [
+ 'endpoint' => $endpoint,
+ 'bucket' => [$required, new ValidS3BucketName],
+ 'region' => [$required, 'string', 'max:255'],
+ 'key' => [$required, 'string', 'max:255'],
+ 'secret' => [$required, 'string', 'max:255'],
+ ];
+ }
+
+ $rules = [
+ 'endpoint' => $endpoint,
+ // rclone reads the user from a config file line, so it must not contain line breaks or quotes.
+ 'key' => [$required, 'string', 'max:255', 'regex:/^[^\x00-\x1f\x7f"]+$/'],
+ 'secret' => [$required, 'string', 'max:255'],
+ ];
+
+ if ($type === BackupStorageType::Sftp) {
+ // SFTP logs in with a private key of the storage team or with a password.
+ $rules['private_key_id'] = ['nullable', 'integer', Rule::exists('private_keys', 'id')->where('team_id', $teamId ?? -1)->where(fn ($query) => $query->where('is_git_related', false))];
+ $rules['secret'] = [$partial ? 'sometimes' : 'required_without:private_key_id', 'nullable', 'string', 'max:255'];
+ }
+
+ return $rules;
+ }
+
+ /**
+ * The endpoint URL of an SFTP or FTPS storage, from the form fields.
+ */
+ public static function serverEndpoint(BackupStorageType $type, string $host, ?string $port = '', string $path = ''): string
+ {
+ $host = trim($host);
+ if (str_contains($host, ':') && ! str_starts_with($host, '[')) {
+ $host = '['.$host.']';
+ }
+ $port = trim((string) $port);
+ $path = implode('/', array_map('rawurlencode', array_filter(explode('/', trim($path)), fn (string $segment): bool => $segment !== '')));
+
+ return $type->endpointSchemes()[0].'://'.$host.($port !== '' ? ':'.$port : '').($path !== '' ? '/'.$path : '');
+ }
+
+ /**
+ * The form fields of an SFTP or FTPS endpoint URL.
+ *
+ * @return array{host: string, port: string, path: string}
+ */
+ public static function splitServerEndpoint(?string $endpoint): array
+ {
+ $parts = parse_url((string) $endpoint) ?: [];
+
+ return [
+ 'host' => trim((string) ($parts['host'] ?? ''), '[]'),
+ 'port' => isset($parts['port']) ? (string) $parts['port'] : '',
+ 'path' => trim(rawurldecode((string) ($parts['path'] ?? '')), '/'),
+ ];
+ }
+
+ /**
+ * The directory on an SFTP or FTPS server that holds the backups: the path of the endpoint URL. Empty means the
+ * login directory of the user.
+ */
+ public function basePath(): string
+ {
+ $path = rtrim(rawurldecode((string) parse_url((string) $this->endpoint, PHP_URL_PATH)), '/');
+ if (self::hasControlCharacters($path)) {
+ throw new \RuntimeException('The storage path must not contain control characters.');
+ }
+
+ return $path;
+ }
+
+ private static function hasControlCharacters(string $value): bool
+ {
+ return preg_match('/[\x00-\x1f\x7f]/', $value) === 1;
+ }
+
+ public function endpointPort(): int
+ {
+ return (int) (parse_url((string) $this->endpoint, PHP_URL_PORT) ?: $this->storageType()->defaultPort());
+ }
+
+ /**
+ * The path of a backup file in the storage, as rclone reads it. The file keeps its full local path, as the S3 object
+ * key does, below the base path (SFTP, FTPS) or the base URL (WebDAV).
+ */
+ public function rcloneRemotePath(string $file): string
+ {
+ $file = ltrim($file, '/');
+ $base = $this->storageType() === BackupStorageType::Webdav ? '' : $this->basePath();
+
+ return self::RCLONE_REMOTE.':'.($base === '' ? $file : $base.'/'.$file);
+ }
+
+ /**
+ * The rclone config of this storage. The server gets it through stdin, so the password is never on a command line.
+ */
+ public function rcloneConfig(): string
+ {
+ $host = trim((string) parse_url((string) $this->endpoint, PHP_URL_HOST), '[]');
+ $options = match ($this->storageType()) {
+ // host_keys makes rclone refuse another host key, and offer only the algorithm of the saved key.
+ BackupStorageType::Sftp => ['type' => 'sftp', 'host' => $host, 'port' => $this->endpointPort(), 'shell_type' => 'none', 'host_keys' => $this->requireSftpHostKey()],
+ BackupStorageType::Ftps => ['type' => 'ftp', 'host' => $host, 'port' => $this->endpointPort(), 'explicit_tls' => 'true'],
+ BackupStorageType::Webdav => ['type' => 'webdav', 'url' => $this->endpoint, 'vendor' => 'other'],
+ BackupStorageType::S3 => throw new \LogicException('S3 storages use the MinIO client.'),
+ };
+ $options['user'] = $this->key;
+ $privateKey = $this->sftpPrivateKey();
+ if ($privateKey) {
+ // rclone reads key_pem from one line, with \n for each line break.
+ $options['key_pem'] = str_replace("\n", '\n', trim(str_replace("\r\n", "\n", (string) $privateKey->private_key)));
+ } else {
+ $options['pass'] = self::rcloneObscure((string) $this->secret);
+ }
+
+ $lines = ['['.self::RCLONE_REMOTE.']'];
+ foreach ($options as $name => $value) {
+ if (preg_match('/[\x00-\x1f\x7f]/', (string) $value)) {
+ throw new \RuntimeException("The storage {$name} must not contain control characters.");
+ }
+ $lines[] = "{$name} = {$value}";
+ }
+
+ return implode("\n", $lines)."\n";
+ }
+
+ /**
+ * Obscures a password as `rclone obscure` does: AES-CTR with the fixed rclone key and a random IV, in URL-safe
+ * base64 without padding.
+ */
+ public static function rcloneObscure(string $value): string
+ {
+ $iv = random_bytes(16);
+ $ciphertext = openssl_encrypt($value, 'aes-256-ctr', hex2bin(self::RCLONE_OBSCURE_KEY), OPENSSL_RAW_DATA, $iv);
+
+ return rtrim(strtr(base64_encode($iv.$ciphertext), '+/', '-_'), '=');
+ }
+
public function team()
{
return $this->belongsTo(Team::class);
}
+ /**
+ * The private key that an SFTP storage logs in with. Null: it logs in with the password.
+ */
+ public function privateKey(): BelongsTo
+ {
+ return $this->belongsTo(PrivateKey::class);
+ }
+
+ /**
+ * The private key of an SFTP storage, only when it belongs to the team of the storage.
+ */
+ public function sftpPrivateKey(): ?PrivateKey
+ {
+ if ($this->storageType() !== BackupStorageType::Sftp || $this->private_key_id === null) {
+ return null;
+ }
+
+ $privateKey = $this->privateKey;
+ if (! $privateKey || (int) $privateKey->team_id !== (int) $this->team_id || $privateKey->is_git_related) {
+ throw new \RuntimeException('The private key of this storage does not exist, belongs to another team, or is a Git deploy key.');
+ }
+
+ return $privateKey;
+ }
+
public function scheduledBackups()
{
return $this->hasMany(ScheduledDatabaseBackup::class, 's3_storage_id');
@@ -219,15 +462,35 @@ class S3Storage extends BaseModel
}
/**
- * Trim whitespace from endpoint to prevent malformed URLs.
+ * Trim whitespace from endpoint to prevent malformed URLs. Another host or port is another server, so the saved SFTP
+ * host key no longer applies.
*/
protected function endpoint(): Attribute
{
return Attribute::make(
- set: fn (?string $value) => $value ? trim($value) : null,
+ set: function (?string $value, array $attributes): array {
+ $value = $value ? trim($value) : null;
+ $previous = $attributes['endpoint'] ?? null;
+ $server = fn (?string $endpoint): array => [strtolower(trim((string) parse_url((string) $endpoint, PHP_URL_HOST), '[]')), parse_url((string) $endpoint, PHP_URL_PORT)];
+
+ if ($previous === null || $server($previous) === $server($value)) {
+ return ['endpoint' => $value];
+ }
+
+ // Without the host key, the storage needs a new connection test before Coolify uses it.
+ return ['endpoint' => $value, 'sftp_host_key' => null, ...(($attributes['sftp_host_key'] ?? null) !== null ? ['is_usable' => false] : [])];
+ },
);
}
+ /**
+ * The SHA256 fingerprint of the saved SFTP host key, as `ssh-keygen -l` prints it.
+ */
+ public function sftpHostKeyFingerprint(): ?string
+ {
+ return $this->sftp_host_key ? PinnedSftpConnectionProvider::fingerprint($this->sftp_host_key) : null;
+ }
+
/**
* Trim whitespace from bucket name to prevent connection errors.
*/
@@ -248,28 +511,54 @@ class S3Storage extends BaseModel
);
}
- public function testConnection(bool $shouldSave = false)
+ /**
+ * @param bool $acceptNewHostKey Only when an admin validates the storage: an SFTP storage without a saved host
+ * key then saves the presented key. Background tests refuse such a storage, so a
+ * reset or changed key is never trusted without the admin.
+ */
+ public function testConnection(bool $shouldSave = false, bool $acceptNewHostKey = false)
{
try {
- $validator = Validator::make(
- [
- 'endpoint' => $this['endpoint'],
- 'bucket' => $this['bucket'],
- ],
- [
- 'endpoint' => ['required', new SafeWebhookUrl(trustedInternalHosts: $this->trustedInternalHosts())],
- 'bucket' => ['required', new ValidS3BucketName],
- ],
- );
- $validator->fails();
- if ($validator->errors()->has('endpoint')) {
- throw new \RuntimeException('S3 endpoint is not allowed: '.$validator->errors()->first('endpoint'));
- }
- if ($validator->errors()->has('bucket')) {
- throw new \RuntimeException('S3 bucket name is not allowed: '.$validator->errors()->first('bucket'));
- }
+ if ($this->isS3()) {
+ $validator = Validator::make(
+ [
+ 'endpoint' => $this['endpoint'],
+ 'bucket' => $this['bucket'],
+ ],
+ [
+ 'endpoint' => ['required', new SafeWebhookUrl(trustedInternalHosts: $this->trustedInternalHosts())],
+ 'bucket' => ['required', new ValidS3BucketName],
+ ],
+ );
+ $validator->fails();
+ if ($validator->errors()->has('endpoint')) {
+ throw new \RuntimeException('S3 endpoint is not allowed: '.$validator->errors()->first('endpoint'));
+ }
+ if ($validator->errors()->has('bucket')) {
+ throw new \RuntimeException('S3 bucket name is not allowed: '.$validator->errors()->first('bucket'));
+ }
- $this->listFilesWithAddressingStyleFallback();
+ $this->listFilesWithAddressingStyleFallback();
+ } else {
+ $validator = Validator::make(
+ $this->only(['endpoint', 'key', 'secret', 'private_key_id']),
+ self::connectionRules($this->storageType(), trustedInternalHosts: $this->trustedInternalHosts(), teamId: $this->team_id, savedEndpoint: $this->endpoint),
+ );
+ if ($validator->fails()) {
+ throw new \RuntimeException('Storage settings are not allowed: '.$validator->errors()->first());
+ }
+
+ if ($this->storageType() === BackupStorageType::Sftp && ! $acceptNewHostKey) {
+ $this->requireSftpHostKey();
+ }
+
+ $disk = $this->storageType() === BackupStorageType::Sftp ? $this->sftpFilesystem() : $this->filesystem();
+ $disk->files();
+ $adapter = $disk->getAdapter();
+ if ($adapter instanceof SftpFilesystemAdapter && $this->sftp_host_key === null) {
+ $this->pinSftpHostKey((string) $adapter->presentedHostKey(), $shouldSave);
+ }
+ }
$this->unusable_email_sent = false;
$this->is_usable = true;
@@ -279,7 +568,7 @@ class S3Storage extends BaseModel
if ($this->unusable_email_sent === false && is_transactional_emails_enabled()) {
try {
$mail = new MailMessage;
- $mail->subject('Coolify: S3 Storage Connection Error');
+ $mail->subject('Coolify: Backup Storage Connection Error');
$mail->view('emails.s3-connection-error', ['name' => $this->name, 'reason' => $e->getMessage(), 'url' => base_url().'/storages/'.$this->uuid]);
// Load the team with its members and their roles explicitly
@@ -306,7 +595,108 @@ class S3Storage extends BaseModel
}
}
+ /**
+ * The files of the storage, as Coolify reads them (connection tests, restores, downloads, retention). SFTP and FTPS
+ * connect to the validated IP of the host, and WebDAV pins it, so a DNS change cannot send them to another address.
+ * SFTP also checks the saved host key, and refuses to connect before testConnection() saved it.
+ */
public function filesystem(): FilesystemAdapter
+ {
+ if ($this->storageType() === BackupStorageType::Sftp) {
+ $this->requireSftpHostKey();
+ }
+
+ return match ($this->storageType()) {
+ BackupStorageType::S3 => $this->s3Filesystem(),
+ BackupStorageType::Sftp => $this->sftpFilesystem(),
+ BackupStorageType::Ftps => $this->adapterFilesystem(new FtpsFilesystemAdapter(
+ host: trim((string) parse_url((string) $this->endpoint, PHP_URL_HOST), '[]'),
+ port: $this->endpointPort(),
+ username: (string) $this->key,
+ password: (string) $this->secret,
+ root: $this->basePath(),
+ pinnedIp: $this->pinnedHost(),
+ )),
+ BackupStorageType::Webdav => $this->webdavFilesystem(),
+ };
+ }
+
+ /**
+ * Without a saved host key, it accepts any key. Only testConnection() uses it so, to save the presented key.
+ */
+ private function sftpFilesystem(): FilesystemAdapter
+ {
+ return $this->adapterFilesystem(new SftpFilesystemAdapter(PinnedSftpConnectionProvider::create(
+ host: trim((string) parse_url((string) $this->endpoint, PHP_URL_HOST), '[]'),
+ address: $this->pinnedHost(),
+ port: $this->endpointPort(),
+ username: (string) $this->key,
+ password: $this->sftpPrivateKey() ? null : (string) $this->secret,
+ privateKey: $this->sftpPrivateKey()?->private_key,
+ pinnedHostKey: $this->sftp_host_key,
+ timeout: self::CONNECTION_TIMEOUT_SECONDS,
+ ), $this->basePath()));
+ }
+
+ private function requireSftpHostKey(): string
+ {
+ if (blank($this->sftp_host_key)) {
+ throw new \RuntimeException(self::SFTP_HOST_KEY_MISSING_MESSAGE);
+ }
+
+ return $this->sftp_host_key;
+ }
+
+ /**
+ * Saves the host key of the first connection only while the saved storage still has the tested endpoint and no
+ * key, so a test that started before an endpoint change cannot save the key of the old server.
+ */
+ private function pinSftpHostKey(string $hostKey, bool $shouldSave): void
+ {
+ if ($hostKey === '') {
+ throw new \RuntimeException('The SFTP server did not present a host key.');
+ }
+ if ($shouldSave && $this->exists) {
+ $saved = static::query()->whereKey($this->getKey())
+ ->where('endpoint', $this->getAttributes()['endpoint'] ?? null)
+ ->whereNull('sftp_host_key')
+ ->update(['sftp_host_key' => $hostKey]);
+ if ($saved === 0) {
+ throw new \RuntimeException('The storage settings changed during the connection test. Validate the connection again.');
+ }
+ $this->sftp_host_key = $hostKey;
+ $this->syncOriginalAttribute('sftp_host_key');
+
+ return;
+ }
+
+ $this->sftp_host_key = $hostKey;
+ }
+
+ private function pinnedHost(): string
+ {
+ return SafeWebhookUrl::pinnedAddress((string) $this->endpoint, $this->storageType()->endpointSchemes(), $this->trustedInternalHosts());
+ }
+
+ private function adapterFilesystem(FlysystemAdapter $adapter): FilesystemAdapter
+ {
+ return new FilesystemAdapter(new Filesystem($adapter), $adapter);
+ }
+
+ private function webdavFilesystem(): FilesystemAdapter
+ {
+ $baseUrl = rtrim((string) $this->endpoint, '/').'/';
+ $client = new Client(array_merge(SafeWebhookUrl::httpClientOptions($baseUrl, $this->trustedInternalHosts()), [
+ 'base_uri' => $baseUrl,
+ 'auth' => [$this->key, $this->secret],
+ 'http_errors' => false,
+ 'connect_timeout' => self::CONNECTION_TIMEOUT_SECONDS,
+ ]));
+
+ return $this->adapterFilesystem(new WebdavFilesystemAdapter($client, $baseUrl));
+ }
+
+ private function s3Filesystem(): FilesystemAdapter
{
return Storage::build([
'driver' => 's3',
@@ -434,7 +824,7 @@ class S3Storage extends BaseModel
*/
public function usesCurlTransfer(): bool
{
- return trim((string) parse_url((string) $this->endpoint, PHP_URL_PATH), '/') !== '';
+ return $this->isS3() && trim((string) parse_url((string) $this->endpoint, PHP_URL_PATH), '/') !== '';
}
/**
@@ -570,7 +960,9 @@ class S3Storage extends BaseModel
if ($message->contains(['timed out', 'timeout', 'connection refused', 'could not resolve', 'curl error 28'])) {
return new \RuntimeException(
- 'Could not connect to the S3 endpoint within 15 seconds. Please verify the endpoint, bucket, credentials, region, and network/firewall settings.',
+ $this->isS3()
+ ? 'Could not connect to the S3 endpoint within 15 seconds. Please verify the endpoint, bucket, credentials, region, and network/firewall settings.'
+ : 'Could not connect to the '.$this->storageType()->label().' server within 15 seconds. Please verify the host, port, credentials, and network/firewall settings.',
previous: $exception,
);
}
diff --git a/app/Models/Server.php b/app/Models/Server.php
index 75f2e21fe1..3192b71881 100644
--- a/app/Models/Server.php
+++ b/app/Models/Server.php
@@ -19,6 +19,7 @@ use App\Jobs\CheckTraefikVersionForServerJob;
use App\Jobs\RegenerateSslCertJob;
use App\Jobs\ServerConnectionCheckJob;
use App\Livewire\Server\Proxy;
+use App\Notifications\Server\HostKeyChanged;
use App\Notifications\Server\Reachable;
use App\Notifications\Server\Unreachable;
use App\Services\DigitalOceanService;
@@ -34,6 +35,7 @@ use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Http\Exceptions\ThrottleRequestsException;
@@ -194,6 +196,9 @@ class Server extends BaseModel
$server->fill($payload);
});
static::saved(function ($server) {
+ if ($server->wasChanged(['ip', 'port'])) {
+ $server->settings?->forgetSshHostKey('address_changed');
+ }
if ($server->wasChanged('private_key_id') || $server->privateKey?->isDirty()) {
refresh_server_connection($server->privateKey);
} elseif ($server->wasChanged(['user', 'ip', 'port'])) {
@@ -271,8 +276,10 @@ class Server extends BaseModel
}
$server->githubRunnerConfig()->delete();
$server->settings()->delete();
+ SshMultiplexingHelper::deleteStaleKnownHostsFiles($server->uuid, null);
$server->sslCertificates()->delete();
$server->notificationThrottles()->delete();
+ ImageScanPolicy::query()->where('scope_type', $server->getMorphClass())->where('scope_id', $server->id)->delete();
});
static::deleted(function (Server $server) {
@@ -443,6 +450,8 @@ class Server extends BaseModel
$this->forceFill(['ip' => $ip]);
$this->syncOriginalAttribute('ip');
static::flushIdentityMap();
+ // The query update skips the saved hook, which forgets the host key of the old address.
+ $this->settings?->forgetSshHostKey('address_changed');
return true;
}
@@ -705,6 +714,16 @@ class Server extends BaseModel
return $this->hasOne(ServerSetting::class);
}
+ public function imageScans(): HasMany
+ {
+ return $this->hasMany(ImageScan::class);
+ }
+
+ public function imageSboms(): HasMany
+ {
+ return $this->hasMany(ImageSbom::class);
+ }
+
public function dockerCleanupExecutions()
{
return $this->hasMany(DockerCleanupExecution::class);
@@ -2528,8 +2547,13 @@ $siteAddress {
return ['uptime' => false, 'error' => 'Server skipped.'];
}
try {
+ // Enforce mode cannot connect without the key, so it reads the key first. Warn mode learns it after the connection.
+ if (SshMultiplexingHelper::hostKeyCheckingMode($this) === 'enforce') {
+ SshMultiplexingHelper::learnHostKey($this, evenIfUnreachable: true);
+ }
// One attempt only: retries would repeat failed logins on a host that may not belong to the user.
instant_remote_process(['ls /'], $this, disableMultiplexing: true, maxAttempts: 1);
+ SshMultiplexingHelper::learnHostKey($this, evenIfUnreachable: true);
$this->refundValidationAttempt();
if ($this->settings->is_reachable === false) {
$this->settings->is_reachable = true;
@@ -2539,16 +2563,72 @@ $siteAddress {
return ['uptime' => true, 'error' => null];
} catch (\Throwable $e) {
+ $error = $e->getMessage();
+ if ($error === SshMultiplexingHelper::HOST_KEY_CHANGED_MESSAGE && ! $this->recordHostKeyMismatch()) {
+ $error = 'SSH host key verification failed, but the server still sends the trusted host key.';
+ }
if (! $justCheckingNewKey && $this->settings->is_reachable === true) {
$this->settings->is_reachable = false;
$this->settings->save();
ServerReachabilityChanged::dispatch($this);
}
- return ['uptime' => false, 'error' => $e->getMessage()];
+ return ['uptime' => false, 'error' => $error];
}
}
+ /**
+ * ssh reported a host key problem. Returns true when the server sends a host key that is not pinned, and
+ * saves that key as pending, so that an admin can compare and trust it. Returns false when the server
+ * still sends the pinned key or the key cannot be read: then it is a normal connection error.
+ * A key is reported once a day, and the team gets at most one notification an hour, because a load
+ * balancer in front of several hosts can switch between keys on every connection. While a key is
+ * pending, the server is scanned at most every ten minutes.
+ *
+ * @param string|null $sshErrorOutput The stderr of ssh. When it shows the fingerprint of the pending key, no scan is needed.
+ */
+ public function recordHostKeyMismatch(?string $sshErrorOutput = null): bool
+ {
+ $settings = $this->settings;
+ if (filled($settings->ssh_host_key_pending) && preg_match_all('/SHA256:[A-Za-z0-9+\/]+/', (string) $sshErrorOutput, $matches) > 0) {
+ $pendingFingerprints = array_map(fn (string $fingerprint) => strtok($fingerprint, ' '), ServerSetting::sshHostKeyFingerprints($settings->ssh_host_key_pending));
+ if (array_diff($matches[0], $pendingFingerprints) === []) {
+ return true;
+ }
+ }
+ // Each scan is a failed login on the server (fail2ban), so a pending key is scanned again at most every ten minutes.
+ $scannedCacheKey = 'ssh_host_key_mismatch_scanned_'.$this->uuid.'_'.hash('sha256', (string) $settings->ssh_host_key);
+ if (filled($settings->ssh_host_key_pending) && Cache::has($scannedCacheKey)) {
+ return true;
+ }
+
+ $newKey = SshMultiplexingHelper::scanHostKey($this);
+ Cache::put($scannedCacheKey, true, now()->addMinutes(10));
+ if ($newKey === null || array_diff(explode("\n", $newKey), explode("\n", (string) $settings->ssh_host_key)) === []) {
+ return false;
+ }
+ if ($newKey === $settings->ssh_host_key_pending) {
+ return true;
+ }
+
+ $settings->forceFill(['ssh_host_key_pending' => $newKey])->save();
+ if (! Cache::add('ssh_host_key_reported_'.$this->uuid.'_'.hash('sha256', $newKey), true, now()->addDay())) {
+ return true;
+ }
+ Log::warning('SSH host key of server changed', ['server_uuid' => $this->uuid, 'mode' => SshMultiplexingHelper::hostKeyCheckingMode($this)]);
+ auditLog('server.ssh_host_key_changed', [
+ 'team_id' => $this->team_id,
+ 'server_uuid' => $this->uuid,
+ 'server_name' => $this->name,
+ 'fingerprints' => ServerSetting::sshHostKeyFingerprints($newKey),
+ ], 'warning');
+ if (Cache::add('ssh_host_key_changed_notified_'.$this->uuid, true, now()->addHour())) {
+ $this->team?->notify(new HostKeyChanged($this));
+ }
+
+ return true;
+ }
+
public function installDocker()
{
return InstallDocker::run($this);
diff --git a/app/Models/ServerSetting.php b/app/Models/ServerSetting.php
index ae32835c10..dc144c10ad 100644
--- a/app/Models/ServerSetting.php
+++ b/app/Models/ServerSetting.php
@@ -4,6 +4,7 @@ namespace App\Models;
use App\Enums\ServerRole;
use App\Enums\TrafficIpMode;
+use App\Helpers\SshMultiplexingHelper;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
@@ -43,6 +44,13 @@ use OpenApi\Attributes as OA;
'traffic_ip_mode' => ['type' => 'string', 'enum' => ['full', 'anonymized', 'off']],
'traefik_certificate_resolver' => ['type' => 'string', 'nullable' => true, 'description' => 'Traefik certificate resolver for generated HTTPS routers. Null when the proxy uses its own certificates.'],
'is_reachable' => ['type' => 'boolean'],
+ 'ssh_host_key' => ['type' => 'string', 'nullable' => true, 'description' => 'Pinned SSH host key(s) of the server, one "type base64" line per key.'],
+ 'ssh_host_key_source' => ['type' => 'string', 'nullable' => true, 'enum' => ['tofu', 'upgrade', 'transfer', 'manual']],
+ 'ssh_host_key_trusted_at' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true],
+ 'ssh_host_key_pending' => ['type' => 'string', 'nullable' => true, 'description' => 'Host key that the server sent when it did not match the pinned key.'],
+ 'is_ssh_host_key_check_disabled' => ['type' => 'boolean'],
+ 'ssh_host_key_fingerprints' => ['type' => 'array', 'items' => ['type' => 'string'], 'readOnly' => true, 'description' => 'SHA256 fingerprints of the pinned host keys, as `ssh-keygen -lf` shows them.'],
+ 'ssh_host_key_pending_fingerprints' => ['type' => 'array', 'items' => ['type' => 'string'], 'readOnly' => true, 'description' => 'SHA256 fingerprints of the host key that did not match.'],
'is_sentinel_enabled' => ['type' => 'boolean'],
'is_swarm_manager' => ['type' => 'boolean'],
'is_swarm_worker' => ['type' => 'boolean'],
@@ -101,6 +109,9 @@ class ServerSetting extends Model
protected $fillable = [
'server_id',
+ 'is_image_scan_enabled',
+ 'image_candidate_scans_today',
+ 'image_candidate_scans_date',
'is_swarm_manager',
'is_jump_server',
'is_build_server',
@@ -183,6 +194,8 @@ class ServerSetting extends Model
*/
protected $attributes = [
'is_traffic_analytics_enabled' => false,
+ 'is_image_scan_enabled' => false,
+ 'image_candidate_scans_today' => 0,
];
protected $casts = [
@@ -215,6 +228,11 @@ class ServerSetting extends Model
'patch_checked_at' => 'datetime',
'is_auto_security_updates_enabled' => 'boolean',
'is_logdrain_cloudwatch_enabled' => 'boolean',
+ 'is_image_scan_enabled' => 'boolean',
+ 'image_candidate_scans_today' => 'integer',
+ 'image_candidate_scans_date' => 'date',
+ 'ssh_host_key_trusted_at' => 'datetime',
+ 'is_ssh_host_key_check_disabled' => 'boolean',
];
/**
@@ -251,6 +269,13 @@ class ServerSetting extends Model
}
});
static::updated(function ($settings) {
+ // A save that sets the key too (a transfer import) already has the key of the new address.
+ if ($settings->wasChanged('is_cloudflare_tunnel') && ! $settings->wasChanged('ssh_host_key')) {
+ $settings->forgetSshHostKey('cloudflare_tunnel_changed');
+ }
+ if ($settings->wasChanged('ssh_host_key') && $settings->server) {
+ SshMultiplexingHelper::deleteStaleKnownHostsFiles($settings->server->uuid, $settings->ssh_host_key);
+ }
if (
$settings->wasChanged('sentinel_token') ||
$settings->wasChanged('sentinel_custom_url') ||
@@ -503,4 +528,96 @@ class ServerSetting extends Model
'checked_at' => $this->patch_checked_at,
];
}
+
+ /**
+ * Remove the pinned and the pending host key after the address of the server changed, so that the next
+ * connection trusts the key of the new address. Only an admin can change the address.
+ */
+ public function forgetSshHostKey(string $reason): void
+ {
+ if (blank($this->ssh_host_key) && blank($this->ssh_host_key_pending)) {
+ return;
+ }
+ $previousFingerprints = self::sshHostKeyFingerprints($this->ssh_host_key);
+ $cleared = [
+ 'ssh_host_key' => null,
+ 'ssh_host_key_source' => null,
+ 'ssh_host_key_trusted_at' => null,
+ 'ssh_host_key_pending' => null,
+ ];
+ // A query update, so that other unsaved changes stay unsaved and the model events do not run again.
+ self::query()->whereKey($this->getKey())->update($cleared);
+ $this->forceFill($cleared)->syncOriginalAttributes(array_keys($cleared));
+ if ($this->server) {
+ SshMultiplexingHelper::deleteStaleKnownHostsFiles($this->server->uuid, null);
+ }
+
+ auditLog('server.ssh_host_key.forgotten', [
+ 'team_id' => $this->server?->team_id,
+ 'server_uuid' => $this->server?->uuid,
+ 'reason' => $reason,
+ 'previous_fingerprints' => $previousFingerprints,
+ ]);
+ }
+
+ /**
+ * Read SSH host keys from public key lines ("type base64 [comment]") or known_hosts lines
+ * ("host type base64"). Returns the keys as "type base64" lines, or null when there is no valid key.
+ */
+ public static function normalizeSshHostKeys(?string $input): ?string
+ {
+ $keys = [];
+ foreach (preg_split('/\R/', (string) $input) as $line) {
+ $fields = preg_split('/\s+/', trim($line), -1, PREG_SPLIT_NO_EMPTY);
+ if ($fields === [] || str_starts_with($fields[0], '#') || str_starts_with($fields[0], '@')) {
+ continue;
+ }
+ if (! self::isSshHostKeyType($fields[0])) {
+ array_shift($fields);
+ }
+ [$type, $blob] = [$fields[0] ?? '', $fields[1] ?? ''];
+ if (self::isSshHostKeyType($type) && self::sshKeyBlobType($blob) === $type) {
+ $keys[] = "{$type} {$blob}";
+ }
+ }
+
+ return $keys === [] ? null : implode("\n", array_values(array_unique($keys)));
+ }
+
+ /**
+ * The SHA256 fingerprints of host key lines, in the format of `ssh-keygen -lf`.
+ *
+ * @return list
+ */
+ public static function sshHostKeyFingerprints(?string $keys): array
+ {
+ return collect(explode("\n", (string) $keys))
+ ->filter()
+ ->map(function (string $line) {
+ [$type, $blob] = explode(' ', $line, 2);
+
+ return 'SHA256:'.rtrim(base64_encode(hash('sha256', base64_decode($blob), true)), '=')." ({$type})";
+ })
+ ->values()
+ ->all();
+ }
+
+ private static function isSshHostKeyType(string $type): bool
+ {
+ return preg_match('/^(ssh-(ed25519|rsa|dss)|ecdsa-sha2-nistp(256|384|521)|sk-(ssh-ed25519|ecdsa-sha2-nistp256)@openssh\.com)$/', $type) === 1;
+ }
+
+ /**
+ * The key type that is encoded in the first field of an SSH public key blob.
+ */
+ private static function sshKeyBlobType(string $blob): ?string
+ {
+ $decoded = base64_decode($blob, true);
+ if ($decoded === false || strlen($decoded) < 4) {
+ return null;
+ }
+ $length = unpack('N', substr($decoded, 0, 4))[1];
+
+ return strlen($decoded) >= 4 + $length ? substr($decoded, 4, $length) : null;
+ }
}
diff --git a/app/Models/ServiceApplication.php b/app/Models/ServiceApplication.php
index 57ecada9c8..5f737459ab 100644
--- a/app/Models/ServiceApplication.php
+++ b/app/Models/ServiceApplication.php
@@ -4,6 +4,7 @@ namespace App\Models;
use App\Support\DomainPortOverrides;
use App\Support\DomainUrlParts;
+use App\Traits\HasImageScans;
use App\Traits\HasNoindexDomains;
use App\Traits\HasRestartLimit;
use App\Traits\ReleasesManagedDnsRecords;
@@ -14,7 +15,7 @@ use Symfony\Component\Yaml\Exception\ParseException;
class ServiceApplication extends BaseModel
{
- use HasFactory, HasNoindexDomains, HasRestartLimit, ReleasesManagedDnsRecords, SoftDeletes;
+ use HasFactory, HasImageScans, HasNoindexDomains, HasRestartLimit, ReleasesManagedDnsRecords, SoftDeletes;
protected $appends = ['url'];
diff --git a/app/Models/ServiceDatabase.php b/app/Models/ServiceDatabase.php
index b81749d319..4220ac13d2 100644
--- a/app/Models/ServiceDatabase.php
+++ b/app/Models/ServiceDatabase.php
@@ -2,12 +2,13 @@
namespace App\Models;
+use App\Traits\HasImageScans;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\SoftDeletes;
class ServiceDatabase extends BaseModel
{
- use HasFactory, SoftDeletes;
+ use HasFactory, HasImageScans, SoftDeletes;
protected $fillable = [
'service_id',
diff --git a/app/Models/SlackNotificationSettings.php b/app/Models/SlackNotificationSettings.php
index 53125e1e45..a32be65fb4 100644
--- a/app/Models/SlackNotificationSettings.php
+++ b/app/Models/SlackNotificationSettings.php
@@ -33,6 +33,7 @@ class SlackNotificationSettings extends Model
'server_unreachable_slack_notifications',
'server_patch_slack_notifications',
'traefik_outdated_slack_notifications',
+ 'image_vulnerabilities_slack_notifications',
];
protected $hidden = [
@@ -57,6 +58,7 @@ class SlackNotificationSettings extends Model
'server_unreachable_slack_notifications' => 'boolean',
'server_patch_slack_notifications' => 'boolean',
'traefik_outdated_slack_notifications' => 'boolean',
+ 'image_vulnerabilities_slack_notifications' => 'boolean',
];
public function team()
diff --git a/app/Models/StandaloneClickhouse.php b/app/Models/StandaloneClickhouse.php
index 63a26b16e0..68fc3e9ecd 100644
--- a/app/Models/StandaloneClickhouse.php
+++ b/app/Models/StandaloneClickhouse.php
@@ -5,6 +5,7 @@ namespace App\Models;
use App\Traits\Auditable;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasDatabaseHealthCheck;
+use App\Traits\HasImageScans;
use App\Traits\HasLogRedactionSetting;
use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute;
@@ -17,7 +18,7 @@ use Throwable;
class StandaloneClickhouse extends BaseModel
{
- use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, ScopedByAccessGrants, SoftDeletes;
+ use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasImageScans, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, ScopedByAccessGrants, SoftDeletes;
protected array $auditExclude = ['last_online_at'];
diff --git a/app/Models/StandaloneDragonfly.php b/app/Models/StandaloneDragonfly.php
index 1386309d47..f2711ab60f 100644
--- a/app/Models/StandaloneDragonfly.php
+++ b/app/Models/StandaloneDragonfly.php
@@ -5,6 +5,7 @@ namespace App\Models;
use App\Traits\Auditable;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasDatabaseHealthCheck;
+use App\Traits\HasImageScans;
use App\Traits\HasLogRedactionSetting;
use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute;
@@ -16,7 +17,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class StandaloneDragonfly extends BaseModel
{
- use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, ScopedByAccessGrants, SoftDeletes;
+ use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasImageScans, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, ScopedByAccessGrants, SoftDeletes;
protected $fillable = [
'uuid',
diff --git a/app/Models/StandaloneKeydb.php b/app/Models/StandaloneKeydb.php
index b54c93f637..c6fe307de4 100644
--- a/app/Models/StandaloneKeydb.php
+++ b/app/Models/StandaloneKeydb.php
@@ -5,6 +5,7 @@ namespace App\Models;
use App\Traits\Auditable;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasDatabaseHealthCheck;
+use App\Traits\HasImageScans;
use App\Traits\HasLogRedactionSetting;
use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute;
@@ -16,7 +17,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class StandaloneKeydb extends BaseModel
{
- use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, ScopedByAccessGrants, SoftDeletes;
+ use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasImageScans, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, ScopedByAccessGrants, SoftDeletes;
protected $fillable = [
'uuid',
diff --git a/app/Models/StandaloneMariadb.php b/app/Models/StandaloneMariadb.php
index 7fd842d001..a94cee3388 100644
--- a/app/Models/StandaloneMariadb.php
+++ b/app/Models/StandaloneMariadb.php
@@ -5,6 +5,7 @@ namespace App\Models;
use App\Traits\Auditable;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasDatabaseHealthCheck;
+use App\Traits\HasImageScans;
use App\Traits\HasLogRedactionSetting;
use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute;
@@ -17,7 +18,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class StandaloneMariadb extends BaseModel
{
- use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, ScopedByAccessGrants, SoftDeletes;
+ use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasImageScans, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, ScopedByAccessGrants, SoftDeletes;
protected $fillable = [
'uuid',
diff --git a/app/Models/StandaloneMongodb.php b/app/Models/StandaloneMongodb.php
index 189cd11de3..7c43802188 100644
--- a/app/Models/StandaloneMongodb.php
+++ b/app/Models/StandaloneMongodb.php
@@ -5,6 +5,7 @@ namespace App\Models;
use App\Traits\Auditable;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasDatabaseHealthCheck;
+use App\Traits\HasImageScans;
use App\Traits\HasLogRedactionSetting;
use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute;
@@ -16,7 +17,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class StandaloneMongodb extends BaseModel
{
- use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, ScopedByAccessGrants, SoftDeletes;
+ use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasImageScans, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, ScopedByAccessGrants, SoftDeletes;
protected $fillable = [
'uuid',
diff --git a/app/Models/StandaloneMysql.php b/app/Models/StandaloneMysql.php
index 17ff604864..c714cfbe0a 100644
--- a/app/Models/StandaloneMysql.php
+++ b/app/Models/StandaloneMysql.php
@@ -5,6 +5,7 @@ namespace App\Models;
use App\Traits\Auditable;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasDatabaseHealthCheck;
+use App\Traits\HasImageScans;
use App\Traits\HasLogRedactionSetting;
use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute;
@@ -16,7 +17,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class StandaloneMysql extends BaseModel
{
- use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, ScopedByAccessGrants, SoftDeletes;
+ use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasImageScans, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, ScopedByAccessGrants, SoftDeletes;
protected $fillable = [
'uuid',
diff --git a/app/Models/StandalonePostgresql.php b/app/Models/StandalonePostgresql.php
index c266ae8583..8112e01ae5 100644
--- a/app/Models/StandalonePostgresql.php
+++ b/app/Models/StandalonePostgresql.php
@@ -5,6 +5,7 @@ namespace App\Models;
use App\Traits\Auditable;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasDatabaseHealthCheck;
+use App\Traits\HasImageScans;
use App\Traits\HasLogRedactionSetting;
use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute;
@@ -16,7 +17,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class StandalonePostgresql extends BaseModel
{
- use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, ScopedByAccessGrants, SoftDeletes;
+ use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasImageScans, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, ScopedByAccessGrants, SoftDeletes;
protected $fillable = [
'uuid',
diff --git a/app/Models/StandaloneRedis.php b/app/Models/StandaloneRedis.php
index 96f1c30d39..08d9768e22 100644
--- a/app/Models/StandaloneRedis.php
+++ b/app/Models/StandaloneRedis.php
@@ -5,6 +5,7 @@ namespace App\Models;
use App\Traits\Auditable;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasDatabaseHealthCheck;
+use App\Traits\HasImageScans;
use App\Traits\HasLogRedactionSetting;
use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute;
@@ -16,7 +17,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class StandaloneRedis extends BaseModel
{
- use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, ScopedByAccessGrants, SoftDeletes;
+ use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasImageScans, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, ScopedByAccessGrants, SoftDeletes;
protected array $auditExclude = ['last_online_at'];
diff --git a/app/Models/StandaloneSqlite.php b/app/Models/StandaloneSqlite.php
index 5375720604..f8989b4d35 100644
--- a/app/Models/StandaloneSqlite.php
+++ b/app/Models/StandaloneSqlite.php
@@ -5,6 +5,7 @@ namespace App\Models;
use App\Traits\Auditable;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasDatabaseHealthCheck;
+use App\Traits\HasImageScans;
use App\Traits\HasLogRedactionSetting;
use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute;
@@ -18,7 +19,7 @@ use InvalidArgumentException;
class StandaloneSqlite extends BaseModel
{
- use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, ScopedByAccessGrants, SoftDeletes;
+ use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasImageScans, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, ScopedByAccessGrants, SoftDeletes;
public const DATA_DIRECTORY = '/var/lib/sqlite';
diff --git a/app/Models/Team.php b/app/Models/Team.php
index 0126548963..b8ffe23001 100644
--- a/app/Models/Team.php
+++ b/app/Models/Team.php
@@ -36,6 +36,7 @@ use OpenApi\Attributes as OA;
'show_boarding' => ['type' => 'boolean', 'description' => 'Whether to show the boarding screen or not.'],
'custom_server_limit' => ['type' => 'string', 'description' => 'The custom server limit.'],
'is_build_server_fallback_enabled' => ['type' => 'boolean', 'description' => 'Whether deployments can fall back to the deployment server when no usable dedicated build server is available.'],
+ 'scan_images_on_new_servers' => ['type' => 'boolean', 'description' => 'Whether image scanning is turned on for servers added to the team from now on.'],
'members' => new OA\Property(
property: 'members',
type: 'array',
@@ -61,12 +62,14 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen
'is_mcp_server_enabled',
'is_build_server_fallback_enabled',
'is_two_factor_required',
+ 'scan_images_on_new_servers',
];
protected $attributes = [
'is_mcp_server_enabled' => true,
'is_build_server_fallback_enabled' => true,
'is_two_factor_required' => false,
+ 'scan_images_on_new_servers' => false,
];
protected $casts = [
@@ -74,6 +77,7 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen
'is_mcp_server_enabled' => 'boolean',
'is_build_server_fallback_enabled' => 'boolean',
'is_two_factor_required' => 'boolean',
+ 'scan_images_on_new_servers' => 'boolean',
];
protected static function booted()
@@ -152,6 +156,9 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen
foreach (S3Storage::query()->where('team_id', $this->id)->get() as $s3) {
$s3->delete();
}
+
+ ImageScanPolicy::query()->where('scope_type', $this->getMorphClass())->where('scope_id', $this->id)->delete();
+ ImageScanIgnore::query()->where('team_id', $this->id)->delete();
}
public static function serverLimitReached(?Team $team = null)
diff --git a/app/Models/TelegramNotificationSettings.php b/app/Models/TelegramNotificationSettings.php
index 6ab6c7a9e9..8f483ff48e 100644
--- a/app/Models/TelegramNotificationSettings.php
+++ b/app/Models/TelegramNotificationSettings.php
@@ -34,6 +34,7 @@ class TelegramNotificationSettings extends Model
'server_unreachable_telegram_notifications',
'server_patch_telegram_notifications',
'traefik_outdated_telegram_notifications',
+ 'image_vulnerabilities_telegram_notifications',
'telegram_notifications_deployment_success_thread_id',
'telegram_notifications_deployment_failure_thread_id',
@@ -50,6 +51,7 @@ class TelegramNotificationSettings extends Model
'telegram_notifications_server_unreachable_thread_id',
'telegram_notifications_server_patch_thread_id',
'telegram_notifications_traefik_outdated_thread_id',
+ 'telegram_notifications_image_vulnerabilities_thread_id',
];
protected $hidden = [
@@ -70,6 +72,7 @@ class TelegramNotificationSettings extends Model
'telegram_notifications_server_unreachable_thread_id',
'telegram_notifications_server_patch_thread_id',
'telegram_notifications_traefik_outdated_thread_id',
+ 'telegram_notifications_image_vulnerabilities_thread_id',
];
protected $casts = [
@@ -91,6 +94,7 @@ class TelegramNotificationSettings extends Model
'server_unreachable_telegram_notifications' => 'boolean',
'server_patch_telegram_notifications' => 'boolean',
'traefik_outdated_telegram_notifications' => 'boolean',
+ 'image_vulnerabilities_telegram_notifications' => 'boolean',
'telegram_notifications_deployment_success_thread_id' => 'encrypted',
'telegram_notifications_deployment_failure_thread_id' => 'encrypted',
@@ -107,6 +111,7 @@ class TelegramNotificationSettings extends Model
'telegram_notifications_server_unreachable_thread_id' => 'encrypted',
'telegram_notifications_server_patch_thread_id' => 'encrypted',
'telegram_notifications_traefik_outdated_thread_id' => 'encrypted',
+ 'telegram_notifications_image_vulnerabilities_thread_id' => 'encrypted',
];
public function team()
diff --git a/app/Models/User.php b/app/Models/User.php
index 149216c82d..64a5c6ff9c 100644
--- a/app/Models/User.php
+++ b/app/Models/User.php
@@ -230,10 +230,28 @@ class User extends Authenticatable implements PasskeyUser, SendsEmail
* it has no pending invitations, and it belongs to no team other than its own personal team.
*/
public function isUnusedInvitedAccount(): bool
+ {
+ return $this->needsInitialInvitationLogin()
+ && ! TeamInvitation::whereEmail($this->email)->exists();
+ }
+
+ /**
+ * Whether a new invitation from the team may carry a magic login link for this account.
+ * A pending invitation of another team keeps the only login, so the admin of a second team cannot sign in as the invitee.
+ */
+ public function canReceiveInvitationLoginFrom(int $teamId): bool
+ {
+ return $this->needsInitialInvitationLogin()
+ && TeamInvitation::whereEmail($this->email)
+ ->where('team_id', '!=', $teamId)
+ ->get()
+ ->every(fn (TeamInvitation $invitation) => $invitation->hasExpired());
+ }
+
+ public function needsInitialInvitationLogin(): bool
{
return ! $this->hasVerifiedEmail()
&& $this->force_password_reset === true
- && ! TeamInvitation::whereEmail($this->email)->exists()
&& ! $this->belongsToNonPersonalTeam();
}
diff --git a/app/Models/WebhookNotificationSettings.php b/app/Models/WebhookNotificationSettings.php
index 9f2f1244cd..134e523333 100644
--- a/app/Models/WebhookNotificationSettings.php
+++ b/app/Models/WebhookNotificationSettings.php
@@ -33,6 +33,7 @@ class WebhookNotificationSettings extends Model
'server_unreachable_webhook_notifications',
'server_patch_webhook_notifications',
'traefik_outdated_webhook_notifications',
+ 'image_vulnerabilities_webhook_notifications',
];
protected $hidden = [
@@ -60,6 +61,7 @@ class WebhookNotificationSettings extends Model
'server_unreachable_webhook_notifications' => 'boolean',
'server_patch_webhook_notifications' => 'boolean',
'traefik_outdated_webhook_notifications' => 'boolean',
+ 'image_vulnerabilities_webhook_notifications' => 'boolean',
];
}
diff --git a/app/Notifications/Application/DeploymentFailed.php b/app/Notifications/Application/DeploymentFailed.php
index 376f120c88..d21d8f3051 100644
--- a/app/Notifications/Application/DeploymentFailed.php
+++ b/app/Notifications/Application/DeploymentFailed.php
@@ -9,6 +9,7 @@ use App\Notifications\CustomEmailNotification;
use App\Notifications\Dto\DiscordMessage;
use App\Notifications\Dto\PushoverMessage;
use App\Notifications\Dto\SlackMessage;
+use App\Notifications\ImageScan\NotificationText;
use App\Traits\IncludesDeploymentCommitDetails;
use Illuminate\Notifications\Messages\MailMessage;
@@ -34,6 +35,12 @@ class DeploymentFailed extends CustomEmailNotification
public ?string $fqdn = null;
+ /** The gate headline ("Deploy blocked: this image has 2 security problems that attackers are using") when the image scan gate stopped the deployment, for email. Never IDs. */
+ public ?string $blockedTitle = null;
+
+ /** The same headline without counts, for chat and webhook channels. */
+ public ?string $blockedChatTitle = null;
+
public function __construct(Application $application, string $deployment_uuid, ?ApplicationPreview $preview = null, ?ApplicationDeploymentQueue $deployment = null)
{
$this->onQueue('high');
@@ -50,6 +57,26 @@ class DeploymentFailed extends CustomEmailNotification
}
$this->deployment_url = base_url()."/project/{$this->project_uuid}/environment/{$this->environment_uuid}/application/{$this->application->uuid}/deployment/{$this->deployment_uuid}";
$this->resolveDeploymentCommitDetails($application, $deployment, $preview);
+ $gate = $deployment?->deployment_uuid === $deployment_uuid
+ ? $deployment->scan_gate_result
+ : ApplicationDeploymentQueue::query()->where('deployment_uuid', $deployment_uuid)->value('scan_gate_result');
+ if (is_array($gate) && ($gate['blocked'] ?? false)) {
+ $this->blockedTitle = self::blockedTitle((string) ($gate['headline'] ?? ''));
+ // A result stored before the plain headline existed may count problems: chat gets the generic title then.
+ $this->blockedChatTitle = isset($gate['headline_plain'])
+ ? self::blockedTitle((string) $gate['headline_plain'])
+ : (preg_match('/\d/', (string) ($gate['headline'] ?? '')) === 1 ? self::blockedTitle('') : $this->blockedTitle);
+ }
+ }
+
+ private static function blockedTitle(string $headline): string
+ {
+ $headline = rtrim(NotificationText::plain(mb_substr($headline, 0, 300)), '.');
+ if ($headline === '') {
+ return 'Deploy blocked by the image scan';
+ }
+
+ return str_starts_with($headline, 'Deploy blocked') ? $headline : "Deploy blocked: {$headline}";
}
public function via(object $notifiable): array
@@ -62,7 +89,9 @@ class DeploymentFailed extends CustomEmailNotification
$mail = new MailMessage;
$pull_request_id = data_get($this->preview, 'pull_request_id', 0);
$fqdn = $this->fqdn;
- if ($pull_request_id === 0) {
+ if ($this->blockedTitle !== null) {
+ $mail->subject('Coolify: '.$this->blockedTitle.' ('.$this->application_name.').');
+ } elseif ($pull_request_id === 0) {
$mail->subject('Coolify: Deployment failed of '.$this->application_name.'.');
} else {
$fqdn = $this->preview->fqdn;
@@ -74,6 +103,7 @@ class DeploymentFailed extends CustomEmailNotification
'fqdn' => $fqdn,
'deployment_url' => $this->deployment_url,
'pull_request_id' => data_get($this->preview, 'pull_request_id', 0),
+ 'blocked' => $this->blockedTitle === null ? null : NotificationText::mailMarkdown($this->blockedTitle),
]);
return $mail;
@@ -83,7 +113,7 @@ class DeploymentFailed extends CustomEmailNotification
{
if ($this->preview) {
$message = new DiscordMessage(
- title: ':cross_mark: Deployment failed',
+ title: $this->blockedChatTitle ?? ':cross_mark: Deployment failed',
description: 'Pull request: '.$this->preview->pull_request_id,
color: DiscordMessage::errorColor(),
isCritical: true,
@@ -104,7 +134,7 @@ class DeploymentFailed extends CustomEmailNotification
$description = '';
}
$message = new DiscordMessage(
- title: ':cross_mark: Deployment failed',
+ title: $this->blockedChatTitle ?? ':cross_mark: Deployment failed',
description: $description,
color: DiscordMessage::errorColor(),
isCritical: true,
@@ -122,7 +152,9 @@ class DeploymentFailed extends CustomEmailNotification
public function toTelegram(): array
{
- if ($this->preview) {
+ if ($this->blockedChatTitle !== null) {
+ $message = 'Coolify: '.$this->blockedChatTitle.' ('.$this->application_name.'): ';
+ } elseif ($this->preview) {
$message = 'Coolify: Pull request #'.$this->preview->pull_request_id.' of '.$this->application_name.' ('.$this->preview->fqdn.') deployment failed: ';
} else {
$message = 'Coolify: Deployment failed of '.$this->application_name.' ('.$this->fqdn.'): ';
@@ -142,7 +174,10 @@ class DeploymentFailed extends CustomEmailNotification
public function toPushover(): PushoverMessage
{
- if ($this->preview) {
+ if ($this->blockedChatTitle !== null) {
+ $title = $this->blockedChatTitle;
+ $message = 'The image scan blocked the deployment of '.NotificationText::html($this->application_name).'.';
+ } elseif ($this->preview) {
$title = "Pull request #{$this->preview->pull_request_id} deployment failed";
$message = "Pull request deployment failed for {$this->application_name}";
} else {
@@ -181,6 +216,11 @@ class DeploymentFailed extends CustomEmailNotification
}
}
+ if ($this->blockedChatTitle !== null) {
+ $title = $this->blockedChatTitle;
+ $description = 'The image scan blocked the deployment of '.NotificationText::slack($this->application_name).'.';
+ }
+
$description .= "\n\n*Project:* ".data_get($this->application, 'environment.project.name');
$description .= "\n*Environment:* {$this->environment_name}";
$description .= "\n*<{$this->deployment_url}|Deployment Logs>*";
@@ -196,7 +236,7 @@ class DeploymentFailed extends CustomEmailNotification
{
$data = [
'success' => false,
- 'message' => 'Deployment failed',
+ 'message' => $this->blockedChatTitle ?? 'Deployment failed',
'event' => 'deployment_failed',
'application_name' => $this->application_name,
'application_uuid' => $this->application->uuid,
@@ -215,6 +255,10 @@ class DeploymentFailed extends CustomEmailNotification
$data['fqdn'] = $this->fqdn;
}
+ if ($this->blockedChatTitle !== null) {
+ $data['blocked'] = true;
+ }
+
return $data;
}
}
diff --git a/app/Notifications/Channels/EmailChannel.php b/app/Notifications/Channels/EmailChannel.php
index ffe89f7c4c..a688867e05 100644
--- a/app/Notifications/Channels/EmailChannel.php
+++ b/app/Notifications/Channels/EmailChannel.php
@@ -6,6 +6,7 @@ use App\Exceptions\NonReportableException;
use App\Models\EmailNotificationSettings;
use App\Models\InstanceSettings;
use App\Models\Team;
+use App\Rules\SafeSmtpHost;
use App\Services\Security\SensitiveDataRedactor;
use App\Support\SmtpTransportFactory;
use Exception;
@@ -14,6 +15,7 @@ use Illuminate\Support\Facades\Log;
use Resend;
use Resend\Exceptions\ErrorException;
use Resend\Exceptions\TransporterException;
+use RuntimeException;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
use Symfony\Component\Mailer\Mailer;
use Symfony\Component\Mime\Email;
@@ -101,9 +103,32 @@ class EmailChannel
throw new ErrorException($error);
}
} elseif ($isSmtpEnabled) {
+ // The team's own SMTP host: check it again and connect to the checked address.
+ $connectAddress = null;
+ if ($settings instanceof EmailNotificationSettings) {
+ try {
+ $connectAddress = SafeSmtpHost::connectionAddress((string) $settings->smtp_host);
+ } catch (RuntimeException $e) {
+ if ($isTestNotification) {
+ throw NonReportableException::fromException($e);
+ }
+
+ // A blocked host fails on every send. A host that did not resolve can be a
+ // temporary DNS failure, so the job is retried and nothing is counted.
+ if ($e->getCode() === SafeSmtpHost::HOST_NOT_ALLOWED) {
+ $settings->recordDeliveryFailure($e->getMessage());
+
+ return;
+ }
+
+ throw $e;
+ }
+ }
+
$transport = SmtpTransportFactory::fromSettings(
$settings,
- config('mail.mailers.smtp.local_domain')
+ config('mail.mailers.smtp.local_domain'),
+ $connectAddress,
);
$mailer = new Mailer($transport);
diff --git a/app/Notifications/Channels/TelegramChannel.php b/app/Notifications/Channels/TelegramChannel.php
index 5b23f54413..20af83e6f3 100644
--- a/app/Notifications/Channels/TelegramChannel.php
+++ b/app/Notifications/Channels/TelegramChannel.php
@@ -12,6 +12,8 @@ use App\Notifications\Database\BackupFailed;
use App\Notifications\Database\BackupMissing;
use App\Notifications\Database\BackupSuccess;
use App\Notifications\Database\BackupSuccessWithS3Warning;
+use App\Notifications\ImageScan\ExploitedProblemFound;
+use App\Notifications\ImageScan\VulnerabilityDigest;
use App\Notifications\ScheduledTask\TaskFailed;
use App\Notifications\ScheduledTask\TaskSuccess;
use App\Notifications\Server\DockerCleanupFailed;
@@ -66,6 +68,8 @@ class TelegramChannel
Reachable::class => $settings->telegram_notifications_server_reachable_thread_id,
ServerPatchCheck::class => $settings->telegram_notifications_server_patch_thread_id,
TraefikVersionOutdated::class => $settings->telegram_notifications_traefik_outdated_thread_id,
+ VulnerabilityDigest::class,
+ ExploitedProblemFound::class => $settings->telegram_notifications_image_vulnerabilities_thread_id,
default => null,
};
diff --git a/app/Notifications/Channels/TransactionalEmailChannel.php b/app/Notifications/Channels/TransactionalEmailChannel.php
index f4e8b294a4..4911559f08 100644
--- a/app/Notifications/Channels/TransactionalEmailChannel.php
+++ b/app/Notifications/Channels/TransactionalEmailChannel.php
@@ -13,7 +13,7 @@ class TransactionalEmailChannel
public function send(User $notifiable, Notification $notification): void
{
$settings = instanceSettings();
- if (! data_get($settings, 'smtp_enabled') && ! data_get($settings, 'resend_enabled')) {
+ if (blank(enabled_email_provider($settings))) {
return;
}
diff --git a/app/Notifications/Database/BackupSuccessWithS3Warning.php b/app/Notifications/Database/BackupSuccessWithS3Warning.php
index cbed5addf5..564e90882e 100644
--- a/app/Notifications/Database/BackupSuccessWithS3Warning.php
+++ b/app/Notifications/Database/BackupSuccessWithS3Warning.php
@@ -53,7 +53,7 @@ class BackupSuccessWithS3Warning extends CustomEmailNotification implements Thro
public function toMail(): MailMessage
{
$mail = new MailMessage;
- $mail->subject("Coolify: Backup succeeded locally but S3 upload failed for {$this->database->name}");
+ $mail->subject("Coolify: Backup succeeded locally but the storage upload failed for {$this->database->name}");
$mail->view('emails.backup-success-with-s3-warning', [
'name' => $this->name,
'database_name' => $this->database_name,
@@ -68,16 +68,16 @@ class BackupSuccessWithS3Warning extends CustomEmailNotification implements Thro
public function toDiscord(): DiscordMessage
{
$message = new DiscordMessage(
- title: ':warning: Database backup succeeded locally, S3 upload failed',
- description: "Database backup for {$this->name} (db:{$this->database_name}) was created successfully on local storage, but failed to upload to S3.",
+ title: ':warning: Database backup succeeded locally, storage upload failed',
+ description: "Database backup for {$this->name} (db:{$this->database_name}) was created successfully on local storage, but failed to upload to the backup storage.",
color: DiscordMessage::warningColor(),
);
$message->addField('Frequency', $this->frequency, true);
- $message->addField('S3 Error', $this->s3_error);
+ $message->addField('Storage Error', $this->s3_error);
if ($this->s3_storage_url) {
- $message->addField('S3 Storage', '[Check Configuration]('.$this->s3_storage_url.')');
+ $message->addField('Storage', '[Check Configuration]('.$this->s3_storage_url.')');
}
return $message;
@@ -85,10 +85,10 @@ class BackupSuccessWithS3Warning extends CustomEmailNotification implements Thro
public function toTelegram(): array
{
- $message = "Coolify: Database backup for {$this->name} (db:{$this->database_name}) with frequency of {$this->frequency} succeeded locally but failed to upload to S3.\n\nS3 Error:\n{$this->s3_error}";
+ $message = "Coolify: Database backup for {$this->name} (db:{$this->database_name}) with frequency of {$this->frequency} succeeded locally but failed to upload to the backup storage.\n\nStorage Error:\n{$this->s3_error}";
if ($this->s3_storage_url) {
- $message .= "\n\nCheck S3 Configuration: {$this->s3_storage_url}";
+ $message .= "\n\nCheck Storage Configuration: {$this->s3_storage_url}";
}
return [
@@ -98,14 +98,14 @@ class BackupSuccessWithS3Warning extends CustomEmailNotification implements Thro
public function toPushover(): PushoverMessage
{
- $message = "Database backup for {$this->name} (db:{$this->database_name}) was created successfully on local storage, but failed to upload to S3.Frequency: {$this->frequency}.S3 Error: {$this->s3_error}";
+ $message = "Database backup for {$this->name} (db:{$this->database_name}) was created successfully on local storage, but failed to upload to the backup storage.Frequency: {$this->frequency}.Storage Error: {$this->s3_error}";
if ($this->s3_storage_url) {
- $message .= "s3_storage_url}\">Check S3 Configuration ";
+ $message .= "s3_storage_url}\">Check Storage Configuration ";
}
return new PushoverMessage(
- title: 'Database backup succeeded locally, S3 upload failed',
+ title: 'Database backup succeeded locally, storage upload failed',
level: 'warning',
message: $message,
);
@@ -113,14 +113,14 @@ class BackupSuccessWithS3Warning extends CustomEmailNotification implements Thro
public function toSlack(): SlackMessage
{
- $title = 'Database backup succeeded locally, S3 upload failed';
- $description = "Database backup for {$this->name} (db:{$this->database_name}) was created successfully on local storage, but failed to upload to S3.";
+ $title = 'Database backup succeeded locally, storage upload failed';
+ $description = "Database backup for {$this->name} (db:{$this->database_name}) was created successfully on local storage, but failed to upload to the backup storage.";
$description .= "\n\n*Frequency:* {$this->frequency}";
- $description .= "\n\n*S3 Error:* {$this->s3_error}";
+ $description .= "\n\n*Storage Error:* {$this->s3_error}";
if ($this->s3_storage_url) {
- $description .= "\n\n*S3 Storage:* <{$this->s3_storage_url}|Check Configuration>";
+ $description .= "\n\n*Storage:* <{$this->s3_storage_url}|Check Configuration>";
}
return new SlackMessage(
diff --git a/app/Notifications/ImageScan/ExploitedProblemFound.php b/app/Notifications/ImageScan/ExploitedProblemFound.php
new file mode 100644
index 0000000000..6c288109be
--- /dev/null
+++ b/app/Notifications/ImageScan/ExploitedProblemFound.php
@@ -0,0 +1,250 @@
+ For the email only. */
+ public array $why = [];
+
+ /**
+ * Keeps only scalar values, so a resource deleted before the queued send does not fail it.
+ *
+ * @param Model $owner The scan owner; the text names its page resource (a service for its applications).
+ * @param list $problems The new relevant findings with an exploit signal.
+ * `incomplete`: the version check of its scan has no result.
+ */
+ public function __construct(Model $owner, array $problems)
+ {
+ $this->onQueue('high');
+ $this->count = count($problems);
+ $this->exploited = collect($problems)->contains(fn (array $problem): bool => SecurityRules::exploitSignal($problem['finding']) === SecurityRules::EXPLOITED);
+ $this->action = self::mainAction($problems);
+ $this->checkIncomplete = $this->action === null && collect($problems)->contains(fn (array $problem): bool => (bool) ($problem['incomplete'] ?? false));
+ $this->why = SecurityStatus::topWhy(
+ array_map(fn (array $problem): array => SecurityStatus::whyFor($problem['finding'], SecurityRules::exploitSignal($problem['finding'])), $problems),
+ VulnerabilityDigest::MAIL_ID_LIMIT,
+ );
+
+ $resource = ScanNotifications::pageResource($owner);
+ $this->resourceName = (string) $resource->getAttribute('name');
+ $this->resourceUuid = (string) $resource->getAttribute('uuid');
+ $this->url = ScanNotifications::link($resource);
+ }
+
+ public function via(object $notifiable): array
+ {
+ return $notifiable->getEnabledChannels('image_vulnerabilities');
+ }
+
+ /**
+ * The badge of the status the problems give: "Update now" with an action, "Check needed" when the version check
+ * has no result, else "No fix yet".
+ */
+ public function title(): string
+ {
+ return match (true) {
+ $this->action !== null => 'Update now',
+ $this->checkIncomplete => 'Check needed',
+ default => 'No fix yet',
+ };
+ }
+
+ /**
+ * The status value of the title.
+ */
+ public function status(): string
+ {
+ return match (true) {
+ $this->action !== null => 'update_now',
+ $this->checkIncomplete => 'check_needed',
+ default => 'no_fix',
+ };
+ }
+
+ /**
+ * "api: 2 problems that attackers are using. Update to 1.28." Chat and webhook channels use the text without the
+ * count ("api: problems that …").
+ */
+ public function text(bool $withCount): string
+ {
+ return NotificationText::plain($this->resourceName).': '.$this->sentence($withCount);
+ }
+
+ public function toMail(?object $notifiable = null): MailMessage
+ {
+ return (new MailMessage)
+ ->subject('Coolify: '.$this->title().': '.NotificationText::plain($this->resourceName))
+ ->view('emails.image-vulnerabilities', [
+ 'intro' => 'A new check found security problems that attackers are using or are likely to use.',
+ 'resources' => [[
+ 'name' => NotificationText::mailMarkdown($this->resourceName),
+ 'url' => $this->url,
+ 'summary' => NotificationText::mailMarkdown(ucfirst($this->sentence(withCount: true))),
+ 'why' => array_map(NotificationText::mailWhy(...), $this->why),
+ ]],
+ 'ignores' => [],
+ 'servers' => [],
+ 'url' => $this->url ?? rtrim(base_url(), '/').'/vulnerabilities',
+ ]);
+ }
+
+ public function toDiscord(): DiscordMessage
+ {
+ return new DiscordMessage(
+ title: $this->title(),
+ description: $this->fit(fn (string $name, string $sentence): string => ($this->url === null
+ ? NotificationText::discord($name)
+ : '['.NotificationText::discord($name)."]({$this->url})").': '.NotificationText::discord($sentence), VulnerabilityDigest::DISCORD_LIMIT),
+ color: DiscordMessage::errorColor(),
+ isCritical: true,
+ );
+ }
+
+ public function toSlack(): SlackMessage
+ {
+ return new SlackMessage(
+ title: $this->title(),
+ description: $this->fit(fn (string $name, string $sentence): string => ($this->url === null
+ ? NotificationText::slack($name)
+ : "<{$this->url}|".NotificationText::slack($name).'>').': '.NotificationText::slack($sentence), VulnerabilityDigest::SLACK_LIMIT),
+ color: SlackMessage::errorColor(),
+ );
+ }
+
+ public function toTelegram(): array
+ {
+ return [
+ 'message' => $this->fit(fn (string $name, string $sentence): string => 'Coolify: '.$this->title()."\n\n".$name.': '.$sentence, VulnerabilityDigest::TELEGRAM_LIMIT),
+ 'buttons' => $this->url === null ? [] : [['text' => VulnerabilityDigest::BUTTON, 'url' => $this->url]],
+ ];
+ }
+
+ public function toPushover(): PushoverMessage
+ {
+ $button = VulnerabilityDigest::BUTTON;
+ $buttons = $this->url === null ? [] : [['text' => $button, 'url' => $this->url]];
+ $buttonLength = $this->url === null ? 0 : mb_strlen(" {$button} ");
+
+ return new PushoverMessage(
+ title: $this->title(),
+ message: $this->fit(fn (string $name, string $sentence): string => NotificationText::html($name).': '.NotificationText::html($sentence), VulnerabilityDigest::PUSHOVER_LIMIT - $buttonLength),
+ buttons: $buttons,
+ level: 'error',
+ );
+ }
+
+ public function toWebhook(): array
+ {
+ return [
+ 'success' => false,
+ 'message' => $this->title(),
+ 'event' => 'image_exploited_problem',
+ 'resource_name' => $this->resourceName,
+ 'resource_uuid' => $this->resourceUuid,
+ 'url' => $this->url,
+ 'status' => $this->status(),
+ 'text' => $this->text(withCount: false),
+ 'action' => $this->action,
+ ];
+ }
+
+ /**
+ * "2 problems that attackers are using. Update to 1.28." / "a problem that attackers are likely to use, and no
+ * fixed version exists yet."
+ */
+ private function sentence(bool $withCount): string
+ {
+ $problems = match (true) {
+ $withCount => $this->count.' '.($this->count === 1 ? 'problem' : 'problems'),
+ $this->count === 1 => 'a problem',
+ default => 'problems',
+ };
+ $use = $this->exploited ? 'are using' : 'are likely to use';
+
+ return match (true) {
+ $this->action !== null => "{$problems} that attackers {$use}. {$this->action}",
+ $this->checkIncomplete => "{$problems} that attackers {$use}. Coolify could not check for a fixed version yet.",
+ default => "{$problems} that attackers {$use}, and no fixed version exists yet.",
+ };
+ }
+
+ /**
+ * The action that fixes the most of the problems; on a tie the first in the order of the action types.
+ *
+ * @param list $problems
+ */
+ private static function mainAction(array $problems): ?string
+ {
+ $order = array_flip(array_map(fn (SecurityActionType $type): string => $type->value, SecurityActionType::cases()));
+ $tally = [];
+ foreach ($problems as $problem) {
+ $action = $problem['action'];
+ if ($action instanceof SecurityAction && $action->sentence !== '') {
+ $tally[$action->sentence] ??= ['count' => 0, 'order' => $order[$action->type->value]];
+ $tally[$action->sentence]['count']++;
+ }
+ }
+ if ($tally === []) {
+ return null;
+ }
+
+ uksort($tally, fn (string $a, string $b): int => [-$tally[$a]['count'], $tally[$a]['order']] <=> [-$tally[$b]['count'], $tally[$b]['order']]);
+
+ return NotificationText::plain((string) array_key_first($tally));
+ }
+
+ /**
+ * The text of a channel within its limit: the name and sentence are cut shorter until the escaped text fits.
+ *
+ * @param callable(string, string): string $build Gets the raw name and sentence, escapes them.
+ */
+ private function fit(callable $build, int $budget): string
+ {
+ $text = '';
+ foreach ([[100, 600], [60, 200], [30, 80]] as [$nameLimit, $sentenceLimit]) {
+ $text = $build(NotificationText::limit($this->resourceName, $nameLimit), NotificationText::limit($this->sentence(withCount: false), $sentenceLimit));
+ if (mb_strlen($text) <= $budget) {
+ break;
+ }
+ }
+
+ return $text;
+ }
+}
diff --git a/app/Notifications/ImageScan/NotificationText.php b/app/Notifications/ImageScan/NotificationText.php
new file mode 100644
index 0000000000..2c12064052
--- /dev/null
+++ b/app/Notifications/ImageScan/NotificationText.php
@@ -0,0 +1,103 @@
+\[\]()#])/u', '\\\\$1', self::plain($value)) ?? '';
+ }
+
+ /** Slack mrkdwn: only &, <, and > have a meaning that escaping can stop. */
+ public static function slack(?string $value): string
+ {
+ return str_replace(['&', '<', '>'], ['&', '<', '>'], self::plain($value));
+ }
+
+ /** Pushover with `html=1`. */
+ public static function html(?string $value): string
+ {
+ return htmlspecialchars(self::plain($value), ENT_QUOTES | ENT_HTML5, 'UTF-8');
+ }
+
+ /** Markdown of the email body; Blade escapes the HTML. */
+ public static function mailMarkdown(?string $value): string
+ {
+ return preg_replace('/([\\\\`*_{}\[\]()#+\-.!|~=])/u', '\\\\$1', self::plain($value)) ?? '';
+ }
+
+ /**
+ * One row of a why-list in the email, as escaped Markdown: "Title ([CVE-2025-1094](https://nvd…), 8.1 High). Used
+ * by attackers." The ID links only to the advisory Coolify builds for it.
+ *
+ * @param array{title: string, display_id: string, score: ?float, severity: string, signal: ?string} $entry
+ */
+ public static function mailWhy(array $entry): string
+ {
+ $id = (string) $entry['display_id'];
+ $link = ResourceScans::advisoryUrl($id);
+ $reference = $link === null ? self::mailMarkdown($id) : '['.self::mailMarkdown($id)."]({$link})";
+ $severity = ucfirst(strtolower(self::plain($entry['severity'])));
+ $score = $entry['score'] === null ? $severity : number_format((float) $entry['score'], 1).' '.$severity;
+ $label = match ($entry['signal']) {
+ SecurityRules::EXPLOITED => ' Used by attackers.',
+ SecurityRules::LIKELY => ' Likely to be exploited.',
+ default => '',
+ };
+
+ return self::mailMarkdown(self::limit($entry['title'], 200))." ({$reference}, ".self::mailMarkdown($score).').'.$label;
+ }
+
+ /**
+ * Cuts a raw value before it is escaped, so one long name cannot fill a message.
+ */
+ public static function limit(?string $value, int $max): string
+ {
+ $value = self::plain($value);
+
+ return mb_strlen($value) > $max ? rtrim(mb_substr($value, 0, $max - 1)).'…' : $value;
+ }
+
+ /**
+ * The lines that fit in $budget characters, joined with new lines, then "And N more." for the rest.
+ *
+ * @param list $lines
+ * @return list
+ */
+ public static function fitLines(array $lines, int $budget): array
+ {
+ $kept = [];
+ $used = 0;
+ $count = count($lines);
+ foreach ($lines as $index => $line) {
+ $remaining = $count - $index - 1;
+ $reserve = $remaining > 0 ? mb_strlen("\nAnd {$remaining} more.") : 0;
+ $length = mb_strlen($line) + ($kept === [] ? 0 : 1);
+ if ($used + $length + $reserve > $budget) {
+ break;
+ }
+ $kept[] = $line;
+ $used += $length;
+ }
+
+ $more = $count - count($kept);
+ if ($more > 0) {
+ $kept[] = "And {$more} more.";
+ }
+
+ return $kept;
+ }
+}
diff --git a/app/Notifications/ImageScan/VulnerabilityDigest.php b/app/Notifications/ImageScan/VulnerabilityDigest.php
new file mode 100644
index 0000000000..cac34026c0
--- /dev/null
+++ b/app/Notifications/ImageScan/VulnerabilityDigest.php
@@ -0,0 +1,236 @@
+}>, ignores: list, servers: list} $digest
+ */
+ public function __construct(public array $digest)
+ {
+ $this->onQueue('high');
+ $this->teamUrl = rtrim(base_url(), '/').'/vulnerabilities';
+ }
+
+ public function via(object $notifiable): array
+ {
+ return $notifiable->getEnabledChannels('image_vulnerabilities');
+ }
+
+ public function toMail(?object $notifiable = null): MailMessage
+ {
+ $budget = self::MAIL_ID_LIMIT;
+ $resources = array_map(function (array $resource) use (&$budget): array {
+ $why = array_slice($resource['why'], 0, max(0, $budget));
+ $budget -= count($why);
+
+ return [
+ 'name' => NotificationText::mailMarkdown($resource['name']),
+ 'url' => $resource['url'],
+ 'summary' => NotificationText::mailMarkdown($this->resourceSentence($resource)),
+ 'why' => array_map(NotificationText::mailWhy(...), $why),
+ ];
+ }, $this->digest['resources']);
+
+ $ignores = array_map(function (array $ignore) use (&$budget): array {
+ $id = $ignore['id'];
+ if ($id !== null && $budget > 0) {
+ $budget--;
+ } elseif ($id !== null) {
+ $id = 'A vulnerability';
+ }
+ $subject = match (true) {
+ $id !== null && $ignore['package'] !== null => "{$id} in {$ignore['package']}",
+ $id !== null => $id,
+ default => "Package {$ignore['package']}",
+ };
+
+ return [
+ 'subject' => NotificationText::mailMarkdown($subject),
+ 'resource' => $ignore['resource'] === null ? null : NotificationText::mailMarkdown($ignore['resource']),
+ 'expires_at' => $ignore['expires_at'],
+ ];
+ }, $this->digest['ignores']);
+
+ return (new MailMessage)
+ ->subject('Coolify: '.self::TITLE)
+ ->view('emails.image-vulnerabilities', [
+ 'intro' => 'Changes in the security status of your resources since the last digest.',
+ 'resources' => $resources,
+ 'ignores' => $ignores,
+ 'servers' => array_map(fn (array $server): array => ['name' => NotificationText::mailMarkdown($server['name']), 'url' => $server['url']], $this->digest['servers']),
+ 'url' => $this->teamUrl,
+ ]);
+ }
+
+ public function toDiscord(): DiscordMessage
+ {
+ $suffix = "\n\n[".self::BUTTON."]({$this->teamUrl})";
+ $lines = $this->chatLines(
+ fn (array $resource): string => '• '.($resource['url'] === null
+ ? NotificationText::discord($this->name($resource))
+ : '['.NotificationText::discord($this->name($resource)).']('.$resource['url'].')')
+ .': '.NotificationText::discord($this->resourceSentence($resource)),
+ NotificationText::discord(...),
+ self::DISCORD_LIMIT - mb_strlen($suffix),
+ );
+
+ return new DiscordMessage(
+ title: self::TITLE,
+ description: implode("\n", $lines).$suffix,
+ color: DiscordMessage::warningColor(),
+ );
+ }
+
+ public function toSlack(): SlackMessage
+ {
+ $suffix = "\n\n<{$this->teamUrl}|".self::BUTTON.'>';
+ $lines = $this->chatLines(
+ fn (array $resource): string => '• '.($resource['url'] === null
+ ? NotificationText::slack($this->name($resource))
+ : '<'.$resource['url'].'|'.NotificationText::slack($this->name($resource)).'>')
+ .': '.NotificationText::slack($this->resourceSentence($resource)),
+ NotificationText::slack(...),
+ self::SLACK_LIMIT - mb_strlen($suffix),
+ );
+
+ return new SlackMessage(
+ title: self::TITLE,
+ description: implode("\n", $lines).$suffix,
+ color: SlackMessage::warningColor(),
+ );
+ }
+
+ public function toTelegram(): array
+ {
+ $prefix = 'Coolify: '.self::TITLE."\n\n";
+ $lines = $this->chatLines(
+ fn (array $resource): string => '• '.NotificationText::plain($this->name($resource)).': '.NotificationText::plain($this->resourceSentence($resource))
+ .($resource['url'] === null ? '' : "\n ".$resource['url']),
+ NotificationText::plain(...),
+ self::TELEGRAM_LIMIT - mb_strlen($prefix),
+ );
+
+ return [
+ 'message' => $prefix.implode("\n", $lines),
+ 'buttons' => [['text' => self::BUTTON, 'url' => $this->teamUrl]],
+ ];
+ }
+
+ public function toPushover(): PushoverMessage
+ {
+ $button = ['text' => self::BUTTON, 'url' => $this->teamUrl];
+ // PushoverMessage appends each button to the message as a link.
+ $buttonLength = mb_strlen(" {$button['text']} ");
+ $lines = $this->chatLines(
+ fn (array $resource): string => '• '.($resource['url'] === null
+ ? NotificationText::html($this->name($resource))
+ : "".NotificationText::html($this->name($resource)).' ')
+ .': '.NotificationText::html($this->resourceSentence($resource)),
+ NotificationText::html(...),
+ self::PUSHOVER_LIMIT - $buttonLength,
+ );
+
+ return new PushoverMessage(
+ title: self::TITLE,
+ message: implode("\n", $lines),
+ buttons: [$button],
+ level: 'warning',
+ );
+ }
+
+ public function toWebhook(): array
+ {
+ return [
+ 'success' => false,
+ 'message' => self::TITLE,
+ 'event' => 'image_vulnerabilities_digest',
+ 'url' => $this->teamUrl,
+ 'resources' => array_map(fn (array $resource): array => [
+ 'name' => $resource['name'],
+ 'uuid' => $resource['uuid'],
+ 'url' => $resource['url'],
+ 'status' => $resource['status'],
+ 'action' => $resource['action'],
+ ], $this->digest['resources']),
+ 'ignores_ending' => $this->digest['ignores'] !== [],
+ 'servers_failing' => array_map(fn (array $server): array => ['name' => $server['name'], 'url' => $server['url']], $this->digest['servers']),
+ ];
+ }
+
+ /**
+ * The lines of a chat message that fit in $budget characters: resources, then ignores and failing servers,
+ * without IDs or counts, then "And N more." for the rest.
+ *
+ * @param callable(array): string $resourceLine
+ * @param callable(string): string $escape
+ * @return list
+ */
+ private function chatLines(callable $resourceLine, callable $escape, int $budget): array
+ {
+ $lines = array_map($resourceLine, $this->digest['resources']);
+
+ $ignores = count($this->digest['ignores']);
+ if ($ignores > 0) {
+ $lines[] = $ignores === 1 ? 'An ignore ends within 7 days.' : 'Some ignores end within 7 days.';
+ }
+
+ foreach ($this->digest['servers'] as $server) {
+ $lines[] = $escape('Image scans failed for 2 days on '.NotificationText::limit($server['name'], self::NAME_LIMIT).'.');
+ }
+
+ return NotificationText::fitLines($lines, $budget);
+ }
+
+ /**
+ * @param array $resource
+ */
+ private function name(array $resource): string
+ {
+ return NotificationText::limit($resource['name'], self::NAME_LIMIT);
+ }
+
+ /**
+ * "Security update. Update to 1.28." / "Check needed. Check again: the last check was 3 days ago."
+ *
+ * @param array $resource
+ */
+ private function resourceSentence(array $resource): string
+ {
+ return NotificationText::limit(trim($resource['badge'].'. '.($resource['action'] ?? '')), self::SENTENCE_LIMIT);
+ }
+}
diff --git a/app/Notifications/Server/HostKeyChanged.php b/app/Notifications/Server/HostKeyChanged.php
new file mode 100644
index 0000000000..f6dfbbf274
--- /dev/null
+++ b/app/Notifications/Server/HostKeyChanged.php
@@ -0,0 +1,102 @@
+onQueue('high');
+ }
+
+ public function via(object $notifiable): array
+ {
+ return $notifiable->getEnabledChannels('server_unreachable');
+ }
+
+ public function toMail(): MailMessage
+ {
+ $mail = new MailMessage;
+ $mail->subject("Coolify: The SSH host key of your server ({$this->server->name}) changed.");
+ $mail->view('emails.server-host-key-changed', [
+ 'name' => $this->server->name,
+ 'text' => $this->text(),
+ 'url' => $this->url(),
+ ]);
+
+ return $mail;
+ }
+
+ public function toDiscord(): DiscordMessage
+ {
+ $message = new DiscordMessage(
+ title: ':warning: SSH host key changed',
+ description: $this->text(),
+ color: DiscordMessage::errorColor(),
+ );
+ $message->addField('Check the new key', '[Link]('.$this->url().')');
+
+ return $message;
+ }
+
+ public function toTelegram(): array
+ {
+ return [
+ 'message' => 'Coolify: '.$this->text(),
+ 'buttons' => [['text' => 'Check the new key', 'url' => $this->url()]],
+ ];
+ }
+
+ public function toPushover(): PushoverMessage
+ {
+ return new PushoverMessage(
+ title: 'SSH host key changed',
+ level: 'error',
+ message: $this->text(),
+ buttons: [['text' => 'Check the new key', 'url' => $this->url()]],
+ );
+ }
+
+ public function toSlack(): SlackMessage
+ {
+ return new SlackMessage(
+ title: 'SSH host key changed',
+ description: $this->text()."\n\nCheck the new key: ".$this->url(),
+ color: SlackMessage::errorColor()
+ );
+ }
+
+ public function toWebhook(): array
+ {
+ return [
+ 'success' => false,
+ 'message' => 'SSH host key changed',
+ 'event' => 'server_ssh_host_key_changed',
+ 'server_name' => $this->server->name,
+ 'server_uuid' => $this->server->uuid,
+ 'url' => $this->url(),
+ ];
+ }
+
+ private function text(): string
+ {
+ return "The SSH host key of your server '{$this->server->name}' does not match the key that Coolify trusts. "
+ .'This occurs when the server was reinstalled, or when another machine answers at its address. '
+ .'Compare the new key with the key on the server, then trust it or reset it.';
+ }
+
+ private function url(): string
+ {
+ return base_url().'/server/'.$this->server->uuid.'/security#server-host-key-section';
+ }
+}
diff --git a/app/Notifications/Server/ServerPatchCheck.php b/app/Notifications/Server/ServerPatchCheck.php
index 84b3028502..e72ad0444e 100644
--- a/app/Notifications/Server/ServerPatchCheck.php
+++ b/app/Notifications/Server/ServerPatchCheck.php
@@ -17,7 +17,7 @@ class ServerPatchCheck extends CustomEmailNotification
public function __construct(public Server $server, public array $patchData)
{
$this->onQueue('high');
- $this->serverUrl = base_url().'/server/'.$this->server->uuid.'/security/patches';
+ $this->serverUrl = base_url().'/server/'.$this->server->uuid.'/security#server-patching-overview-section';
}
public function via(object $notifiable): array
diff --git a/app/Rules/SafeSmtpHost.php b/app/Rules/SafeSmtpHost.php
new file mode 100644
index 0000000000..4ca79a33ed
--- /dev/null
+++ b/app/Rules/SafeSmtpHost.php
@@ -0,0 +1,120 @@
+checkConnectionHost($host)['status']) {
+ 'allowed' => null,
+ 'unresolved' => 'The SMTP host could not be resolved.',
+ 'link_local' => 'The SMTP host must not point to link-local addresses.',
+ 'blocked' => isInstanceAdmin()
+ ? 'The SMTP host points to a local or private address that is not allowed. Configure allowed internal targets: '.route('settings.advanced').'#endpoint-section'
+ : 'The SMTP host points to a local or private address that is not allowed. Ask your Coolify administrator to allow this host.',
+ };
+
+ if ($message !== null) {
+ $fail($message);
+ }
+ }
+
+ /**
+ * Check the host again just before a connection and return the address to connect to,
+ * so a DNS change after validation cannot point the connection at a blocked address.
+ *
+ * @throws RuntimeException With code HOST_NOT_ALLOWED or HOST_UNRESOLVED.
+ */
+ public static function connectionAddress(string $host): string
+ {
+ $host = self::normalizeHost($host);
+ if (! self::isValidHost($host)) {
+ throw new RuntimeException('The SMTP host is not a valid hostname or IP address.', self::HOST_NOT_ALLOWED);
+ }
+
+ ['status' => $status, 'ips' => $ips] = SafeWebhookUrl::forSmtpHost()->checkConnectionHost($host);
+
+ if ($status === 'unresolved') {
+ throw new RuntimeException('The SMTP host could not be resolved.', self::HOST_UNRESOLVED);
+ }
+
+ if ($status !== 'allowed') {
+ throw new RuntimeException('The SMTP host is not allowed: it points to a local or private address.', self::HOST_NOT_ALLOWED);
+ }
+
+ if ($ips === []) {
+ return $host;
+ }
+
+ $ipv4 = array_values(array_filter($ips, fn (string $ip): bool => filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false));
+
+ return $ipv4[0] ?? $ips[0];
+ }
+
+ /**
+ * The host to check and connect to: an IP address without brackets, or a lower-case host name
+ * without the trailing dot of a fully qualified name (smtp.gmail.com.). TLS verifies this name.
+ */
+ public static function normalizeHost(string $host): string
+ {
+ $host = normalizeIpAddress($host);
+ if (filter_var($host, FILTER_VALIDATE_IP) !== false) {
+ return $host;
+ }
+
+ $host = strtolower($host);
+
+ return str_ends_with($host, '.') ? substr($host, 0, -1) : $host;
+ }
+
+ /**
+ * An IP address, or a host name whose labels have letters, digits, hyphens, and
+ * underscores (Docker service names such as mail_server).
+ */
+ private static function isValidHost(string $host): bool
+ {
+ if (filter_var($host, FILTER_VALIDATE_IP) !== false) {
+ return true;
+ }
+
+ if ($host === '' || strlen($host) > 253) {
+ return false;
+ }
+
+ foreach (explode('.', $host) as $label) {
+ if (preg_match('/\A[a-z0-9_](?:[a-z0-9_-]{0,61}[a-z0-9_])?\z/i', $label) !== 1) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+}
diff --git a/app/Rules/SafeWebhookUrl.php b/app/Rules/SafeWebhookUrl.php
index 23010dc6d5..3676b17e92 100644
--- a/app/Rules/SafeWebhookUrl.php
+++ b/app/Rules/SafeWebhookUrl.php
@@ -3,6 +3,7 @@
namespace App\Rules;
use App\Models\InstanceSettings;
+use App\Services\Dns\DnsResolver;
use Closure;
use GuzzleHttp\Exception\RequestException;
use GuzzleHttp\Psr7\UriComparator;
@@ -11,21 +12,31 @@ use Illuminate\Support\Facades\Log;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\UriInterface;
-use PurplePixie\PhpDns\DNSTypes;
use Throwable;
class SafeWebhookUrl implements ValidationRule
{
+ private const DEFAULT_PORTS = ['https' => 443, 'http' => 80, 'sftp' => 22, 'ftps' => 21];
+
+ /** @var (Closure(string): array)|null */
+ private static ?Closure $fakeResolver = null;
+
/**
* @param (Closure(string): array)|null $resolver
* @param array $trustedInternalHosts
* @param bool $allowPrivateNetworks Allow private, CGNAT, and unique local addresses and internal hostnames.
* Loopback, link-local (cloud metadata), and reserved targets stay blocked.
+ * @param array $schemes The URL schemes to accept, for example sftp for a backup storage.
+ * @param bool $preferSystemDns Ask the system resolver before the custom DNS servers.
+ * @param int $customDnsTimeout Seconds to wait for each custom DNS server query.
*/
public function __construct(
private ?Closure $resolver = null,
private array $trustedInternalHosts = [],
private bool $allowPrivateNetworks = false,
+ private array $schemes = ['http', 'https'],
+ private bool $preferSystemDns = false,
+ private int $customDnsTimeout = 5,
) {}
/**
@@ -37,6 +48,31 @@ class SafeWebhookUrl implements ValidationRule
return new static(allowPrivateNetworks: self::gitSourcesMayUsePrivateNetworks());
}
+ /**
+ * SMTP hosts get the targets of git sources. They are often internal names (split-horizon DNS,
+ * Docker networks), so the system resolver answers first, and a send does not wait long for
+ * custom DNS servers.
+ */
+ public static function forSmtpHost(): static
+ {
+ return new static(
+ allowPrivateNetworks: self::gitSourcesMayUsePrivateNetworks(),
+ preferSystemDns: true,
+ customDnsTimeout: 2,
+ );
+ }
+
+ /**
+ * Resolve hosts with the given callback instead of DNS in every rule without its own
+ * resolver, so tests never send DNS queries. Pass null to reset.
+ *
+ * @param (Closure(string): array)|null $resolver
+ */
+ public static function resolveHostsUsing(?Closure $resolver): void
+ {
+ self::$fakeResolver = $resolver;
+ }
+
public static function gitSourcesMayUsePrivateNetworks(): bool
{
return ! isCloud();
@@ -59,8 +95,8 @@ class SafeWebhookUrl implements ValidationRule
}
$scheme = strtolower(parse_url($value, PHP_URL_SCHEME) ?? '');
- if (! in_array($scheme, ['https', 'http'])) {
- $fail('The :attribute must use the http or https scheme.');
+ if (! in_array($scheme, $this->schemes, true)) {
+ $fail('The :attribute must use the '.implode(' or ', $this->schemes).' scheme.');
return;
}
@@ -214,11 +250,73 @@ class SafeWebhookUrl implements ValidationRule
return array_map(fn (string $ip): string => sprintf('%s:%d=%s', $target['host'], $target['port'], $ip), $ips);
}
+ /**
+ * The address to connect to for an endpoint that is not HTTP (SFTP, FTPS): the validated IP of its host, so a
+ * DNS change after the check cannot send the connection to another address.
+ *
+ * @param array $schemes
+ * @param array $trustedInternalHosts
+ */
+ public static function pinnedAddress(string $url, array $schemes, array $trustedInternalHosts = []): string
+ {
+ $target = self::resolveUrlForRequest($url, $trustedInternalHosts, schemes: $schemes);
+
+ return $target['ips'] === [] ? $target['host'] : self::preferredIp($target['ips']);
+ }
+
+ /**
+ * @param non-empty-array $ips
+ */
+ private static function preferredIp(array $ips): string
+ {
+ foreach ($ips as $ip) {
+ if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false) {
+ return $ip;
+ }
+ }
+
+ return $ips[0];
+ }
+
private static function isIpv6(string $ip): bool
{
return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false;
}
+ /**
+ * Resolve and check a host for a connection that is not HTTP (for example SMTP).
+ * The caller checks the host syntax first. "ips" has the checked addresses, or is empty
+ * when the host is an IP address.
+ *
+ * @return array{status: 'allowed'|'blocked'|'link_local'|'unresolved', ips: array}
+ */
+ public function checkConnectionHost(string $host): array
+ {
+ $host = rtrim($this->normalizeHostForIpCheck(strtolower($host)), '.');
+
+ if ($this->isBlockedHostname($host) && ! $this->isAllowedHostname($host)) {
+ $this->logBlockedHost('connection host', $host);
+
+ return ['status' => 'blocked', 'ips' => []];
+ }
+
+ $isIp = filter_var($host, FILTER_VALIDATE_IP) !== false;
+ $ips = $isIp ? [$host] : $this->resolveHost($host);
+ if ($ips === []) {
+ return ['status' => 'unresolved', 'ips' => []];
+ }
+
+ foreach ($ips as $ip) {
+ if (! $this->isAllowedIp($ip, $host)) {
+ $this->logBlockedIp('connection host', $host, $ip);
+
+ return ['status' => $this->isLinkLocalIp($ip) ? 'link_local' : 'blocked', 'ips' => []];
+ }
+ }
+
+ return ['status' => 'allowed', 'ips' => $isIp ? [] : $ips];
+ }
+
public static function redactedUrlForLog(string $url): string
{
$scheme = parse_url($url, PHP_URL_SCHEME);
@@ -235,9 +333,9 @@ class SafeWebhookUrl implements ValidationRule
/**
* @return array{host: string, port: int, ips: array}
*/
- private static function resolveUrlForRequest(string $url, array $trustedInternalHosts = [], ?Closure $resolver = null, bool $allowPrivateNetworks = false): array
+ private static function resolveUrlForRequest(string $url, array $trustedInternalHosts = [], ?Closure $resolver = null, bool $allowPrivateNetworks = false, array $schemes = ['http', 'https']): array
{
- $rule = new self(resolver: $resolver, trustedInternalHosts: $trustedInternalHosts, allowPrivateNetworks: $allowPrivateNetworks);
+ $rule = new self(resolver: $resolver, trustedInternalHosts: $trustedInternalHosts, allowPrivateNetworks: $allowPrivateNetworks, schemes: $schemes);
if (! filter_var($url, FILTER_VALIDATE_URL)) {
throw new \RuntimeException('Webhook URL is invalid.');
}
@@ -252,11 +350,11 @@ class SafeWebhookUrl implements ValidationRule
}
$scheme = strtolower(parse_url($url, PHP_URL_SCHEME) ?? '');
- if (! in_array($scheme, ['http', 'https'], true)) {
+ if (! in_array($scheme, $schemes, true)) {
throw new \RuntimeException('Webhook URL scheme is unsafe.');
}
- $port = parse_url($url, PHP_URL_PORT) ?: ($scheme === 'https' ? 443 : 80);
+ $port = parse_url($url, PHP_URL_PORT) ?: self::DEFAULT_PORTS[$scheme];
$hostForDns = rtrim($rule->normalizeHostForIpCheck(strtolower($host)), '.');
if (filter_var($hostForDns, FILTER_VALIDATE_IP)) {
@@ -297,14 +395,24 @@ class SafeWebhookUrl implements ValidationRule
*/
private function resolveHost(string $host): array
{
- if ($this->resolver instanceof Closure) {
- return array_values(array_filter(($this->resolver)($host), fn (string $ip): bool => filter_var($ip, FILTER_VALIDATE_IP) !== false));
+ $resolver = $this->resolver ?? self::$fakeResolver;
+ if ($resolver instanceof Closure) {
+ return array_values(array_filter($resolver($host), fn (string $ip): bool => filter_var($ip, FILTER_VALIDATE_IP) !== false));
}
if ($host === 'localhost') {
return ['127.0.0.1', '::1'];
}
+ if ($this->preferSystemDns) {
+ $systemResolvedIps = $this->resolveHostWithSystemDns($host);
+ $customDnsServers = $this->customDnsServers();
+
+ return $systemResolvedIps !== [] || $customDnsServers === []
+ ? $systemResolvedIps
+ : $this->resolveHostWithCustomDnsServers($host, $customDnsServers);
+ }
+
$customDnsServers = $this->customDnsServers();
if ($customDnsServers !== []) {
$customResolvedIps = $this->resolveHostWithCustomDnsServers($host, $customDnsServers);
@@ -359,9 +467,9 @@ class SafeWebhookUrl implements ValidationRule
$ips = [];
foreach ($dnsServers as $dnsServer) {
- foreach ([DNSTypes::NAME_A, DNSTypes::NAME_AAAA] as $type) {
+ foreach ([DnsResolver::TYPE_A, DnsResolver::TYPE_AAAA] as $type) {
try {
- $records = queryDnsServer($dnsServer, $host, $type);
+ $records = queryDnsServer($dnsServer, $host, $type, $this->customDnsTimeout);
if ($records === false) {
continue;
diff --git a/app/Services/AutoUpdateNotifier.php b/app/Services/AutoUpdateNotifier.php
new file mode 100644
index 0000000000..e077bc87e9
--- /dev/null
+++ b/app/Services/AutoUpdateNotifier.php
@@ -0,0 +1,175 @@
+update([
+ 'auto_update_target_version' => $targetVersion,
+ 'auto_update_started_at' => now(),
+ ]);
+ }
+
+ public static function started(string $currentVersion, string $targetVersion): void
+ {
+ self::notify("Automatic update started: Coolify {$currentVersion} to {$targetVersion}.", true);
+ }
+
+ public static function failed(string $reason): void
+ {
+ self::clearAttempt();
+ self::notifyFailure($reason);
+ }
+
+ /**
+ * Reports the result of the saved attempt: success when this version is the target,
+ * failure when the upgrade script wrote an error or the attempt timed out.
+ */
+ public static function checkResult(): void
+ {
+ $settings = instanceSettings();
+ $targetVersion = $settings->auto_update_target_version;
+ if (blank($targetVersion)) {
+ return;
+ }
+
+ $startedAt = $settings->auto_update_started_at;
+ $runningVersion = (string) config('constants.coolify.version');
+ if (CoolifyUpgradeStatus::hasReachedTargetVersion($runningVersion, $targetVersion)) {
+ if (self::claimAttempt($settings, $targetVersion)
+ && ! self::notify("Automatic update finished: Coolify is now on {$runningVersion}.", true)) {
+ self::restoreAttempt($targetVersion, $startedAt);
+ }
+
+ return;
+ }
+
+ $status = self::readUpgradeStatus();
+ [$step, $message, $writtenAt] = array_pad(explode('|', trim($status)), 3, '');
+ if ($step === 'error' && self::isWrittenDuringAttempt($writtenAt, $startedAt)) {
+ if (self::claimAttempt($settings, $targetVersion)
+ && ! self::notifyFailure("the upgrade to {$targetVersion} stopped with this error: {$message}. Log: /data/coolify/source/upgrade-*.log")) {
+ self::restoreAttempt($targetVersion, $startedAt);
+ }
+
+ return;
+ }
+
+ $timedOut = ! $startedAt || $startedAt->lt(now()->subMinutes(self::RESULT_TIMEOUT_MINUTES));
+ if ($timedOut && ! CoolifyUpgradeStatus::isRunning($status) && self::claimAttempt($settings, $targetVersion)
+ && ! self::notifyFailure("Coolify {$targetVersion} did not start within ".self::RESULT_TIMEOUT_MINUTES." minutes. Coolify {$runningVersion} is still running. Log: /data/coolify/source/upgrade-*.log")) {
+ self::restoreAttempt($targetVersion, $startedAt);
+ }
+ }
+
+ /**
+ * An error from an earlier attempt stays in the status file until the new upgrade script writes its first step.
+ */
+ private static function isWrittenDuringAttempt(string $writtenAt, ?CarbonInterface $startedAt): bool
+ {
+ if (! $startedAt) {
+ return true;
+ }
+
+ try {
+ return Carbon::parse(trim($writtenAt))->gte($startedAt->copy()->startOfSecond());
+ } catch (\Throwable) {
+ return false;
+ }
+ }
+
+ private static function readUpgradeStatus(): string
+ {
+ try {
+ $server = Server::find(0);
+
+ return $server ? (string) instant_remote_process(['cat '.CoolifyUpgradeStatus::FILE.' 2>/dev/null || true'], $server, false) : '';
+ } catch (\Throwable $e) {
+ Log::warning('Could not read the upgrade status', ['error' => $e->getMessage()]);
+
+ return '';
+ }
+ }
+
+ /**
+ * Clears the attempt only if it is still saved for this target version.
+ * Returns false when another run already reported it, so each result is sent one time.
+ */
+ private static function claimAttempt(InstanceSettings $settings, string $targetVersion): bool
+ {
+ $claimed = InstanceSettings::query()
+ ->whereKey($settings->getKey())
+ ->where('auto_update_target_version', $targetVersion)
+ ->update([
+ 'auto_update_target_version' => null,
+ 'auto_update_started_at' => null,
+ ]) === 1;
+
+ $settings->refresh();
+
+ return $claimed;
+ }
+
+ /**
+ * Saves a claimed attempt again after its result could not be queued, so the next check sends it again.
+ * Does nothing when a newer attempt was saved in the meantime.
+ */
+ private static function restoreAttempt(string $targetVersion, ?CarbonInterface $startedAt): void
+ {
+ InstanceSettings::query()
+ ->whereKey(0)
+ ->whereNull('auto_update_target_version')
+ ->update([
+ 'auto_update_target_version' => $targetVersion,
+ 'auto_update_started_at' => $startedAt,
+ ]);
+ }
+
+ private static function clearAttempt(): void
+ {
+ instanceSettings()->update([
+ 'auto_update_target_version' => null,
+ 'auto_update_started_at' => null,
+ ]);
+ }
+
+ private static function notifyFailure(string $reason): bool
+ {
+ return self::notify("Automatic update failed: {$reason}\n\nFix the cause, then start the update again: ".base_url().'/settings/updates', false);
+ }
+
+ /** Returns false when the notification could not be queued. */
+ private static function notify(string $message, bool $success): bool
+ {
+ try {
+ Team::find(0)?->notify(new GeneralNotification($message, $success));
+
+ return true;
+ } catch (\Throwable $e) {
+ Log::warning('Could not send the automatic update notification', ['error' => $e->getMessage()]);
+
+ return false;
+ }
+ }
+}
diff --git a/app/Services/AvatarStorageService.php b/app/Services/AvatarStorageService.php
index 889293d668..6feb174008 100644
--- a/app/Services/AvatarStorageService.php
+++ b/app/Services/AvatarStorageService.php
@@ -74,7 +74,7 @@ class AvatarStorageService
return Storage::disk('images');
}
- $storage = S3Storage::query()->whereKey($s3StorageId)->where('is_usable', true)->first();
+ $storage = S3Storage::query()->s3Only()->whereKey($s3StorageId)->where('is_usable', true)->first();
if (! $storage) {
throw new RuntimeException('The configured S3 storage is not available.');
}
diff --git a/app/Services/CoolifyUpgradeStatus.php b/app/Services/CoolifyUpgradeStatus.php
index fb27017083..9075d0b07e 100644
--- a/app/Services/CoolifyUpgradeStatus.php
+++ b/app/Services/CoolifyUpgradeStatus.php
@@ -2,12 +2,18 @@
namespace App\Services;
+use App\Models\Server;
use DateTimeInterface;
class CoolifyUpgradeStatus
{
public const FILE = '/data/coolify/source/.upgrade-status';
+ /**
+ * scripts/upgrade.sh holds this lock while it writes a status, so an error write cannot interleave with it.
+ */
+ public const LOCK_FILE = '/data/coolify/source/.upgrade-status.lock';
+
public const STALE_AFTER_MINUTES = 10;
/**
@@ -85,6 +91,39 @@ class CoolifyUpgradeStatus
];
}
+ /**
+ * Writes an error that stopped an upgrade before the upgrade script started, so the upgrade modal shows it.
+ * Does nothing while an upgrade script runs, because the error then belongs to this attempt only.
+ * The write happens only if the status file did not change after the read, so a status that an
+ * upgrade script wrote in the meantime is kept.
+ */
+ public static function writeError(Server $server, string $message): void
+ {
+ $current = (string) instant_remote_process(['cat '.self::FILE.' 2>/dev/null || true'], $server, false);
+ if (self::isRunning($current)) {
+ return;
+ }
+
+ $line = 'error|'.trim((string) preg_replace('/[|\r\n]+/', ' ', $message)).'|'.now()->toIso8601String();
+ instant_remote_process([self::compareAndWriteCommand($current, $line)], $server, false);
+ }
+
+ /**
+ * Shell command that writes $line to the status file only if the file still contains $expected.
+ * It holds the lock that scripts/upgrade.sh takes for its status writes, if flock is available.
+ */
+ public static function compareAndWriteCommand(string $expected, string $line): string
+ {
+ $script = 'if command -v flock >/dev/null 2>&1; then exec 9>"$2"; flock -w 10 9 || exit 1; fi; '
+ .'if [ "$(cat "$1" 2>/dev/null)" = "$(printf %s "$3" | base64 -d)" ]; then printf %s "$4" | base64 -d > "$1"; echo >> "$1"; fi';
+
+ return 'sh -c '.escapeshellarg($script).' _ '
+ .escapeshellarg(self::FILE).' '
+ .escapeshellarg(self::LOCK_FILE).' '
+ .escapeshellarg(base64_encode(trim($expected))).' '
+ .escapeshellarg(base64_encode($line));
+ }
+
public static function isRunning(string $content, ?DateTimeInterface $now = null): bool
{
$parts = explode('|', trim($content));
diff --git a/app/Services/Dns/DnsRecord.php b/app/Services/Dns/DnsRecord.php
new file mode 100644
index 0000000000..43d543dbcc
--- /dev/null
+++ b/app/Services/Dns/DnsRecord.php
@@ -0,0 +1,24 @@
+type;
+ }
+
+ public function getData(): string
+ {
+ return $this->data;
+ }
+}
diff --git a/app/Services/Dns/DnsResolver.php b/app/Services/Dns/DnsResolver.php
new file mode 100644
index 0000000000..0cf24c2c38
--- /dev/null
+++ b/app/Services/Dns/DnsResolver.php
@@ -0,0 +1,280 @@
+ */
+ private const TYPE_IDS = [self::TYPE_A => 1, self::TYPE_AAAA => 28];
+
+ private bool $error = false;
+
+ private string $lasterror = '';
+
+ public function __construct(
+ private readonly string $server,
+ private readonly int $port = 53,
+ private readonly int $timeout = 5,
+ private readonly bool $udp = true,
+ ) {}
+
+ public function hasError(): bool
+ {
+ return $this->error;
+ }
+
+ public function getLasterror(): string
+ {
+ return $this->lasterror;
+ }
+
+ /**
+ * Query the resolver for the address records of one host.
+ *
+ * @return array|false
+ */
+ public function query(string $host, string $type = self::TYPE_A): array|false
+ {
+ $this->error = false;
+ $this->lasterror = '';
+
+ $qtype = self::TYPE_IDS[$type] ?? self::TYPE_IDS[self::TYPE_A];
+ $id = random_int(0, 0xFFFF);
+ $request = self::buildQuery($host, $qtype, $id);
+
+ $raw = $this->udp ? $this->exchangeUdp($request) : $this->exchangeTcp($request);
+ if ($raw === false) {
+ return false;
+ }
+
+ if (strlen($raw) < 12) {
+ return $this->fail('Return buffer too small');
+ }
+
+ $header = unpack('nid/nflags', substr($raw, 0, 12));
+ if ($header['id'] !== $id) {
+ return $this->fail('DNS response transaction id mismatch');
+ }
+
+ // TC (truncation) bit set: ask the caller to retry over TCP.
+ if ($this->udp && ($header['flags'] & 0x0200) !== 0) {
+ return $this->fail('Response too big for UDP, retry with TCP');
+ }
+
+ return self::parseAnswers($raw, $type);
+ }
+
+ /**
+ * Parse the answer section, returning only the requested address records.
+ *
+ * Pure function of the raw response bytes, so it is safe to call directly
+ * in tests with crafted (including malicious) payloads.
+ *
+ * @return array
+ */
+ public static function parseAnswers(string $raw, string $type): array
+ {
+ $length = strlen($raw);
+ if ($length < 12) {
+ return [];
+ }
+
+ $header = unpack('nid/nflags/nqd/nan/nns/nar', substr($raw, 0, 12));
+ $offset = 12;
+
+ for ($i = 0; $i < $header['qd']; $i++) {
+ $offset = self::skipName($raw, $offset) + 4; // skip QTYPE + QCLASS
+ if ($offset > $length) {
+ return [];
+ }
+ }
+
+ $wantId = self::TYPE_IDS[$type] ?? self::TYPE_IDS[self::TYPE_A];
+ $records = [];
+
+ for ($i = 0; $i < $header['an']; $i++) {
+ $offset = self::skipName($raw, $offset);
+ if ($offset + 10 > $length) {
+ break;
+ }
+
+ $rr = unpack('ntype/nclass/Nttl/nrdlength', substr($raw, $offset, 10));
+ $offset += 10;
+ $rdlength = $rr['rdlength'];
+
+ if ($offset + $rdlength > $length) {
+ break;
+ }
+
+ $rdata = substr($raw, $offset, $rdlength);
+ $offset += $rdlength;
+
+ if ($rr['type'] !== $wantId) {
+ continue;
+ }
+
+ if (($wantId === 1 && $rdlength === 4) || ($wantId === 28 && $rdlength === 16)) {
+ $ip = @inet_ntop($rdata);
+ if ($ip !== false) {
+ $records[] = new DnsRecord($type, $ip);
+ }
+ }
+ }
+
+ return $records;
+ }
+
+ /**
+ * Skip one domain name and return the offset just past its on-wire field.
+ *
+ * Compression pointers are not followed: the name value is never needed, so
+ * a pointer is treated as a fixed two-byte field. The uncompressed label
+ * walk is capped, so the method always terminates in bounded time and a
+ * self-referential or looping pointer cannot cause recursion.
+ */
+ private static function skipName(string $raw, int $offset): int
+ {
+ $length = strlen($raw);
+
+ for ($labels = 0; $labels <= 128; $labels++) {
+ if ($offset >= $length) {
+ return $length;
+ }
+
+ $marker = ord($raw[$offset]);
+
+ if ($marker === 0) {
+ return $offset + 1;
+ }
+
+ if (($marker & 0xC0) === 0xC0) {
+ return $offset + 2;
+ }
+
+ if (($marker & 0xC0) !== 0) {
+ return $length; // reserved bits set: malformed
+ }
+
+ $offset += 1 + $marker;
+ }
+
+ return $length; // too many labels: malformed
+ }
+
+ private static function buildQuery(string $host, int $qtype, int $id): string
+ {
+ $query = pack('nnnnnn', $id, 0x0100, 1, 0, 0, 0); // RD set, one question
+
+ foreach (explode('.', trim($host, '.')) as $label) {
+ if ($label === '') {
+ continue;
+ }
+ $label = substr($label, 0, 63);
+ $query .= chr(strlen($label)).$label;
+ }
+
+ $query .= "\0";
+ $query .= pack('nn', $qtype, 1); // QTYPE, QCLASS IN
+
+ return $query;
+ }
+
+ private function exchangeUdp(string $request): string|false
+ {
+ $socket = @fsockopen('udp://'.$this->server, $this->port, $errno, $errstr, $this->timeout);
+ if ($socket === false) {
+ return $this->fail("Failed to open socket (code {$errno}: {$errstr})");
+ }
+
+ stream_set_timeout($socket, $this->timeout);
+
+ if (@fwrite($socket, $request) === false) {
+ fclose($socket);
+
+ return $this->fail('Failed to write question to socket');
+ }
+
+ $raw = @fread($socket, 4096);
+ fclose($socket);
+
+ if ($raw === false || $raw === '') {
+ return $this->fail('Failed to read data buffer');
+ }
+
+ return $raw;
+ }
+
+ private function exchangeTcp(string $request): string|false
+ {
+ $socket = @fsockopen('tcp://'.$this->server, $this->port, $errno, $errstr, $this->timeout);
+ if ($socket === false) {
+ return $this->fail("Failed to open socket (code {$errno}: {$errstr})");
+ }
+
+ stream_set_timeout($socket, $this->timeout);
+
+ if (@fwrite($socket, pack('n', strlen($request)).$request) === false) {
+ fclose($socket);
+
+ return $this->fail('Failed to write question to TCP socket');
+ }
+
+ $sizeData = $this->readTcp($socket, 2);
+ if ($sizeData === false) {
+ fclose($socket);
+
+ return $this->fail('Failed to read size from TCP socket');
+ }
+
+ $size = unpack('n', $sizeData)[1];
+ $raw = $size > 0 ? $this->readTcp($socket, $size) : '';
+ fclose($socket);
+
+ if ($raw === false) {
+ return $this->fail('Failed to read data buffer');
+ }
+
+ return $raw;
+ }
+
+ /**
+ * @param resource $socket
+ */
+ private function readTcp($socket, int $size): string|false
+ {
+ $buffer = '';
+ while (strlen($buffer) < $size) {
+ $chunk = @fread($socket, $size - strlen($buffer));
+ if ($chunk === false || $chunk === '') {
+ break;
+ }
+ $buffer .= $chunk;
+ }
+
+ return strlen($buffer) === $size ? $buffer : false;
+ }
+
+ private function fail(string $message): false
+ {
+ $this->error = true;
+ $this->lasterror = $message;
+
+ return false;
+ }
+}
diff --git a/app/Services/DockerRegistryLogins.php b/app/Services/DockerRegistryLogins.php
index 3fdc928aba..d389e8715b 100644
--- a/app/Services/DockerRegistryLogins.php
+++ b/app/Services/DockerRegistryLogins.php
@@ -17,6 +17,9 @@ class DockerRegistryLogins
{
public const DOCKER_HUB = 'docker.io';
+ /** Credentials stay in the output, which instant_remote_process neither logs nor stores. */
+ private const READ_CONFIG = 'cat "$HOME/.docker/config.json" 2>/dev/null || true';
+
/**
* A registry host with an optional port. An IPv6 host is bracketed ([2a01:4f8::1]:5000) and holds only
* hex digits, ':' and '.', so the value stays safe in shell commands.
@@ -32,11 +35,59 @@ class DockerRegistryLogins
*/
public static function forServer(Server $server): array
{
- $config = instant_remote_process(['cat "$HOME/.docker/config.json" 2>/dev/null || true'], $server);
+ $config = instant_remote_process([self::READ_CONFIG], $server);
return self::parseConfig($config);
}
+ /**
+ * The `auths` login of one registry from the server's Docker config, as a base64 Docker config that holds only
+ * that registry, for the scanner commands. The config is read over SSH without logging; the result is never
+ * stored or logged. Credential helpers and stores cannot be read and take precedence over `auths`, like in Docker:
+ * they return the status `helper`.
+ *
+ * @return array{status: 'ok'|'helper'|'none', config: ?string}
+ */
+ public static function registryAuthConfig(Server $server, string $registry): array
+ {
+ $config = instant_remote_process([self::READ_CONFIG], $server, throwError: false);
+
+ return self::singleRegistryConfig($config, $registry);
+ }
+
+ /**
+ * @return array{status: 'ok'|'helper'|'none', config: ?string}
+ */
+ public static function singleRegistryConfig(?string $config, string $registry): array
+ {
+ $registry = self::normalizeRegistry($registry);
+ $decoded = json_decode((string) $config, true);
+ if (! is_array($decoded)) {
+ return ['status' => 'none', 'config' => null];
+ }
+
+ // Docker reads a credential helper or store before `auths`, so an `auths` entry next to one may be stale.
+ $helpers = is_array($decoded['credHelpers'] ?? null) ? array_map(fn ($key) => self::normalizeRegistry((string) $key), array_keys($decoded['credHelpers'])) : [];
+ $store = is_string($decoded['credsStore'] ?? null) && $decoded['credsStore'] !== '';
+ if ($store || in_array($registry, $helpers, true)) {
+ return ['status' => 'helper', 'config' => null];
+ }
+
+ foreach (is_array($decoded['auths'] ?? null) ? $decoded['auths'] : [] as $key => $entry) {
+ $auth = is_array($entry) && self::normalizeRegistry((string) $key) === $registry ? self::basicAuth($entry) : null;
+ if ($auth !== null) {
+ $configKey = $registry === self::DOCKER_HUB ? 'https://index.docker.io/v1/' : $registry;
+
+ return [
+ 'status' => 'ok',
+ 'config' => base64_encode(json_encode(['auths' => [$configKey => ['auth' => $auth]]], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR)),
+ ];
+ }
+ }
+
+ return ['status' => 'none', 'config' => null];
+ }
+
/**
* @return array
*/
@@ -161,6 +212,28 @@ class DockerRegistryLogins
->values();
}
+ /**
+ * The base64 "username:password" of an `auths` entry, from "auth" or from "username" and "password".
+ *
+ * @param array $entry
+ */
+ private static function basicAuth(array $entry): ?string
+ {
+ $auth = is_string($entry['auth'] ?? null) ? $entry['auth'] : '';
+ $decoded = preg_match('/\A[A-Za-z0-9+\/]+={0,2}\z/', $auth) === 1 ? base64_decode($auth, true) : false;
+ if (is_string($decoded) && str_contains($decoded, ':')) {
+ return $auth;
+ }
+
+ $username = $entry['username'] ?? null;
+ $password = $entry['password'] ?? null;
+ if (is_string($username) && $username !== '' && ! str_contains($username, ':') && is_string($password) && $password !== '') {
+ return base64_encode($username.':'.$password);
+ }
+
+ return null;
+ }
+
/**
* Docker stores "username:password" base64 encoded in "auth"; keep only the username part.
*
diff --git a/app/Services/EnvironmentCanvas.php b/app/Services/EnvironmentCanvas.php
index c0310dbf05..c4acc84207 100644
--- a/app/Services/EnvironmentCanvas.php
+++ b/app/Services/EnvironmentCanvas.php
@@ -8,6 +8,8 @@ use App\Models\Project;
use App\Models\ResourceLink;
use App\Models\Server;
use App\Models\Service;
+use App\Services\ImageScan\ResourceScans;
+use App\Services\ImageScan\VulnerabilityMarkers;
use App\Support\ResourceStartActivity;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Database\Eloquent\Model;
@@ -101,13 +103,17 @@ class EnvironmentCanvas
{
$startingServiceUuids = $this->startingServiceUuids($resources->filter(fn (Model $resource) => $resource instanceof Service));
+ $markers = VulnerabilityMarkers::for($resources->values());
+
$servers = collect();
- $nodes = $resources->map(function (Model $resource) use (&$servers, $startingServiceUuids) {
+ $nodes = $resources->map(function (Model $resource) use (&$servers, $startingServiceUuids, $markers) {
$node = match (true) {
$resource instanceof Application => $this->applicationNode($resource),
$resource instanceof Service => $this->serviceNode($resource, in_array($resource->uuid, $startingServiceUuids, true)),
default => $this->databaseNode($resource),
};
+ // The security marker of the card: "Update now" or "No fix yet".
+ $node['security'] = $markers[ResourceScans::key($resource)] ?? null;
foreach ($node['servers'] as $server) {
$servers->put($server->id, $server);
}
diff --git a/app/Services/FileDownloadStorageService.php b/app/Services/FileDownloadStorageService.php
index 6524a24764..6112033120 100644
--- a/app/Services/FileDownloadStorageService.php
+++ b/app/Services/FileDownloadStorageService.php
@@ -39,7 +39,7 @@ class FileDownloadStorageService
return null;
}
- return S3Storage::query()->whereTeamId(0)->where('is_usable', true)->find($storageId);
+ return S3Storage::query()->s3Only()->whereTeamId(0)->where('is_usable', true)->find($storageId);
}
public static function newObjectKey(): string
diff --git a/app/Services/ImageScan/BaseImageCheck.php b/app/Services/ImageScan/BaseImageCheck.php
new file mode 100644
index 0000000000..cde86a9848
--- /dev/null
+++ b/app/Services/ImageScan/BaseImageCheck.php
@@ -0,0 +1,88 @@
+resource;
+ if ($better === null || ! $owner instanceof Model) {
+ return null;
+ }
+
+ $base = (array) data_get($scan->candidates, 'base');
+ $from = $better['from'];
+ $attributableIds = array_flip(array_map(fn ($id): string => mb_strtolower((string) $id), (array) ($base['attributable_ids'] ?? [])));
+ $fixedIds = array_flip(array_map(fn ($id): string => mb_strtolower((string) $id), (array) ($better['entry']['fixed_ids'] ?? [])));
+ $has = fn (Finding $finding, array $ids): bool => collect([$finding->id, ...$finding->aliases])->contains(fn (string $id): bool => isset($ids[mb_strtolower($id)]));
+ $attributable = array_filter(RefreshCandidates::relevantFindings($scan, $owner), fn (Finding $finding): bool => $has($finding, $attributableIds));
+ $total = count($attributable);
+ $fixed = count(array_filter($attributable, fn (Finding $finding): bool => $has($finding, $fixedIds)));
+ if ($total === 0 || $fixed === 0) {
+ return null;
+ }
+
+ return [
+ 'from' => $from,
+ 'to' => $better['to'],
+ 'fixed' => $fixed,
+ 'total' => $total,
+ ];
+ }
+
+ /**
+ * The better `line` candidate of the base check, when the check is for the current base image (`base_image_ref`)
+ * and the candidate is a newer tag of the same image. `to` keeps the registry and name as the Dockerfile writes them.
+ *
+ * @return array{from: string, to: string, entry: array}|null
+ */
+ public static function betterBase(ImageScan $scan): ?array
+ {
+ $from = (string) $scan->base_image_ref;
+ $base = data_get($scan->candidates, 'base');
+ if ($from === '' || ! is_array($base) || ($base['status'] ?? null) !== 'ok' || ($base['image'] ?? null) !== $from) {
+ return null;
+ }
+
+ $entry = collect(is_array($base['entries'] ?? null) ? $base['entries'] : [])
+ ->first(fn ($entry): bool => is_array($entry) && ($entry['kind'] ?? null) === 'line' && ($entry['better'] ?? false) === true && is_string($entry['ref'] ?? null));
+ if ($entry === null) {
+ return null;
+ }
+
+ try {
+ $current = ImageRef::parse($from);
+ $candidate = ImageRef::parse($entry['ref']);
+ } catch (InvalidArgumentException) {
+ return null;
+ }
+ if ($current->tag === null || $current->digest !== null || $candidate->tag === null || $candidate->tag === $current->tag
+ || $candidate->qualifiedName() !== $current->qualifiedName()) {
+ return null;
+ }
+
+ return [
+ 'from' => $from,
+ 'to' => substr($from, 0, (int) strrpos($from, ':')).':'.$candidate->tag,
+ 'entry' => $entry,
+ ];
+ }
+}
diff --git a/app/Services/ImageScan/CandidateEvaluator.php b/app/Services/ImageScan/CandidateEvaluator.php
new file mode 100644
index 0000000000..a2a94d0729
--- /dev/null
+++ b/app/Services/ImageScan/CandidateEvaluator.php
@@ -0,0 +1,174 @@
+ $currentFindings
+ * @param list $candidateFindings
+ * @param array|null $currentEol The end-of-life distro of the current image.
+ * @param array|null $candidateEol The end-of-life distro of the candidate.
+ */
+ public function isBetter(array $currentFindings, array $candidateFindings, RelevanceRules $rules, ?array $currentEol, ?array $candidateEol): bool
+ {
+ $current = [];
+ foreach ($this->notIgnored($currentFindings, $rules) as $finding) {
+ foreach ($this->keys($finding) as $key) {
+ $current[$key] = true;
+ }
+ }
+
+ $candidateRelevant = $this->relevant($candidateFindings, $rules);
+ foreach ($candidateRelevant as $finding) {
+ if (SecurityRules::exploitSignal($finding) !== null && array_intersect_key(array_flip($this->keys($finding)), $current) === []) {
+ return false;
+ }
+ }
+
+ $currentRelevant = $this->relevant($currentFindings, $rules);
+ $fixesExploit = array_filter($this->fixed($currentRelevant, $candidateFindings), fn (Finding $finding): bool => SecurityRules::exploitSignal($finding) !== null) !== [];
+
+ return $fixesExploit
+ || ($currentEol !== null && $candidateEol === null)
+ || count($candidateRelevant) + ($candidateEol === null ? 0 : 1) < count($currentRelevant) + ($currentEol === null ? 0 : 1);
+ }
+
+ /**
+ * IDs and aliases of the relevant current findings that the candidate no longer has under any of their IDs.
+ *
+ * @param list $current
+ * @param list $candidate All findings of the candidate.
+ * @return list
+ */
+ public function fixedIds(array $current, array $candidate, RelevanceRules $rules): array
+ {
+ $fixed = [];
+ foreach ($this->fixed($this->relevant($current, $rules), $candidate) as $finding) {
+ array_push($fixed, ...$this->ids($finding));
+ }
+
+ return array_values(array_unique($fixed));
+ }
+
+ /**
+ * @param list $findings
+ * @return list
+ */
+ public function relevant(array $findings, RelevanceRules $rules): array
+ {
+ return array_values(array_filter($findings, fn (Finding $finding): bool => SecurityRules::isRelevant($finding, $rules)));
+ }
+
+ /**
+ * The current findings that the candidate no longer has.
+ *
+ * @param list $current
+ * @param list $candidate
+ * @return list
+ */
+ public function fixed(array $current, array $candidate): array
+ {
+ $remaining = [];
+ foreach ($candidate as $finding) {
+ foreach ($this->keys($finding) as $key) {
+ $remaining[$key] = true;
+ }
+ }
+
+ return array_values(array_filter($current, fn (Finding $finding) => array_intersect_key(array_flip($this->keys($finding)), $remaining) === []));
+ }
+
+ /**
+ * The current findings that the other list also has under one of their IDs.
+ *
+ * @param list $current
+ * @param list $other
+ * @return list
+ */
+ public function shared(array $current, array $other): array
+ {
+ $fixed = $this->fixed($current, $other);
+
+ return array_values(array_filter($current, fn (Finding $finding) => ! in_array($finding, $fixed, true)));
+ }
+
+ /**
+ * The relevant critical, high, and medium findings of the candidate that the relevant current findings do not have
+ * under any of their IDs, counted once per finding ID. The same basis as `counts()` of `fixed()`.
+ *
+ * @param list $currentRelevant
+ * @param list $candidate All findings of the candidate.
+ */
+ public function added(array $currentRelevant, array $candidate, RelevanceRules $rules): int
+ {
+ $known = [];
+ foreach ($currentRelevant as $finding) {
+ foreach ($this->keys($finding) as $key) {
+ $known[$key] = true;
+ }
+ }
+
+ $added = [];
+ foreach ($this->relevant($candidate, $rules) as $finding) {
+ if (in_array(strtolower($finding->severity), self::COUNTED_SEVERITIES, true)
+ && array_intersect_key(array_flip($this->keys($finding)), $known) === []) {
+ $added[mb_strtolower($finding->id)] = true;
+ }
+ }
+
+ return count($added);
+ }
+
+ /**
+ * @param list $findings
+ * @return array{critical: int, high: int, medium: int}
+ */
+ public function counts(array $findings): array
+ {
+ $counts = array_fill_keys(self::COUNTED_SEVERITIES, 0);
+ foreach ($findings as $finding) {
+ $severity = strtolower($finding->severity);
+ if (isset($counts[$severity])) {
+ $counts[$severity]++;
+ }
+ }
+
+ return $counts;
+ }
+
+ /**
+ * @param list $findings
+ * @return list
+ */
+ private function notIgnored(array $findings, RelevanceRules $rules): array
+ {
+ return array_values(array_filter($findings, fn (Finding $finding): bool => $rules->ignores->matches($finding) === null));
+ }
+
+ /**
+ * @return list
+ */
+ private function ids(Finding $finding): array
+ {
+ return array_values(array_unique(array_filter([$finding->id, ...$finding->aliases], fn (string $id) => $id !== '')));
+ }
+
+ /**
+ * IDs compare without case, like ignores and the fix classifier.
+ *
+ * @return list
+ */
+ private function keys(Finding $finding): array
+ {
+ return array_values(array_unique(array_map('mb_strtolower', $this->ids($finding))));
+ }
+}
diff --git a/app/Services/ImageScan/ComposeImageVariable.php b/app/Services/ImageScan/ComposeImageVariable.php
new file mode 100644
index 0000000000..4ed82fff77
--- /dev/null
+++ b/app/Services/ImageScan/ComposeImageVariable.php
@@ -0,0 +1,84 @@
+[A-Za-z_][A-Za-z0-9_]*)(?:(?:?[-?])(?[^}]*))?\}|\$(?[A-Za-z_][A-Za-z0-9_]*)';
+
+ private function __construct(
+ public string $key,
+ public bool $tagOnly,
+ public string $prefix,
+ public ?string $default,
+ ) {}
+
+ /**
+ * Null for an image without a variable, or with a variable in a form that Coolify cannot change.
+ */
+ public static function parse(string $image): ?self
+ {
+ $image = trim($image);
+ if (preg_match('/\A(?:'.self::VARIABLE.')\z/', $image, $match) === 1) {
+ return self::from($match, false, '');
+ }
+ if (preg_match('/\A(?[^$:@\s]+(?::[0-9]+)?(?:\/[^$:@\s]+)*):(?:'.self::VARIABLE.')\z/', $image, $match) === 1) {
+ return self::from($match, true, $match['prefix']);
+ }
+
+ return null;
+ }
+
+ /**
+ * Whether the image names a variable at all (supported or not).
+ */
+ public static function hasVariable(string $image): bool
+ {
+ return str_contains($image, '$');
+ }
+
+ /**
+ * The image that Compose starts when the variable has this value (null: not set, so the default applies).
+ */
+ public function resolve(?string $value): ?string
+ {
+ $value = $value !== null && $value !== '' ? $value : $this->default;
+ if ($value === null || $value === '') {
+ return null;
+ }
+
+ return $this->tagOnly ? "{$this->prefix}:{$value}" : $value;
+ }
+
+ /**
+ * The value of the variable that starts the image with this tag. `$current` is the image it starts now.
+ */
+ public function valueFor(string $current, string $tag): string
+ {
+ if ($this->tagOnly) {
+ return $tag;
+ }
+
+ $lastColon = strrpos($current, ':');
+ $lastSlash = strrpos($current, '/');
+ $name = $lastColon !== false && ($lastSlash === false || $lastColon > $lastSlash) ? substr($current, 0, $lastColon) : $current;
+
+ return "{$name}:{$tag}";
+ }
+
+ /**
+ * @param array