Skip to content

Commit b4097fb

Browse files
ammachadoclaude
andcommitted
CAMEL-23703: camel-launcher - secure website installers with immutable release manifests
Add install.sh and install.ps1: per-user installers that verify a SHA-256 recorded in a signed-path manifest before extracting, reject path traversal / absolute paths / escaping symlinks, and confirm Java 17+ is discoverable before activation. WebsiteManifestGenerator produces the manifests consumed by both scripts. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent df36606 commit b4097fb

10 files changed

Lines changed: 2907 additions & 0 deletions

File tree

docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,47 @@ arguments to the adjacent `camel.bat` (preserving spaces and Unicode) and return
9191
its exit code. It exists so package managers that require a genuine executable
9292
users may continue to invoke `bin\camel.bat`; both behave identically.
9393

94+
==== Website installers for the Camel CLI
95+
96+
Two canonical installer scripts are now available for installing the
97+
xref:camel-jbang-launcher.adoc[Camel CLI Launcher] without a package manager:
98+
99+
[source,bash]
100+
----
101+
curl -fsSL https://camel.apache.org/install.sh | sh
102+
----
103+
104+
[source,powershell]
105+
----
106+
irm https://camel.apache.org/install.ps1 | iex
107+
----
108+
109+
With no arguments, both installers resolve and install the latest published release. An exact
110+
version can be requested instead with `--version X.Y.Z` (`install.sh`) or `-Version X.Y.Z`
111+
(`install.ps1`); the requested version is validated and matched against the fetched manifest
112+
before anything is downloaded.
113+
114+
Both installers download the release archive from Maven Central, verify it against a SHA-256
115+
recorded in a signed-path manifest before extracting it, and reject archives containing absolute
116+
paths, `../` traversal, escaping symlinks/reparse points, or more than one top-level directory.
117+
The staged launcher is then run once to confirm a Java 17+ runtime can be discovered (see
118+
"Camel CLI launcher Java runtime discovery" above); if that check fails, the previously active
119+
installation, if any, is left untouched and the installer exits nonzero.
120+
121+
Installation is always per-user and never requires elevation or `sudo`:
122+
123+
* POSIX (`install.sh`) installs under `${XDG_DATA_HOME:-$HOME/.local/share}/camel-cli/versions/<version>`
124+
and activates it via a symlink at `$HOME/.local/bin/camel`. The installer never writes to shell
125+
profile files (`.bashrc`, `.profile`, etc.); if `$HOME/.local/bin` is not already on `PATH`, it
126+
prints guidance instead.
127+
* Windows (`install.ps1`) installs under `%LOCALAPPDATA%\Apache Camel\cli\versions\<version>` and
128+
activates it via a `camel.cmd` shim at `%LOCALAPPDATA%\Apache Camel\bin\camel.cmd` that delegates
129+
to the staged `camel-x64.exe` or `camel-arm64.exe` (auto-detected). The bin directory is added
130+
once, case-insensitively, to the current user's `PATH`; the machine `PATH` is never modified.
131+
132+
Previously installed version directories are left in place after an upgrade or downgrade and
133+
must be removed manually. Reinstalling the same version replaces that version directory.
134+
94135
=== camel-langchain4j-agent
95136

96137
The `Agent.chat()` method return type has changed from `String` to `Result<String>` (from `dev.langchain4j.service.Result`).

dsl/camel-jbang/camel-launcher/pom.xml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,12 @@
283283
<artifactId>junit-jupiter</artifactId>
284284
<scope>test</scope>
285285
</dependency>
286+
<dependency>
287+
<groupId>org.apache.commons</groupId>
288+
<artifactId>commons-compress</artifactId>
289+
<version>${commons-compress-version}</version>
290+
<scope>test</scope>
291+
</dependency>
286292
</dependencies>
287293

288294
<build>
Lines changed: 291 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,291 @@
1+
#
2+
# Licensed to the Apache Software Foundation (ASF) under one or more
3+
# contributor license agreements. See the NOTICE file distributed with
4+
# this work for additional information regarding copyright ownership.
5+
# The ASF licenses this file to You under the Apache License, Version 2.0
6+
# (the "License"); you may not use this file except in compliance with
7+
# the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
#
17+
18+
param(
19+
[string] $Version
20+
)
21+
22+
$ErrorActionPreference = 'Stop'
23+
24+
# Test seams only: production installs never set these, so the defaults below are always used.
25+
$ManifestBaseUrl = if ($env:CAMEL_INSTALL_MANIFEST_BASE_URL) { $env:CAMEL_INSTALL_MANIFEST_BASE_URL } else { 'https://camel.apache.org/camel-cli/releases' }
26+
$MavenBaseUrl = if ($env:CAMEL_INSTALL_MAVEN_BASE_URL) { $env:CAMEL_INSTALL_MAVEN_BASE_URL } else { 'https://repo1.maven.org/maven2/org/apache/camel/camel-launcher' }
27+
$CaCertPath = $env:CAMEL_INSTALL_CA_CERT
28+
29+
$InstallRoot = Join-Path $env:LOCALAPPDATA 'Apache Camel'
30+
$DataRoot = Join-Path $InstallRoot 'cli\versions'
31+
$BinDir = Join-Path $InstallRoot 'bin'
32+
33+
function Fail {
34+
param([string] $Message)
35+
[Console]::Error.WriteLine("install.ps1: $Message")
36+
exit 1
37+
}
38+
39+
function Test-ValidVersion {
40+
param([string] $Value)
41+
return $Value -match '\A[0-9]+\.[0-9]+\.[0-9]+\z'
42+
}
43+
44+
function Test-ValidSha256 {
45+
param([string] $Value, [string] $Label)
46+
if ($Value -notmatch '\A[0-9a-f]{64}\z') {
47+
Fail "$Label is not a 64-character lowercase hex value"
48+
}
49+
}
50+
51+
if ($CaCertPath) {
52+
# Test seam only: trusts the loopback fixture's self-signed CA for this process without touching
53+
# the real Windows certificate store. Production installs never set CAMEL_INSTALL_CA_CERT.
54+
$installerCaCert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2($CaCertPath)
55+
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12
56+
[System.Net.ServicePointManager]::ServerCertificateValidationCallback = {
57+
param($sender, $certificate, $chain, $sslPolicyErrors)
58+
$verifyChain = New-Object System.Security.Cryptography.X509Certificates.X509Chain
59+
$verifyChain.ChainPolicy.ExtraStore.Add($installerCaCert) | Out-Null
60+
$verifyChain.ChainPolicy.RevocationMode = [System.Security.Cryptography.X509Certificates.X509RevocationMode]::NoCheck
61+
$verifyChain.ChainPolicy.VerificationFlags = [System.Security.Cryptography.X509Certificates.X509VerificationFlags]::AllowUnknownCertificateAuthority
62+
$leaf = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2($certificate)
63+
if (-not $verifyChain.Build($leaf)) {
64+
return $false
65+
}
66+
$root = $verifyChain.ChainElements[$verifyChain.ChainElements.Count - 1].Certificate
67+
return $root.Thumbprint -eq $installerCaCert.Thumbprint
68+
}.GetNewClosure()
69+
}
70+
71+
# Downloads $Url to $OutFile; used for both the manifest and archive fetches.
72+
function Get-Manifest {
73+
param([string] $Url, [string] $OutFile)
74+
try {
75+
Invoke-WebRequest -Uri $Url -OutFile $OutFile -UseBasicParsing | Out-Null
76+
} catch {
77+
Fail "failed to download $Url"
78+
}
79+
}
80+
81+
# Reads $Path line by line without ever dot-sourcing, invoking, or evaluating its content.
82+
function Read-Manifest {
83+
param([string] $Path)
84+
85+
$lines = @(Get-Content -LiteralPath $Path -Encoding UTF8)
86+
if ($lines.Count -ne 4) {
87+
Fail "manifest must contain exactly four lines"
88+
}
89+
90+
$known = @('format', 'version', 'tar_sha256', 'zip_sha256')
91+
$values = New-Object 'System.Collections.Generic.Dictionary[string,string]' ([StringComparer]::OrdinalIgnoreCase)
92+
foreach ($line in $lines) {
93+
if ([string]::IsNullOrEmpty($line)) {
94+
Fail "manifest contains a blank line"
95+
}
96+
$parts = $line.Split('=', 2)
97+
if ($parts.Count -ne 2 -or [string]::IsNullOrEmpty($parts[0])) {
98+
Fail "manifest contains a blank line"
99+
}
100+
$key = $parts[0]
101+
$value = $parts[1]
102+
if ([string]::IsNullOrEmpty($value)) {
103+
Fail "manifest key '$key' has an empty value"
104+
}
105+
if ($values.ContainsKey($key)) {
106+
Fail "manifest has duplicate key: $key"
107+
}
108+
if ($known -notcontains $key.ToLowerInvariant()) {
109+
Fail "manifest has unknown key: $key"
110+
}
111+
$values[$key] = $value
112+
}
113+
114+
foreach ($required in $known) {
115+
if (-not $values.ContainsKey($required)) {
116+
Fail "manifest is missing a required key"
117+
}
118+
}
119+
120+
if ($values['format'] -ne '1') {
121+
Fail "unsupported manifest format: $($values['format'])"
122+
}
123+
if (-not (Test-ValidVersion $values['version'])) {
124+
Fail "manifest version is not a valid X.Y.Z value"
125+
}
126+
Test-ValidSha256 $values['tar_sha256'] 'manifest tar_sha256'
127+
Test-ValidSha256 $values['zip_sha256'] 'manifest zip_sha256'
128+
129+
return $values
130+
}
131+
132+
# Lists archive entries via System.IO.Compression before Expand-Archive ever runs, and rejects absolute
133+
# paths, traversal, symlink/reparse-point entries, multiple top-level roots, and a missing launcher.
134+
function Test-ArchiveEntry {
135+
param([string] $ArchivePath, [string] $Version)
136+
137+
$expectedRoot = "camel-launcher-$Version"
138+
Add-Type -AssemblyName System.IO.Compression.FileSystem
139+
$zip = [System.IO.Compression.ZipFile]::OpenRead($ArchivePath)
140+
try {
141+
$roots = New-Object 'System.Collections.Generic.HashSet[string]'
142+
$foundX64 = $false
143+
$foundArm64 = $false
144+
foreach ($entry in $zip.Entries) {
145+
$name = $entry.FullName
146+
if ([string]::IsNullOrEmpty($name)) {
147+
continue
148+
}
149+
if ($name.StartsWith('/') -or $name.StartsWith('\') -or ($name.Length -ge 2 -and $name[1] -eq ':')) {
150+
Fail "archive contains an absolute path entry: $name"
151+
}
152+
$normalized = $name.Replace('\', '/')
153+
$segments = $normalized.Split('/')
154+
if ($segments -contains '..') {
155+
Fail "archive contains a path traversal entry: $name"
156+
}
157+
$unixMode = ([uint32]$entry.ExternalAttributes -shr 16) -band 0xF000
158+
if ($unixMode -eq 0xA000) {
159+
Fail "archive contains a symbolic link or reparse point entry, which is not allowed"
160+
}
161+
[void]$roots.Add($segments[0])
162+
if ($normalized -eq "$expectedRoot/bin/camel-x64.exe") {
163+
$foundX64 = $true
164+
}
165+
if ($normalized -eq "$expectedRoot/bin/camel-arm64.exe") {
166+
$foundArm64 = $true
167+
}
168+
}
169+
if ($roots.Count -ne 1) {
170+
Fail "archive must contain exactly one top-level directory"
171+
}
172+
if (-not $roots.Contains($expectedRoot)) {
173+
Fail "archive top-level directory does not match expected version: $($roots -join ',')"
174+
}
175+
if (-not $foundX64) {
176+
Fail "archive is missing bin\camel-x64.exe"
177+
}
178+
if (-not $foundArm64) {
179+
Fail "archive is missing bin\camel-arm64.exe"
180+
}
181+
} finally {
182+
$zip.Dispose()
183+
}
184+
}
185+
186+
# Runs the freshly staged upstream launcher; a nonzero exit (e.g. no Java 17+ available) aborts the
187+
# install and leaves the previously active installation untouched.
188+
function Test-StagedLauncher {
189+
param([string] $ExePath)
190+
try {
191+
& $ExePath 'version' *> $null
192+
} catch {
193+
Fail "staged launcher failed verification (Java 17+ required)"
194+
}
195+
if ($LASTEXITCODE -ne 0) {
196+
Fail "staged launcher failed verification (Java 17+ required)"
197+
}
198+
}
199+
200+
function Set-CamelShim {
201+
param([string] $Version, [string] $StagedRoot)
202+
203+
$targetDir = Join-Path $DataRoot $Version
204+
New-Item -ItemType Directory -Force -Path $DataRoot | Out-Null
205+
if (Test-Path -LiteralPath $targetDir) {
206+
Remove-Item -LiteralPath $targetDir -Recurse -Force
207+
}
208+
Move-Item -LiteralPath $StagedRoot -Destination $targetDir
209+
210+
New-Item -ItemType Directory -Force -Path $BinDir | Out-Null
211+
$arch = if ($env:PROCESSOR_ARCHITECTURE -eq 'ARM64') { 'arm64' } else { 'x64' }
212+
$exePath = Join-Path $targetDir "bin\camel-$arch.exe"
213+
$shimContent = "@echo off`r`n`"$exePath`" %*`r`nexit /b %ERRORLEVEL%`r`n"
214+
$tempShim = Join-Path $BinDir ".camel.$PID.tmp.cmd"
215+
Set-Content -LiteralPath $tempShim -Value $shimContent -NoNewline -Encoding UTF8
216+
$finalShim = Join-Path $BinDir 'camel.cmd'
217+
Move-Item -LiteralPath $tempShim -Destination $finalShim -Force
218+
}
219+
220+
# Adds $Dir once, case-insensitively, to the current user's PATH (registry-level, no elevation) and to
221+
# this process; the machine PATH is never written.
222+
function Add-UserPath {
223+
param([string] $Dir)
224+
225+
$userPath = [Environment]::GetEnvironmentVariable('Path', 'User')
226+
$entries = @()
227+
if ($userPath) {
228+
$entries = $userPath.Split(';') | Where-Object { $_ -ne '' }
229+
}
230+
$present = $entries | Where-Object { $_.TrimEnd('\') -ieq $Dir.TrimEnd('\') }
231+
if (-not $present) {
232+
$newPath = if ($entries.Count -gt 0) { ($entries + $Dir) -join ';' } else { $Dir }
233+
[Environment]::SetEnvironmentVariable('Path', $newPath, 'User')
234+
}
235+
if (($env:Path -split ';') -notcontains $Dir) {
236+
$env:Path = "$env:Path;$Dir"
237+
}
238+
}
239+
240+
if ($Version -and -not (Test-ValidVersion $Version)) {
241+
Fail "invalid -Version value: $Version (expected X.Y.Z)"
242+
}
243+
244+
New-Item -ItemType Directory -Force -Path $InstallRoot | Out-Null
245+
$stagingRoot = Join-Path $InstallRoot ("staging." + [Guid]::NewGuid().ToString('N'))
246+
New-Item -ItemType Directory -Path $stagingRoot | Out-Null
247+
248+
try {
249+
if ($Version) {
250+
$manifestUrl = "$ManifestBaseUrl/$Version.properties"
251+
} else {
252+
$manifestUrl = "$ManifestBaseUrl/latest.properties"
253+
}
254+
$manifestFile = Join-Path $stagingRoot 'manifest.properties'
255+
Get-Manifest -Url $manifestUrl -OutFile $manifestFile
256+
257+
$manifest = Read-Manifest -Path $manifestFile
258+
$resolvedVersion = $manifest['version']
259+
260+
if ($Version -and $Version -ne $resolvedVersion) {
261+
Fail "manifest version ($resolvedVersion) does not match requested version ($Version)"
262+
}
263+
264+
$archiveUrl = "$MavenBaseUrl/$resolvedVersion/camel-launcher-$resolvedVersion-bin.zip"
265+
$archiveFile = Join-Path $stagingRoot "camel-launcher-$resolvedVersion-bin.zip"
266+
Get-Manifest -Url $archiveUrl -OutFile $archiveFile
267+
268+
$actualHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $archiveFile).Hash.ToLowerInvariant()
269+
if ($actualHash -ne $manifest['zip_sha256']) {
270+
Fail "checksum mismatch for downloaded archive"
271+
}
272+
273+
Test-ArchiveEntry -ArchivePath $archiveFile -Version $resolvedVersion
274+
275+
$extractDir = Join-Path $stagingRoot 'extract'
276+
Expand-Archive -LiteralPath $archiveFile -DestinationPath $extractDir -Force
277+
278+
$stagedRoot = Join-Path $extractDir "camel-launcher-$resolvedVersion"
279+
$arch = if ($env:PROCESSOR_ARCHITECTURE -eq 'ARM64') { 'arm64' } else { 'x64' }
280+
$stagedExe = Join-Path $stagedRoot "bin\camel-$arch.exe"
281+
Test-StagedLauncher -ExePath $stagedExe
282+
283+
Set-CamelShim -Version $resolvedVersion -StagedRoot $stagedRoot
284+
Add-UserPath -Dir $BinDir
285+
286+
Write-Host "Installed Camel CLI $resolvedVersion to $(Join-Path $DataRoot $resolvedVersion)"
287+
} finally {
288+
if (Test-Path -LiteralPath $stagingRoot) {
289+
Remove-Item -LiteralPath $stagingRoot -Recurse -Force -ErrorAction SilentlyContinue
290+
}
291+
}

0 commit comments

Comments
 (0)