mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
fix(git): accept ssh:// repository URLs with custom ports
The repository URL rule rejected ssh://user@host:port/path, so deploy-key resources on servers with a custom SSH port needed a manual URL edit after creation. Accept ssh:// URLs and keep them unchanged for deploy keys so the port reaches the clone. Rename scpStyleGitUrlToHttps to sshGitUrlToHttps and let it convert ssh:// URLs too, so public flows, repository links and GitHub App repository slugs handle them. Closes #12161 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
c82cac626f
commit
7466da12a2
@@ -1547,7 +1547,7 @@ class ApplicationsController extends Controller
|
||||
$application->domain_port_overrides = $domainPortOverrides;
|
||||
}
|
||||
$gitRepository = $application->git_repository;
|
||||
$httpsRepository = scpStyleGitUrlToHttps($gitRepository);
|
||||
$httpsRepository = sshGitUrlToHttps($gitRepository);
|
||||
if (is_string($httpsRepository)) {
|
||||
$gitRepository = $httpsRepository;
|
||||
$application->git_repository = $httpsRepository;
|
||||
|
||||
@@ -229,6 +229,15 @@ class GithubPrivateRepositoryDeployKey extends Component
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep ssh:// URLs as-is so a custom SSH port is preserved for deployments.
|
||||
if (str_starts_with($this->repository_url, 'ssh://')) {
|
||||
$this->git_host = parse_url($this->repository_url, PHP_URL_HOST);
|
||||
$this->git_repository = $this->repository_url;
|
||||
$this->git_source = 'other';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->repository_url_parsed = Url::fromString($this->repository_url);
|
||||
$this->git_host = $this->repository_url_parsed->getHost();
|
||||
$this->git_repository = $this->repository_url_parsed->getSegment(1).'/'.$this->repository_url_parsed->getSegment(2);
|
||||
|
||||
@@ -137,7 +137,7 @@ class PublicGitRepository extends Component
|
||||
throw new \RuntimeException('Invalid repository URL: '.$validator->errors()->first('repository_url'));
|
||||
}
|
||||
|
||||
$httpsRepositoryUrl = scpStyleGitUrlToHttps($this->repository_url);
|
||||
$httpsRepositoryUrl = sshGitUrlToHttps($this->repository_url);
|
||||
if (is_string($httpsRepositoryUrl)) {
|
||||
$this->repository_url = $httpsRepositoryUrl;
|
||||
}
|
||||
|
||||
@@ -731,7 +731,7 @@ class Application extends BaseModel
|
||||
|
||||
return "{$this->source->html_url}/{$this->git_repository}/tree/{$this->git_branch}{$base_dir}";
|
||||
}
|
||||
$httpsRepository = $this->httpsUrlFromScpStyleGitRepository();
|
||||
$httpsRepository = $this->httpsUrlFromSshGitRepository();
|
||||
if (is_string($httpsRepository)) {
|
||||
if (str($this->git_repository)->contains('bitbucket')) {
|
||||
return "{$httpsRepository}/src/{$this->git_branch}{$base_dir}";
|
||||
@@ -752,7 +752,7 @@ class Application extends BaseModel
|
||||
if (! is_null($this->source?->html_url) && ! is_null($this->git_repository) && ! is_null($this->git_branch)) {
|
||||
return "{$this->source->html_url}/{$this->git_repository}/settings/hooks";
|
||||
}
|
||||
$httpsRepository = $this->httpsUrlFromScpStyleGitRepository();
|
||||
$httpsRepository = $this->httpsUrlFromSshGitRepository();
|
||||
if (is_string($httpsRepository)) {
|
||||
return "{$httpsRepository}/settings/hooks";
|
||||
}
|
||||
@@ -769,7 +769,7 @@ class Application extends BaseModel
|
||||
if (! is_null($this->source?->html_url) && ! is_null($this->git_repository) && ! is_null($this->git_branch)) {
|
||||
return "{$this->source->html_url}/{$this->git_repository}/commits/{$this->git_branch}";
|
||||
}
|
||||
$httpsRepository = $this->httpsUrlFromScpStyleGitRepository();
|
||||
$httpsRepository = $this->httpsUrlFromSshGitRepository();
|
||||
if (is_string($httpsRepository)) {
|
||||
return "{$httpsRepository}/commits/{$this->git_branch}";
|
||||
}
|
||||
@@ -790,11 +790,9 @@ class Application extends BaseModel
|
||||
}
|
||||
|
||||
$git_repository = $this->git_repository;
|
||||
$httpsRepository = scpStyleGitUrlToHttps($git_repository);
|
||||
$httpsRepository = sshGitUrlToHttps($git_repository);
|
||||
if (is_string($httpsRepository)) {
|
||||
$git_repository = $httpsRepository;
|
||||
} elseif (str($this->git_repository)->startsWith('ssh://')) {
|
||||
$git_repository = 'https://'.parse_url($git_repository, PHP_URL_HOST).parse_url($git_repository, PHP_URL_PATH);
|
||||
}
|
||||
|
||||
if (! filter_var($git_repository, FILTER_VALIDATE_URL) || ! in_array(parse_url($git_repository, PHP_URL_SCHEME), ['http', 'https'], true)) {
|
||||
@@ -809,9 +807,9 @@ class Application extends BaseModel
|
||||
return $url->__toString();
|
||||
}
|
||||
|
||||
private function httpsUrlFromScpStyleGitRepository(): ?string
|
||||
private function httpsUrlFromSshGitRepository(): ?string
|
||||
{
|
||||
$httpsRepository = scpStyleGitUrlToHttps($this->git_repository);
|
||||
$httpsRepository = sshGitUrlToHttps($this->git_repository);
|
||||
|
||||
if (! is_string($httpsRepository)) {
|
||||
return null;
|
||||
|
||||
@@ -29,7 +29,7 @@ class ValidGitRepositoryUrl implements ValidationRule
|
||||
|
||||
// Brackets are only allowed around an IPv6 host, so check the rest of the URL without it.
|
||||
$valueWithoutIpv6Host = preg_replace_callback(
|
||||
'/^((?:https?|git):\/\/|[a-zA-Z0-9._-]+@)\[([0-9A-Fa-f:.]+)\]/',
|
||||
'/^((?:https?|git):\/\/|(?:ssh:\/\/)?[a-zA-Z0-9._-]+@)\[([0-9A-Fa-f:.]+)\]/',
|
||||
fn (array $matches): string => filter_var($matches[2], FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false ? $matches[1].'ipv6-host' : $matches[0],
|
||||
$value,
|
||||
);
|
||||
@@ -96,6 +96,19 @@ class ValidGitRepositoryUrl implements ValidationRule
|
||||
if ($scp === null || preg_match('/^(\[[0-9A-Fa-f:.]+\]|[a-zA-Z0-9.-]+)$/', $scp['host']) !== 1 || preg_match('/^[a-zA-Z0-9\-_\/.~]+$/', $scp['path']) !== 1) {
|
||||
$fail('The :attribute is not a valid SSH repository URL.');
|
||||
|
||||
return;
|
||||
}
|
||||
} elseif (str_starts_with($value, 'ssh://')) {
|
||||
if (! $this->allowSSH) {
|
||||
$fail('SSH URLs are not allowed.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate ssh:// URL format (ssh://user@host[:port]/user/repo.git)
|
||||
if (! preg_match('/^ssh:\/\/[a-zA-Z0-9._-]+@(\[[0-9A-Fa-f:.]+\]|[a-zA-Z0-9.-]+)(:[0-9]{1,5})?\/[a-zA-Z0-9\-_\/.~]+$/', $value)) {
|
||||
$fail('The :attribute is not a valid SSH repository URL.');
|
||||
|
||||
return;
|
||||
}
|
||||
} elseif (str_starts_with($value, 'http://') || str_starts_with($value, 'https://')) {
|
||||
@@ -160,7 +173,7 @@ class ValidGitRepositoryUrl implements ValidationRule
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
$fail('The :attribute must start with https://, http://, git://, or be an SSH URL (user@host:path).');
|
||||
$fail('The :attribute must start with https://, http://, git://, ssh://, or be an SSH URL (user@host:path).');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -4816,8 +4816,15 @@ function parseScpStyleGitUrl(?string $gitRepository): ?array
|
||||
];
|
||||
}
|
||||
|
||||
function scpStyleGitUrlToHttps(?string $gitRepository): ?string
|
||||
function sshGitUrlToHttps(?string $gitRepository): ?string
|
||||
{
|
||||
if (is_string($gitRepository) && str_starts_with($gitRepository, 'ssh://')) {
|
||||
$host = parse_url($gitRepository, PHP_URL_HOST);
|
||||
$path = ltrim((string) parse_url($gitRepository, PHP_URL_PATH), '/');
|
||||
|
||||
return filled($host) && $path !== '' ? "https://{$host}/{$path}" : null;
|
||||
}
|
||||
|
||||
$parts = parseScpStyleGitUrl($gitRepository);
|
||||
|
||||
if ($parts === null) {
|
||||
@@ -4833,8 +4840,8 @@ function gitRepositorySlug(?string $gitRepository): string
|
||||
return '';
|
||||
}
|
||||
|
||||
if (($scp = parseScpStyleGitUrl($gitRepository)) !== null) {
|
||||
$gitRepository = $scp['path'];
|
||||
if (($httpsRepository = sshGitUrlToHttps($gitRepository)) !== null) {
|
||||
$gitRepository = (string) parse_url($httpsRepository, PHP_URL_PATH);
|
||||
} elseif (str($gitRepository)->startsWith('http') || str($gitRepository)->contains('github.com')) {
|
||||
$gitRepository = str($gitRepository)->replace('https://', '')->replace('http://', '')->replace('github.com/', '');
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ beforeEach(function () {
|
||||
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
|
||||
});
|
||||
|
||||
test('public application api converts scp-style ssh urls to https', function () {
|
||||
test('public application api converts ssh urls to https', function (string $gitRepository) {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
@@ -38,7 +38,7 @@ test('public application api converts scp-style ssh urls to https', function ()
|
||||
'project_uuid' => $this->project->uuid,
|
||||
'environment_uuid' => $this->environment->uuid,
|
||||
'server_uuid' => $this->server->uuid,
|
||||
'git_repository' => 'custom-user@git.example.com:2222/organization/repository.git',
|
||||
'git_repository' => $gitRepository,
|
||||
'git_branch' => 'main',
|
||||
'build_pack' => 'nixpacks',
|
||||
'ports_exposes' => '3000',
|
||||
@@ -50,4 +50,7 @@ test('public application api converts scp-style ssh urls to https', function ()
|
||||
$application = Application::where('uuid', $response->json('uuid'))->firstOrFail();
|
||||
|
||||
expect($application->git_repository)->toBe('https://git.example.com/organization/repository.git');
|
||||
});
|
||||
})->with([
|
||||
'scp-style with port' => 'custom-user@git.example.com:2222/organization/repository.git',
|
||||
'ssh scheme with port' => 'ssh://custom-user@git.example.com:2222/organization/repository.git',
|
||||
]);
|
||||
|
||||
@@ -85,6 +85,20 @@ test('admin can select and create an application with a key owned by the current
|
||||
expect(Application::query()->sole()->private_key_id)->toBe($this->privateKey->id);
|
||||
});
|
||||
|
||||
test('deploy key application keeps an ssh url with a custom port', function () {
|
||||
actAsDeployKeyUser($this->admin, $this->team);
|
||||
|
||||
deployKeyComponent()
|
||||
->call('setPrivateKey', $this->privateKey->id)
|
||||
->set('repository_url', 'ssh://git@gitea.example.local:2222/myuser/my-repository.git')
|
||||
->set('branch', 'main')
|
||||
->call('submit')
|
||||
->assertHasNoErrors();
|
||||
|
||||
expect(Application::query()->sole()->git_repository)
|
||||
->toBe('ssh://git@gitea.example.local:2222/myuser/my-repository.git');
|
||||
});
|
||||
|
||||
test('member cannot invoke the deploy key selection action directly', function () {
|
||||
actAsDeployKeyUser($this->member, $this->team);
|
||||
$this->withoutExceptionHandling();
|
||||
|
||||
@@ -32,3 +32,11 @@ test('strips custom ports when converting scp-style ssh urls to https', function
|
||||
->assertSet('repository_url', 'https://git.example.com/organization/repository.git')
|
||||
->assertSet('branchFound', true);
|
||||
});
|
||||
|
||||
test('converts ssh scheme urls with custom ports to https', function () {
|
||||
Livewire::test(PublicGitRepository::class, ['type' => 'public'])
|
||||
->set('repository_url', 'ssh://git@gitea.example.com:2222/organization/repository.git')
|
||||
->call('loadBranch')
|
||||
->assertSet('repository_url', 'https://gitea.example.com/organization/repository.git')
|
||||
->assertSet('branchFound', true);
|
||||
});
|
||||
|
||||
@@ -47,7 +47,7 @@ it('does not generate commit links from incomplete repository URLs', function (s
|
||||
'git protocol' => 'git://git.example.com/coollabsio/coolify.git',
|
||||
]);
|
||||
|
||||
it('converts scp-style remotes with generic usernames into https repository links', function (string $repository, string $expectedBranch, string $expectedCommits, string $expectedWebhook) {
|
||||
it('converts ssh remotes with generic usernames into https repository links', function (string $repository, string $expectedBranch, string $expectedCommits, string $expectedWebhook) {
|
||||
$application = new Application;
|
||||
$application->setRelation('source', null);
|
||||
$application->git_repository = $repository;
|
||||
@@ -76,4 +76,10 @@ it('converts scp-style remotes with generic usernames into https repository link
|
||||
'https://git.example.com/organization/repository/commits/main',
|
||||
'https://git.example.com/organization/repository/settings/hooks',
|
||||
],
|
||||
'ssh scheme with port' => [
|
||||
'ssh://git@gitea.example.com:2222/organization/repository.git',
|
||||
'https://gitea.example.com/organization/repository/tree/main/',
|
||||
'https://gitea.example.com/organization/repository/commits/main',
|
||||
'https://gitea.example.com/organization/repository/settings/hooks',
|
||||
],
|
||||
]);
|
||||
|
||||
@@ -50,8 +50,8 @@ it('parses scp-style ssh git urls including custom usernames and ports', functio
|
||||
],
|
||||
]);
|
||||
|
||||
it('converts scp-style ssh git urls to https without embedding custom ports in the path', function (string $url, string $expected) {
|
||||
expect(scpStyleGitUrlToHttps($url))->toBe($expected);
|
||||
it('converts ssh git urls to https without embedding custom ports in the path', function (string $url, string $expected) {
|
||||
expect(sshGitUrlToHttps($url))->toBe($expected);
|
||||
})->with([
|
||||
'git username' => [
|
||||
'git@github.com:organization/repository.git',
|
||||
@@ -69,19 +69,30 @@ it('converts scp-style ssh git urls to https without embedding custom ports in t
|
||||
'git@[fd00::5]:2222/organization/repository.git',
|
||||
'https://[fd00::5]/organization/repository.git',
|
||||
],
|
||||
'ssh scheme' => [
|
||||
'ssh://git@github.com/organization/repository.git',
|
||||
'https://github.com/organization/repository.git',
|
||||
],
|
||||
'ssh scheme with port' => [
|
||||
'ssh://git@gitea.example.com:2222/organization/repository.git',
|
||||
'https://gitea.example.com/organization/repository.git',
|
||||
],
|
||||
'ssh scheme with IPv6 host and port' => [
|
||||
'ssh://git@[fd00::5]:2222/organization/repository.git',
|
||||
'https://[fd00::5]/organization/repository.git',
|
||||
],
|
||||
]);
|
||||
|
||||
it('rejects non-scp-style git urls', function (string $url) {
|
||||
expect(parseScpStyleGitUrl($url))->toBeNull()
|
||||
->and(scpStyleGitUrlToHttps($url))->toBeNull();
|
||||
->and(sshGitUrlToHttps($url))->toBeNull();
|
||||
})->with([
|
||||
'https' => 'https://github.com/organization/repository.git',
|
||||
'email without path' => 'custom-user@git.example.com',
|
||||
'ssh scheme' => 'ssh://git@github.com/organization/repository.git',
|
||||
'empty' => '',
|
||||
]);
|
||||
|
||||
it('normalizes github app repository slugs from scp-style ssh urls', function (string $url, string $expected) {
|
||||
it('normalizes github app repository slugs from ssh urls', function (string $url, string $expected) {
|
||||
expect(gitRepositorySlug($url))->toBe($expected);
|
||||
})->with([
|
||||
'https' => ['https://github.com/organization/repository.git', 'organization/repository'],
|
||||
@@ -89,4 +100,6 @@ it('normalizes github app repository slugs from scp-style ssh urls', function (s
|
||||
'git username' => ['git@github.com:organization/repository.git', 'organization/repository'],
|
||||
'custom username' => ['custom-user@git.example.com:organization/repository.git', 'organization/repository'],
|
||||
'custom username and port' => ['custom-user@git.example.com:2222/organization/repository.git', 'organization/repository'],
|
||||
'ssh scheme' => ['ssh://git@github.com/organization/repository.git', 'organization/repository'],
|
||||
'ssh scheme with port' => ['ssh://git@ghe.example.com:2222/organization/repository.git', 'organization/repository'],
|
||||
]);
|
||||
|
||||
@@ -110,6 +110,10 @@ it('validates SSH URLs when allowed', function () {
|
||||
'custom-user@git.example.com:organization/repository.git',
|
||||
'custom-user@git.example.com:2222/organization/repository.git',
|
||||
'enterprise-user@enterprise.ghe.com:organization/repository.git',
|
||||
'ssh://git@gitea.example.local:2222/myuser/my-repository.git',
|
||||
'ssh://git@github.com/user/repo.git',
|
||||
'ssh://git@192.168.1.10:22/~user/repo.git',
|
||||
'ssh://git@[2001:db8::10]:2222/group/project.git',
|
||||
];
|
||||
|
||||
foreach ($validUrls as $url) {
|
||||
@@ -133,6 +137,7 @@ it('rejects SSH URLs when not allowed', function () {
|
||||
'git@github.com:user/repo.git',
|
||||
'git@gitlab.com:user/repo.git',
|
||||
'custom-user@git.example.com:organization/repository.git',
|
||||
'ssh://git@gitea.example.com:2222/organization/repository.git',
|
||||
];
|
||||
|
||||
foreach ($invalidUrls as $url) {
|
||||
@@ -371,6 +376,11 @@ it('rejects invalid URL formats', function () {
|
||||
'ftp://github.com/user/repo',
|
||||
'file:///path/to/repo',
|
||||
'ssh://github.com/user/repo',
|
||||
'ssh://git@github.com',
|
||||
'ssh://git@github.com:2222',
|
||||
'ssh://git@github.com:port/user/repo.git',
|
||||
'ssh://git@github.com:2222:user/repo.git',
|
||||
'ssh://git@[not-an-ip]:2222/user/repo.git',
|
||||
'https://',
|
||||
'http://',
|
||||
'git@',
|
||||
|
||||
Reference in New Issue
Block a user