mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
chore: merge main into next (#12354)
Co-authored-by: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Co-authored-by: Aditya Tripathi <aditya@climactic.co> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Aditya Tripathi
Claude Opus 5.5
commit
76d2eb25c3
@@ -126,6 +126,7 @@ class ApplicationsController extends Controller
|
||||
]);
|
||||
if (request()->attributes->get('can_read_sensitive', false) === true) {
|
||||
$application->makeVisible([
|
||||
...Application::COMMAND_AND_CONFIGURATION_ATTRIBUTES,
|
||||
'custom_labels',
|
||||
'dockerfile',
|
||||
'docker_compose',
|
||||
@@ -161,21 +162,6 @@ class ApplicationsController extends Controller
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setting or changing a manual webhook secret needs the deploy ability. Sending the stored value
|
||||
* again or clearing it does not.
|
||||
*/
|
||||
private function manualWebhookSecretForbiddenResponse(Request $request, ?Application $application = null): ?JsonResponse
|
||||
{
|
||||
foreach (['manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea'] as $field) {
|
||||
if ($request->filled($field) && $request->input($field) !== $application?->{$field}) {
|
||||
return $this->deployAbilityForbiddenResponse($request, 'A manual webhook secret lets anyone who knows it start a deployment, so it needs a token with the deploy permission.');
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function applicationSettingsFromRequest(Request $request): array
|
||||
{
|
||||
$settings = [];
|
||||
@@ -1545,9 +1531,6 @@ class ApplicationsController extends Controller
|
||||
if ($internalNameValidation = $this->customInternalNameValidationResponse($applicationSettings)) {
|
||||
return $internalNameValidation;
|
||||
}
|
||||
if ($forbidden = $this->manualWebhookSecretForbiddenResponse($request)) {
|
||||
return $forbidden;
|
||||
}
|
||||
if ($type === 'public') {
|
||||
$validationRules = [
|
||||
'git_repository' => ['string', 'required', new ValidGitRepositoryUrl],
|
||||
@@ -3526,10 +3509,6 @@ class ApplicationsController extends Controller
|
||||
'errors' => $errors,
|
||||
], 422);
|
||||
}
|
||||
if ($forbidden = $this->manualWebhookSecretForbiddenResponse($request, $application)) {
|
||||
return $forbidden;
|
||||
}
|
||||
|
||||
$githubApp = null;
|
||||
if ($request->has('github_app_uuid')) {
|
||||
if ($application->build_pack === 'dockerimage' || filled($application->dockerfile) || $application->deploymentType() === 'deploy_key') {
|
||||
|
||||
@@ -251,6 +251,7 @@ class DatabasesController extends Controller
|
||||
'mysql_root_password',
|
||||
'mariadb_password',
|
||||
'mariadb_root_password',
|
||||
...$database::CONFIGURATION_ATTRIBUTES,
|
||||
]);
|
||||
$this->exposeNestedServerSecrets($database);
|
||||
} else {
|
||||
|
||||
@@ -460,16 +460,17 @@ class General extends Component
|
||||
|
||||
$this->saveApplicationSettingsWithAudit($this->application);
|
||||
} else {
|
||||
// From model to properties
|
||||
// From model to properties. Commands, configuration, and secrets can embed credentials, so only users who can update the application get them.
|
||||
$canViewSecrets = auth()->user()?->can('update', $this->application) ?? false;
|
||||
$this->name = $this->application->name;
|
||||
$this->description = $this->application->description;
|
||||
$this->fqdn = $this->application->fqdn;
|
||||
$this->gitRepository = $this->application->git_repository;
|
||||
$this->gitBranch = $this->application->git_branch;
|
||||
$this->gitCommitSha = $this->application->git_commit_sha;
|
||||
$this->installCommand = $this->application->install_command;
|
||||
$this->buildCommand = $this->application->build_command;
|
||||
$this->startCommand = $this->application->start_command;
|
||||
$this->installCommand = $canViewSecrets ? $this->application->install_command : null;
|
||||
$this->buildCommand = $canViewSecrets ? $this->application->build_command : null;
|
||||
$this->startCommand = $canViewSecrets ? $this->application->start_command : null;
|
||||
$this->buildPack = $this->application->build_pack;
|
||||
$this->staticImage = $this->application->static_image;
|
||||
$this->baseDirectory = $this->application->base_directory;
|
||||
@@ -477,29 +478,26 @@ class General extends Component
|
||||
$this->portsExposes = $this->application->ports_exposes;
|
||||
$this->portsMappings = $this->application->ports_mappings;
|
||||
$this->customNetworkAliases = $this->application->custom_network_aliases;
|
||||
$this->dockerfile = $this->application->dockerfile;
|
||||
$this->dockerfile = $canViewSecrets ? $this->application->dockerfile : null;
|
||||
$this->dockerfileLocation = $this->application->dockerfile_location ?? ($this->buildPack === 'dockerfile' ? '/Dockerfile' : null);
|
||||
$this->dockerfileTargetBuild = $this->application->dockerfile_target_build;
|
||||
$this->dockerRegistryImageName = $this->application->docker_registry_image_name;
|
||||
$this->dockerRegistryImageTag = $this->application->docker_registry_image_tag;
|
||||
$this->dockerComposeLocation = $this->application->docker_compose_location;
|
||||
$canViewCompose = auth()->user()?->can('update', $this->application) ?? false;
|
||||
$this->dockerCompose = $canViewCompose ? redactLockedValuesInGeneratedCompose($this->application->docker_compose, $this->application) : null;
|
||||
$this->dockerComposeRaw = $canViewCompose ? $this->application->docker_compose_raw : null;
|
||||
$this->dockerComposeCustomStartCommand = $this->application->docker_compose_custom_start_command;
|
||||
$this->dockerComposeCustomBuildCommand = $this->application->docker_compose_custom_build_command;
|
||||
$this->customLabels = $this->application->parseContainerLabels();
|
||||
$this->customDockerRunOptions = $this->application->custom_docker_run_options;
|
||||
$this->preDeploymentCommand = $this->application->pre_deployment_command;
|
||||
$this->dockerCompose = $canViewSecrets ? redactLockedValuesInGeneratedCompose($this->application->docker_compose, $this->application) : null;
|
||||
$this->dockerComposeRaw = $canViewSecrets ? $this->application->docker_compose_raw : null;
|
||||
$this->dockerComposeCustomStartCommand = $canViewSecrets ? $this->application->docker_compose_custom_start_command : null;
|
||||
$this->dockerComposeCustomBuildCommand = $canViewSecrets ? $this->application->docker_compose_custom_build_command : null;
|
||||
$this->customLabels = $canViewSecrets ? $this->application->parseContainerLabels() : null;
|
||||
$this->customDockerRunOptions = $canViewSecrets ? $this->application->custom_docker_run_options : null;
|
||||
$this->preDeploymentCommand = $canViewSecrets ? $this->application->pre_deployment_command : null;
|
||||
$this->preDeploymentCommandContainer = $this->application->pre_deployment_command_container;
|
||||
$this->postDeploymentCommand = $this->application->post_deployment_command;
|
||||
$this->postDeploymentCommand = $canViewSecrets ? $this->application->post_deployment_command : null;
|
||||
$this->postDeploymentCommandContainer = $this->application->post_deployment_command_container;
|
||||
$this->customNginxConfiguration = $this->application->custom_nginx_configuration;
|
||||
$this->customNginxConfiguration = $canViewSecrets ? $this->application->custom_nginx_configuration : null;
|
||||
$this->isHttpBasicAuthEnabled = $this->application->is_http_basic_auth_enabled;
|
||||
$this->httpBasicAuthUsername = $this->application->http_basic_auth_username;
|
||||
$this->httpBasicAuthPassword = auth()->user()->can('update', $this->application)
|
||||
? $this->application->http_basic_auth_password
|
||||
: null;
|
||||
$this->httpBasicAuthPassword = $canViewSecrets ? $this->application->http_basic_auth_password : null;
|
||||
$this->httpBasicAuthHashAlgorithm = ($this->application->usesArgon2idBasicAuth() ? HttpBasicAuthHashAlgorithm::ARGON2ID : HttpBasicAuthHashAlgorithm::BCRYPT)->value;
|
||||
$this->httpBasicAuthBcryptCost = $this->application->http_basic_auth_bcrypt_cost;
|
||||
$this->httpBasicAuthArgon2idMemoryCost = $this->application->http_basic_auth_argon2id_memory_cost;
|
||||
|
||||
@@ -137,17 +137,17 @@ class General extends Component
|
||||
$this->database->save();
|
||||
$this->dispatch('name-changed', uuid: $this->database->uuid, name: $this->database->name);
|
||||
} else {
|
||||
$canSeeCredentials = auth()->user()?->can('update', $this->database) ?? false;
|
||||
$this->name = $this->database->name;
|
||||
$this->description = $this->database->description;
|
||||
$this->clickhouseAdminUser = $this->database->clickhouse_admin_user;
|
||||
$canSeeCredentials = auth()->user()?->can('update', $this->database) ?? false;
|
||||
$this->clickhouseAdminPassword = $canSeeCredentials ? $this->database->clickhouse_admin_password : '';
|
||||
$this->image = $this->database->image;
|
||||
$this->portsMappings = $this->database->ports_mappings;
|
||||
$this->isPublic = $this->database->is_public;
|
||||
$this->publicPort = $this->database->public_port;
|
||||
$this->publicPortTimeout = $this->database->public_port_timeout;
|
||||
$this->customDockerRunOptions = $this->database->custom_docker_run_options;
|
||||
$this->customDockerRunOptions = $canSeeCredentials ? $this->database->custom_docker_run_options : null;
|
||||
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,16 +124,16 @@ class General extends Component
|
||||
$this->database->save();
|
||||
$this->dispatch('name-changed', uuid: $this->database->uuid, name: $this->database->name);
|
||||
} else {
|
||||
$canSeeCredentials = auth()->user()?->can('update', $this->database) ?? false;
|
||||
$this->name = $this->database->name;
|
||||
$this->description = $this->database->description;
|
||||
$canSeeCredentials = auth()->user()?->can('update', $this->database) ?? false;
|
||||
$this->dragonflyPassword = $canSeeCredentials ? $this->database->dragonfly_password : '';
|
||||
$this->image = $this->database->image;
|
||||
$this->portsMappings = $this->database->ports_mappings;
|
||||
$this->isPublic = $this->database->is_public;
|
||||
$this->publicPort = $this->database->public_port;
|
||||
$this->publicPortTimeout = $this->database->public_port_timeout;
|
||||
$this->customDockerRunOptions = $this->database->custom_docker_run_options;
|
||||
$this->customDockerRunOptions = $canSeeCredentials ? $this->database->custom_docker_run_options : null;
|
||||
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,9 +128,9 @@ class General extends Component
|
||||
$this->database->save();
|
||||
$this->dispatch('name-changed', uuid: $this->database->uuid, name: $this->database->name);
|
||||
} else {
|
||||
$canSeeCredentials = auth()->user()?->can('update', $this->database) ?? false;
|
||||
$this->name = $this->database->name;
|
||||
$this->description = $this->database->description;
|
||||
$canSeeCredentials = auth()->user()?->can('update', $this->database) ?? false;
|
||||
$this->keydbConf = $canSeeCredentials ? $this->database->keydb_conf : null;
|
||||
$this->keydbPassword = $canSeeCredentials ? $this->database->keydb_password : '';
|
||||
$this->image = $this->database->image;
|
||||
@@ -138,7 +138,7 @@ class General extends Component
|
||||
$this->isPublic = $this->database->is_public;
|
||||
$this->publicPort = $this->database->public_port;
|
||||
$this->publicPortTimeout = $this->database->public_port_timeout;
|
||||
$this->customDockerRunOptions = $this->database->custom_docker_run_options;
|
||||
$this->customDockerRunOptions = $canSeeCredentials ? $this->database->custom_docker_run_options : null;
|
||||
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,9 +153,9 @@ class General extends Component
|
||||
$this->database->save();
|
||||
$this->dispatch('name-changed', uuid: $this->database->uuid, name: $this->database->name);
|
||||
} else {
|
||||
$canSeeCredentials = auth()->user()?->can('update', $this->database) ?? false;
|
||||
$this->name = $this->database->name;
|
||||
$this->description = $this->database->description;
|
||||
$canSeeCredentials = auth()->user()?->can('update', $this->database) ?? false;
|
||||
$this->mariadbRootPassword = $canSeeCredentials ? $this->database->mariadb_root_password : '';
|
||||
$this->mariadbUser = $this->database->mariadb_user;
|
||||
$this->mariadbPassword = $canSeeCredentials ? $this->database->mariadb_password : '';
|
||||
@@ -167,7 +167,7 @@ class General extends Component
|
||||
$this->publicPort = $this->database->public_port;
|
||||
$this->publicPortTimeout = $this->database->public_port_timeout;
|
||||
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
|
||||
$this->customDockerRunOptions = $this->database->custom_docker_run_options;
|
||||
$this->customDockerRunOptions = $canSeeCredentials ? $this->database->custom_docker_run_options : null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -145,9 +145,9 @@ class General extends Component
|
||||
$this->database->save();
|
||||
$this->dispatch('name-changed', uuid: $this->database->uuid, name: $this->database->name);
|
||||
} else {
|
||||
$canSeeCredentials = auth()->user()?->can('update', $this->database) ?? false;
|
||||
$this->name = $this->database->name;
|
||||
$this->description = $this->database->description;
|
||||
$canSeeCredentials = auth()->user()?->can('update', $this->database) ?? false;
|
||||
$this->mongoConf = $canSeeCredentials ? $this->database->mongo_conf : null;
|
||||
$this->mongoInitdbRootUsername = $this->database->mongo_initdb_root_username;
|
||||
$this->mongoInitdbRootPassword = $canSeeCredentials ? $this->database->mongo_initdb_root_password : '';
|
||||
@@ -158,7 +158,7 @@ class General extends Component
|
||||
$this->publicPort = $this->database->public_port;
|
||||
$this->publicPortTimeout = $this->database->public_port_timeout;
|
||||
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
|
||||
$this->customDockerRunOptions = $this->database->custom_docker_run_options;
|
||||
$this->customDockerRunOptions = $canSeeCredentials ? $this->database->custom_docker_run_options : null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -153,9 +153,9 @@ class General extends Component
|
||||
$this->database->save();
|
||||
$this->dispatch('name-changed', uuid: $this->database->uuid, name: $this->database->name);
|
||||
} else {
|
||||
$canSeeCredentials = auth()->user()?->can('update', $this->database) ?? false;
|
||||
$this->name = $this->database->name;
|
||||
$this->description = $this->database->description;
|
||||
$canSeeCredentials = auth()->user()?->can('update', $this->database) ?? false;
|
||||
$this->mysqlRootPassword = $canSeeCredentials ? $this->database->mysql_root_password : '';
|
||||
$this->mysqlUser = $this->database->mysql_user;
|
||||
$this->mysqlPassword = $canSeeCredentials ? $this->database->mysql_password : '';
|
||||
@@ -167,7 +167,7 @@ class General extends Component
|
||||
$this->publicPort = $this->database->public_port;
|
||||
$this->publicPortTimeout = $this->database->public_port_timeout;
|
||||
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
|
||||
$this->customDockerRunOptions = $this->database->custom_docker_run_options;
|
||||
$this->customDockerRunOptions = $canSeeCredentials ? $this->database->custom_docker_run_options : null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -169,10 +169,10 @@ class General extends Component
|
||||
$this->database->save();
|
||||
$this->dispatch('name-changed', uuid: $this->database->uuid, name: $this->database->name);
|
||||
} else {
|
||||
$canSeeCredentials = auth()->user()?->can('update', $this->database) ?? false;
|
||||
$this->name = $this->database->name;
|
||||
$this->description = $this->database->description;
|
||||
$this->postgresUser = $this->database->postgres_user;
|
||||
$canSeeCredentials = auth()->user()?->can('update', $this->database) ?? false;
|
||||
$this->postgresPassword = $canSeeCredentials ? $this->database->postgres_password : '';
|
||||
$this->postgresDb = $this->database->postgres_db;
|
||||
$this->postgresInitdbArgs = $this->database->postgres_initdb_args;
|
||||
@@ -185,7 +185,7 @@ class General extends Component
|
||||
$this->publicPort = $this->database->public_port;
|
||||
$this->publicPortTimeout = $this->database->public_port_timeout;
|
||||
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
|
||||
$this->customDockerRunOptions = $this->database->custom_docker_run_options;
|
||||
$this->customDockerRunOptions = $canSeeCredentials ? $this->database->custom_docker_run_options : null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -146,6 +146,7 @@ class General extends Component
|
||||
$this->database->save();
|
||||
$this->dispatch('name-changed', uuid: $this->database->uuid, name: $this->database->name);
|
||||
} else {
|
||||
$canSeeCredentials = auth()->user()?->can('update', $this->database) ?? false;
|
||||
$this->name = $this->database->name;
|
||||
$this->description = $this->database->description;
|
||||
$this->image = $this->database->image;
|
||||
@@ -154,10 +155,9 @@ class General extends Component
|
||||
$this->publicPort = $this->database->public_port;
|
||||
$this->publicPortTimeout = $this->database->public_port_timeout;
|
||||
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
|
||||
$this->customDockerRunOptions = $this->database->custom_docker_run_options;
|
||||
$this->customDockerRunOptions = $canSeeCredentials ? $this->database->custom_docker_run_options : null;
|
||||
$this->redisVersion = $this->database->getRedisVersion();
|
||||
$this->redisUsername = $this->database->redis_username;
|
||||
$canSeeCredentials = auth()->user()?->can('update', $this->database) ?? false;
|
||||
$this->redisConf = $canSeeCredentials ? $this->database->redis_conf : null;
|
||||
$this->isPasswordLocked = $this->database->isCredentialLocked();
|
||||
$this->redisPassword = $canSeeCredentials && ! $this->isPasswordLocked ? $this->database->redis_password : null;
|
||||
|
||||
@@ -29,6 +29,8 @@ class General extends Component
|
||||
|
||||
public bool $isLogDrainEnabled = false;
|
||||
|
||||
public bool $isPasswordHiddenForMember = false;
|
||||
|
||||
public function mount()
|
||||
{
|
||||
try {
|
||||
@@ -43,6 +45,8 @@ class General extends Component
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
|
||||
$this->isPasswordHiddenForMember = ! (auth()->user()?->can('update', $this->database) ?? false);
|
||||
}
|
||||
|
||||
protected function rules(): array
|
||||
@@ -83,11 +87,12 @@ class General extends Component
|
||||
$this->database->save();
|
||||
$this->dispatch('name-changed', uuid: $this->database->uuid, name: $this->database->name);
|
||||
} else {
|
||||
$canSeeCredentials = auth()->user()?->can('update', $this->database) ?? false;
|
||||
$this->name = $this->database->name;
|
||||
$this->description = $this->database->description;
|
||||
$this->sqliteDatabases = $this->database->sqlite_databases;
|
||||
$this->image = $this->database->image;
|
||||
$this->customDockerRunOptions = $this->database->custom_docker_run_options;
|
||||
$this->customDockerRunOptions = $canSeeCredentials ? $this->database->custom_docker_run_options : null;
|
||||
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ class ConfigurationChecker extends Component
|
||||
}
|
||||
|
||||
/**
|
||||
* Members must never see environment variable values or Compose content, so redact
|
||||
* Members must never see environment variable values, Compose content, commands, or configuration, so redact
|
||||
* those changes before they are serialized to the browser.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $changes
|
||||
@@ -67,8 +67,9 @@ class ConfigurationChecker extends Component
|
||||
|
||||
return collect($changes)
|
||||
->map(function (array $change): array {
|
||||
$key = str((string) data_get($change, 'key'))->afterLast('.')->value();
|
||||
$isHidden = data_get($change, 'section') === 'environment'
|
||||
|| str((string) data_get($change, 'key'))->afterLast('.')->value() === 'docker_compose_raw';
|
||||
|| in_array($key, [...Application::COMMAND_AND_CONFIGURATION_ATTRIBUTES, 'docker_compose_raw', 'dockerfile', 'custom_labels'], true);
|
||||
if (! $isHidden) {
|
||||
return $change;
|
||||
}
|
||||
|
||||
@@ -118,17 +118,18 @@ class HealthChecks extends Component
|
||||
|
||||
$this->resource->save();
|
||||
} else {
|
||||
// Sync from model
|
||||
// Sync from model. The command and response text can embed credentials, so only users who can update the resource get them.
|
||||
$canViewSecrets = auth()->user()?->can('update', $this->resource) ?? false;
|
||||
$this->healthCheckEnabled = $this->resource->health_check_enabled;
|
||||
$this->healthCheckType = $this->resource->health_check_type ?? 'http';
|
||||
$this->healthCheckCommand = $this->resource->health_check_command;
|
||||
$this->healthCheckCommand = $canViewSecrets ? $this->resource->health_check_command : null;
|
||||
$this->healthCheckMethod = $this->resource->health_check_method;
|
||||
$this->healthCheckScheme = $this->resource->health_check_scheme;
|
||||
$this->healthCheckHost = $this->resource->health_check_host;
|
||||
$this->healthCheckPort = $this->resource->health_check_port;
|
||||
$this->healthCheckPath = $this->resource->health_check_path;
|
||||
$this->healthCheckReturnCode = $this->resource->health_check_return_code;
|
||||
$this->healthCheckResponseText = $this->resource->health_check_response_text;
|
||||
$this->healthCheckResponseText = $canViewSecrets ? $this->resource->health_check_response_text : null;
|
||||
$this->healthCheckInterval = $this->resource->health_check_interval;
|
||||
$this->healthCheckTimeout = $this->resource->health_check_timeout;
|
||||
$this->healthCheckRetries = $this->resource->health_check_retries;
|
||||
|
||||
@@ -258,6 +258,24 @@ class Application extends BaseModel
|
||||
|
||||
protected $appends = ['server_status'];
|
||||
|
||||
/**
|
||||
* Free-form commands and configuration that can embed credentials. Only users who can
|
||||
* update the application and `read:sensitive` tokens may read them.
|
||||
*/
|
||||
public const COMMAND_AND_CONFIGURATION_ATTRIBUTES = [
|
||||
'install_command',
|
||||
'build_command',
|
||||
'start_command',
|
||||
'pre_deployment_command',
|
||||
'post_deployment_command',
|
||||
'health_check_command',
|
||||
'health_check_response_text',
|
||||
'custom_docker_run_options',
|
||||
'docker_compose_custom_start_command',
|
||||
'docker_compose_custom_build_command',
|
||||
'custom_nginx_configuration',
|
||||
];
|
||||
|
||||
/**
|
||||
* Sensitive fields hidden by default in serialized output (toArray/toJson).
|
||||
* API controllers should call makeVisible([...]) for callers with the
|
||||
@@ -265,6 +283,7 @@ class Application extends BaseModel
|
||||
* job, compose generation) must makeVisible explicitly before toArray().
|
||||
*/
|
||||
protected $hidden = [
|
||||
...self::COMMAND_AND_CONFIGURATION_ATTRIBUTES,
|
||||
'http_basic_auth_password',
|
||||
'git_https_password',
|
||||
'manual_webhook_secret_github',
|
||||
|
||||
@@ -63,6 +63,14 @@ class StandaloneClickhouse extends BaseModel
|
||||
|
||||
protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status'];
|
||||
|
||||
/**
|
||||
* Free-form configuration that can embed credentials. Only users who can
|
||||
* update the database and `read:sensitive` tokens may read it.
|
||||
*/
|
||||
public const CONFIGURATION_ATTRIBUTES = [
|
||||
'custom_docker_run_options',
|
||||
];
|
||||
|
||||
/**
|
||||
* Sensitive fields hidden by default in serialized output (toArray/toJson).
|
||||
* API controllers should call makeVisible([...]) for callers with the
|
||||
@@ -70,6 +78,7 @@ class StandaloneClickhouse extends BaseModel
|
||||
*/
|
||||
protected $hidden = [
|
||||
'clickhouse_admin_password',
|
||||
...self::CONFIGURATION_ATTRIBUTES,
|
||||
'internal_db_url',
|
||||
'external_db_url',
|
||||
];
|
||||
|
||||
@@ -57,6 +57,14 @@ class StandaloneDragonfly extends BaseModel
|
||||
|
||||
protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status'];
|
||||
|
||||
/**
|
||||
* Free-form configuration that can embed credentials. Only users who can
|
||||
* update the database and `read:sensitive` tokens may read it.
|
||||
*/
|
||||
public const CONFIGURATION_ATTRIBUTES = [
|
||||
'custom_docker_run_options',
|
||||
];
|
||||
|
||||
/**
|
||||
* Sensitive fields hidden by default in serialized output (toArray/toJson).
|
||||
* API controllers should call makeVisible([...]) for callers with the
|
||||
@@ -64,6 +72,7 @@ class StandaloneDragonfly extends BaseModel
|
||||
*/
|
||||
protected $hidden = [
|
||||
'dragonfly_password',
|
||||
...self::CONFIGURATION_ATTRIBUTES,
|
||||
'internal_db_url',
|
||||
'external_db_url',
|
||||
// Internal start-command state, not part of the API.
|
||||
|
||||
@@ -58,6 +58,15 @@ class StandaloneKeydb extends BaseModel
|
||||
|
||||
protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status'];
|
||||
|
||||
/**
|
||||
* Free-form configuration that can embed credentials. Only users who can
|
||||
* update the database and `read:sensitive` tokens may read it.
|
||||
*/
|
||||
public const CONFIGURATION_ATTRIBUTES = [
|
||||
'keydb_conf',
|
||||
'custom_docker_run_options',
|
||||
];
|
||||
|
||||
/**
|
||||
* Sensitive fields hidden by default in serialized output (toArray/toJson).
|
||||
* API controllers should call makeVisible([...]) for callers with the
|
||||
@@ -65,6 +74,7 @@ class StandaloneKeydb extends BaseModel
|
||||
*/
|
||||
protected $hidden = [
|
||||
'keydb_password',
|
||||
...self::CONFIGURATION_ATTRIBUTES,
|
||||
'internal_db_url',
|
||||
'external_db_url',
|
||||
// Internal start-command state, not part of the API.
|
||||
|
||||
@@ -61,6 +61,15 @@ class StandaloneMariadb extends BaseModel
|
||||
|
||||
protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status'];
|
||||
|
||||
/**
|
||||
* Free-form configuration that can embed credentials. Only users who can
|
||||
* update the database and `read:sensitive` tokens may read it.
|
||||
*/
|
||||
public const CONFIGURATION_ATTRIBUTES = [
|
||||
'mariadb_conf',
|
||||
'custom_docker_run_options',
|
||||
];
|
||||
|
||||
/**
|
||||
* Sensitive fields hidden by default in serialized output (toArray/toJson).
|
||||
* API controllers should call makeVisible([...]) for callers with the
|
||||
@@ -69,6 +78,7 @@ class StandaloneMariadb extends BaseModel
|
||||
protected $hidden = [
|
||||
'mariadb_password',
|
||||
'mariadb_root_password',
|
||||
...self::CONFIGURATION_ATTRIBUTES,
|
||||
'internal_db_url',
|
||||
'external_db_url',
|
||||
];
|
||||
|
||||
@@ -61,6 +61,15 @@ class StandaloneMongodb extends BaseModel
|
||||
|
||||
protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status'];
|
||||
|
||||
/**
|
||||
* Free-form configuration that can embed credentials. Only users who can
|
||||
* update the database and `read:sensitive` tokens may read it.
|
||||
*/
|
||||
public const CONFIGURATION_ATTRIBUTES = [
|
||||
'mongo_conf',
|
||||
'custom_docker_run_options',
|
||||
];
|
||||
|
||||
/**
|
||||
* Sensitive fields hidden by default in serialized output (toArray/toJson).
|
||||
* API controllers should call makeVisible([...]) for callers with the
|
||||
@@ -68,6 +77,7 @@ class StandaloneMongodb extends BaseModel
|
||||
*/
|
||||
protected $hidden = [
|
||||
'mongo_initdb_root_password',
|
||||
...self::CONFIGURATION_ATTRIBUTES,
|
||||
'internal_db_url',
|
||||
'external_db_url',
|
||||
];
|
||||
|
||||
@@ -62,6 +62,15 @@ class StandaloneMysql extends BaseModel
|
||||
|
||||
protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status'];
|
||||
|
||||
/**
|
||||
* Free-form configuration that can embed credentials. Only users who can
|
||||
* update the database and `read:sensitive` tokens may read it.
|
||||
*/
|
||||
public const CONFIGURATION_ATTRIBUTES = [
|
||||
'mysql_conf',
|
||||
'custom_docker_run_options',
|
||||
];
|
||||
|
||||
/**
|
||||
* Sensitive fields hidden by default in serialized output (toArray/toJson).
|
||||
* API controllers should call makeVisible([...]) for callers with the
|
||||
@@ -70,6 +79,7 @@ class StandaloneMysql extends BaseModel
|
||||
protected $hidden = [
|
||||
'mysql_password',
|
||||
'mysql_root_password',
|
||||
...self::CONFIGURATION_ATTRIBUTES,
|
||||
'internal_db_url',
|
||||
'external_db_url',
|
||||
];
|
||||
|
||||
@@ -64,6 +64,15 @@ class StandalonePostgresql extends BaseModel
|
||||
|
||||
protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status'];
|
||||
|
||||
/**
|
||||
* Free-form configuration that can embed credentials. Only users who can
|
||||
* update the database and `read:sensitive` tokens may read it.
|
||||
*/
|
||||
public const CONFIGURATION_ATTRIBUTES = [
|
||||
'postgres_conf',
|
||||
'custom_docker_run_options',
|
||||
];
|
||||
|
||||
/**
|
||||
* Sensitive fields hidden by default in serialized output (toArray/toJson).
|
||||
* API controllers should call makeVisible([...]) for callers with the
|
||||
@@ -72,6 +81,7 @@ class StandalonePostgresql extends BaseModel
|
||||
protected $hidden = [
|
||||
'postgres_password',
|
||||
'init_scripts',
|
||||
...self::CONFIGURATION_ATTRIBUTES,
|
||||
'internal_db_url',
|
||||
'external_db_url',
|
||||
];
|
||||
|
||||
@@ -59,6 +59,15 @@ class StandaloneRedis extends BaseModel
|
||||
|
||||
protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status'];
|
||||
|
||||
/**
|
||||
* Free-form configuration that can embed credentials. Only users who can
|
||||
* update the database and `read:sensitive` tokens may read it.
|
||||
*/
|
||||
public const CONFIGURATION_ATTRIBUTES = [
|
||||
'redis_conf',
|
||||
'custom_docker_run_options',
|
||||
];
|
||||
|
||||
/**
|
||||
* Sensitive fields hidden by default in serialized output (toArray/toJson).
|
||||
* API controllers should call makeVisible([...]) for callers with the
|
||||
@@ -66,6 +75,7 @@ class StandaloneRedis extends BaseModel
|
||||
*/
|
||||
protected $hidden = [
|
||||
'redis_password',
|
||||
...self::CONFIGURATION_ATTRIBUTES,
|
||||
'internal_db_url',
|
||||
'external_db_url',
|
||||
// Internal start-command state, not part of the API.
|
||||
|
||||
@@ -58,6 +58,23 @@ class StandaloneSqlite extends BaseModel
|
||||
'health_check_start_period',
|
||||
];
|
||||
|
||||
/**
|
||||
* Free-form configuration that can embed credentials. Only users who can
|
||||
* update the database and `read:sensitive` tokens may read it.
|
||||
*/
|
||||
public const CONFIGURATION_ATTRIBUTES = [
|
||||
'custom_docker_run_options',
|
||||
];
|
||||
|
||||
/**
|
||||
* Sensitive fields hidden by default in serialized output (toArray/toJson).
|
||||
* API controllers should call makeVisible([...]) for callers with the
|
||||
* `read:sensitive` or `root` token ability.
|
||||
*/
|
||||
protected $hidden = [
|
||||
...self::CONFIGURATION_ATTRIBUTES,
|
||||
];
|
||||
|
||||
protected $appends = ['database_type', 'server_status'];
|
||||
|
||||
protected $casts = [
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
namespace App\Notifications\Dto;
|
||||
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class PushoverMessage
|
||||
{
|
||||
public function __construct(
|
||||
@@ -43,8 +41,6 @@ class PushoverMessage
|
||||
$payload['message'] .= " <a href='".$buttonUrl."'>".$text.'</a>';
|
||||
}
|
||||
|
||||
Log::info('Pushover message', $payload);
|
||||
|
||||
return $payload;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,17 @@ tolower($0) ~ /^[ \t]*create[ \t]+(database|schema)[ \t]/ { if (is_system(databa
|
||||
!skip { print }
|
||||
AWK;
|
||||
|
||||
/**
|
||||
* mysql and mariadb client commands that run a shell command, write or read a file:
|
||||
* system, tee, pager, edit and source, as a long command at the start of a line or as
|
||||
* a short command (\!, \T, \P, \e, \.) anywhere. An odd backslash run starts a short
|
||||
* command; mysqldump writes a backslash in data as "\\" and escapes only 0, n, r, Z and
|
||||
* quotes, so its rows do not match.
|
||||
*/
|
||||
private const MYSQL_CLIENT_COMMAND_PATTERN = '(^|[^\\\\])(\\\\\\\\)*\\\\[!TPe.]';
|
||||
|
||||
private const MYSQL_NAMED_CLIENT_COMMAND_PATTERN = '^[[:space:]]*(system|tee|pager|edit|source)([[:space:]]|;|$)';
|
||||
|
||||
/**
|
||||
* Prints a PostgreSQL SQL dump without the table data of its COPY ... FROM stdin blocks.
|
||||
*/
|
||||
@@ -317,24 +328,42 @@ SH;
|
||||
* MySQL and MariaDB restore SQL dumps. A tar backup must wrap exactly one dump.
|
||||
* The all-databases mode checks the backup before it drops any database and skips
|
||||
* the system databases unless their users and privileges are restored as well.
|
||||
* The client runs the dump with its client commands disabled: --sandbox on MariaDB
|
||||
* (10.5.25, 10.6.18, 10.11.8, 11.4.2 and later), --commands=FALSE on MySQL (8.0.43, 8.4.6,
|
||||
* 9.4.0 and later). Older clients do not know these options, so a dump with a client
|
||||
* command that runs a shell command or uses a file is refused on every client.
|
||||
*/
|
||||
private function mysql(string $binary, string $prefix, bool $dumpAll, bool $restoreMysqlUsers = false): string
|
||||
{
|
||||
$preflight = <<<'SH'
|
||||
$commandPattern = escapeshellarg(self::MYSQL_CLIENT_COMMAND_PATTERN);
|
||||
$namedCommandPattern = escapeshellarg(self::MYSQL_NAMED_CLIENT_COMMAND_PATTERN);
|
||||
$preflight = <<<SH
|
||||
if is_tar; then
|
||||
extract_tar
|
||||
use_single_tar_member || fail 'A tar backup must contain exactly one SQL dump. Nothing was changed.'
|
||||
fi
|
||||
is_text || fail 'Unsupported backup format. Use an SQL dump. Nothing was changed.'
|
||||
if stream | LC_ALL=C grep -Eq {$commandPattern} || stream | LC_ALL=C grep -Eiq {$namedCommandPattern}; then
|
||||
fail 'Blocked restore: the backup contains a {$binary} client command (system, tee, pager, edit or source). If you trust this dump, restore it from the database container terminal. Nothing was changed.'
|
||||
fi
|
||||
|
||||
SH;
|
||||
$clientSafety = <<<SH
|
||||
client_safety=
|
||||
if {$binary} --help 2>/dev/null | grep -q -e '--sandbox'; then
|
||||
client_safety=--sandbox
|
||||
elif {$binary} --help 2>/dev/null | grep -q -e '--commands'; then
|
||||
client_safety=--commands=FALSE
|
||||
fi
|
||||
|
||||
SH;
|
||||
|
||||
if (! $dumpAll) {
|
||||
return $preflight.<<<SH
|
||||
return $preflight.$clientSafety.<<<SH
|
||||
if [ "$(stream | grep -c '^USE `')" -gt 1 ]; then
|
||||
fail 'This backup contains more than one database. Select "Backup contains all databases" to restore it. Nothing was changed.'
|
||||
fi
|
||||
stream | {$binary} -u \${$prefix}_USER -p\${$prefix}_PASSWORD \${$prefix}_DATABASE
|
||||
stream | {$binary} \$client_safety -u \${$prefix}_USER -p\${$prefix}_PASSWORD \${$prefix}_DATABASE
|
||||
SH;
|
||||
}
|
||||
|
||||
@@ -350,11 +379,11 @@ SH;
|
||||
$source = '{ stream | awk '.escapeshellarg(self::MYSQL_SYSTEM_DATABASE_FILTER)." || echo 'Coolify could not filter the system databases of the backup;'; }";
|
||||
}
|
||||
|
||||
return $preflight.<<<SH
|
||||
return $preflight.$clientSafety.<<<SH
|
||||
for pid in \$({$root} -N -e "SELECT id FROM information_schema.processlist WHERE user != 'root';"); do {$root} -e "KILL \$pid" 2>/dev/null || true; done
|
||||
{$root} -N -e "SELECT CONCAT('DROP DATABASE IF EXISTS \\`',schema_name,'\\`;') FROM information_schema.schemata WHERE schema_name NOT IN ('information_schema','mysql','performance_schema','sys');" | {$root} || exit 1
|
||||
{$root} -e "CREATE DATABASE IF NOT EXISTS \\`{$database}\\`;" || exit 1
|
||||
{$source} | {$root} {$database}
|
||||
{$source} | {$binary} \$client_safety -u root -p\${{$prefix}_ROOT_PASSWORD} {$database}
|
||||
SH;
|
||||
}
|
||||
|
||||
|
||||
@@ -104,11 +104,15 @@
|
||||
|
||||
<x-application.settings-section id="container-section" title="Container"
|
||||
description="Docker options and log forwarding for the database container.">
|
||||
<x-forms.input
|
||||
helper="Add supported docker run options used when the container starts. Unsupported options can interfere with Coolify automation."
|
||||
placeholder="--cap-add SYS_ADMIN --device=/dev/fuse"
|
||||
id="customDockerRunOptions" label="Custom Docker options" canGate="update"
|
||||
:canResource="$database" />
|
||||
@if ($isPasswordHiddenForMember)
|
||||
<x-forms.input label="Custom Docker options" disabled value="Hidden (only admins can view)" />
|
||||
@else
|
||||
<x-forms.input
|
||||
helper="Add supported docker run options used when the container starts. Unsupported options can interfere with Coolify automation."
|
||||
placeholder="--cap-add SYS_ADMIN --device=/dev/fuse"
|
||||
id="customDockerRunOptions" label="Custom Docker options" canGate="update"
|
||||
:canResource="$database" />
|
||||
@endif
|
||||
<x-application.settings-subsection id="log-delivery-section" title="Log delivery" helper="Forward container logs to the drain configured on the server.">
|
||||
<x-forms.listbox canGate="update" :canResource="$database" id="isLogDrainEnabled" label="Log drain" live onChange="instantSaveAdvanced"
|
||||
:disabled="! auth()->user()->can('update', $database)" :options="[
|
||||
|
||||
@@ -82,11 +82,15 @@
|
||||
|
||||
<x-application.settings-section id="container-section" title="Container"
|
||||
description="Docker options and log forwarding for the database container.">
|
||||
<x-forms.input
|
||||
helper="Add supported docker run options used when the container starts. Unsupported options can interfere with Coolify automation."
|
||||
placeholder="--cap-add SYS_ADMIN --device=/dev/fuse"
|
||||
id="customDockerRunOptions" label="Custom Docker options" canGate="update"
|
||||
:canResource="$database" />
|
||||
@if ($isPasswordHiddenForMember)
|
||||
<x-forms.input label="Custom Docker options" disabled value="Hidden (only admins can view)" />
|
||||
@else
|
||||
<x-forms.input
|
||||
helper="Add supported docker run options used when the container starts. Unsupported options can interfere with Coolify automation."
|
||||
placeholder="--cap-add SYS_ADMIN --device=/dev/fuse"
|
||||
id="customDockerRunOptions" label="Custom Docker options" canGate="update"
|
||||
:canResource="$database" />
|
||||
@endif
|
||||
<x-application.settings-subsection id="log-delivery-section" title="Log delivery" helper="Forward container logs to the drain configured on the server.">
|
||||
<x-forms.listbox canGate="update" :canResource="$database" id="isLogDrainEnabled" label="Log drain" live onChange="instantSaveAdvanced"
|
||||
:disabled="! auth()->user()->can('update', $database)" :options="[
|
||||
|
||||
@@ -83,11 +83,15 @@
|
||||
|
||||
<x-application.settings-section id="container-section" title="Container"
|
||||
description="Docker options, configuration, and log forwarding for the database container.">
|
||||
<x-forms.input
|
||||
helper="Add supported docker run options used when the container starts. Unsupported options can interfere with Coolify automation."
|
||||
placeholder="--cap-add SYS_ADMIN --device=/dev/fuse"
|
||||
id="customDockerRunOptions" label="Custom Docker options" canGate="update"
|
||||
:canResource="$database" />
|
||||
@if ($isPasswordHiddenForMember)
|
||||
<x-forms.input label="Custom Docker options" disabled value="Hidden (only admins can view)" />
|
||||
@else
|
||||
<x-forms.input
|
||||
helper="Add supported docker run options used when the container starts. Unsupported options can interfere with Coolify automation."
|
||||
placeholder="--cap-add SYS_ADMIN --device=/dev/fuse"
|
||||
id="customDockerRunOptions" label="Custom Docker options" canGate="update"
|
||||
:canResource="$database" />
|
||||
@endif
|
||||
<x-application.settings-subsection id="configuration-section" title="Configuration" helper="Override only the KeyDB directives you need. All other defaults remain active.">
|
||||
@if ($isPasswordHiddenForMember)
|
||||
<x-forms.input label="Custom KeyDB configuration" disabled value="Hidden (only admins can view)" />
|
||||
|
||||
@@ -88,11 +88,15 @@
|
||||
|
||||
<x-application.settings-section id="container-section" title="Container"
|
||||
description="Docker options, configuration, and log forwarding for the database container.">
|
||||
<x-forms.input
|
||||
helper="Add supported docker run options used when the container starts. Unsupported options can interfere with Coolify automation."
|
||||
placeholder="--cap-add SYS_ADMIN --device=/dev/fuse"
|
||||
id="customDockerRunOptions" label="Custom Docker options" canGate="update"
|
||||
:canResource="$database" />
|
||||
@if ($isPasswordHiddenForMember)
|
||||
<x-forms.input label="Custom Docker options" disabled value="Hidden (only admins can view)" />
|
||||
@else
|
||||
<x-forms.input
|
||||
helper="Add supported docker run options used when the container starts. Unsupported options can interfere with Coolify automation."
|
||||
placeholder="--cap-add SYS_ADMIN --device=/dev/fuse"
|
||||
id="customDockerRunOptions" label="Custom Docker options" canGate="update"
|
||||
:canResource="$database" />
|
||||
@endif
|
||||
<x-application.settings-subsection id="configuration-section" title="Configuration" helper="Override the MariaDB configuration used by this container.">
|
||||
@if ($isPasswordHiddenForMember)
|
||||
<x-forms.input label="Custom MariaDB configuration" disabled value="Hidden (only admins can view)" />
|
||||
|
||||
@@ -85,11 +85,15 @@
|
||||
|
||||
<x-application.settings-section id="container-section" title="Container"
|
||||
description="Docker options, configuration, and log forwarding for the database container.">
|
||||
<x-forms.input
|
||||
helper="Add supported docker run options used when the container starts. Unsupported options can interfere with Coolify automation."
|
||||
placeholder="--cap-add SYS_ADMIN --device=/dev/fuse"
|
||||
id="customDockerRunOptions" label="Custom Docker options" canGate="update"
|
||||
:canResource="$database" />
|
||||
@if ($isPasswordHiddenForMember)
|
||||
<x-forms.input label="Custom Docker options" disabled value="Hidden (only admins can view)" />
|
||||
@else
|
||||
<x-forms.input
|
||||
helper="Add supported docker run options used when the container starts. Unsupported options can interfere with Coolify automation."
|
||||
placeholder="--cap-add SYS_ADMIN --device=/dev/fuse"
|
||||
id="customDockerRunOptions" label="Custom Docker options" canGate="update"
|
||||
:canResource="$database" />
|
||||
@endif
|
||||
<x-application.settings-subsection id="configuration-section" title="Configuration" helper="Override the MongoDB configuration used by this container.">
|
||||
@if ($isPasswordHiddenForMember)
|
||||
<x-forms.input label="Custom MongoDB configuration" disabled value="Hidden (only admins can view)" />
|
||||
|
||||
@@ -88,11 +88,15 @@
|
||||
|
||||
<x-application.settings-section id="container-section" title="Container"
|
||||
description="Docker options, configuration, and log forwarding for the database container.">
|
||||
<x-forms.input
|
||||
helper="Add supported docker run options used when the container starts. Unsupported options can interfere with Coolify automation."
|
||||
placeholder="--cap-add SYS_ADMIN --device=/dev/fuse"
|
||||
id="customDockerRunOptions" label="Custom Docker options" canGate="update"
|
||||
:canResource="$database" />
|
||||
@if ($isPasswordHiddenForMember)
|
||||
<x-forms.input label="Custom Docker options" disabled value="Hidden (only admins can view)" />
|
||||
@else
|
||||
<x-forms.input
|
||||
helper="Add supported docker run options used when the container starts. Unsupported options can interfere with Coolify automation."
|
||||
placeholder="--cap-add SYS_ADMIN --device=/dev/fuse"
|
||||
id="customDockerRunOptions" label="Custom Docker options" canGate="update"
|
||||
:canResource="$database" />
|
||||
@endif
|
||||
<x-application.settings-subsection id="configuration-section" title="Configuration" helper="Override the MySQL configuration used by this container.">
|
||||
@if ($isPasswordHiddenForMember)
|
||||
<x-forms.input label="Custom MySQL configuration" disabled value="Hidden (only admins can view)" />
|
||||
|
||||
@@ -90,11 +90,15 @@
|
||||
|
||||
<x-application.settings-section id="container-section" title="Container"
|
||||
description="Docker options, configuration, and log forwarding for the database container.">
|
||||
<x-forms.input
|
||||
helper="Add supported docker run options used when the container starts. Unsupported options can interfere with Coolify automation."
|
||||
placeholder="--cap-add SYS_ADMIN --device=/dev/fuse"
|
||||
id="customDockerRunOptions" label="Custom Docker options" canGate="update"
|
||||
:canResource="$database" />
|
||||
@if ($isPasswordHiddenForMember)
|
||||
<x-forms.input label="Custom Docker options" disabled value="Hidden (only admins can view)" />
|
||||
@else
|
||||
<x-forms.input
|
||||
helper="Add supported docker run options used when the container starts. Unsupported options can interfere with Coolify automation."
|
||||
placeholder="--cap-add SYS_ADMIN --device=/dev/fuse"
|
||||
id="customDockerRunOptions" label="Custom Docker options" canGate="update"
|
||||
:canResource="$database" />
|
||||
@endif
|
||||
<x-application.settings-subsection id="configuration-section" title="Configuration" helper="Override the PostgreSQL configuration used by this container.">
|
||||
@if ($isPasswordHiddenForMember)
|
||||
<x-forms.input label="Custom PostgreSQL configuration" disabled value="Hidden (only admins can view)" />
|
||||
|
||||
@@ -90,11 +90,15 @@
|
||||
|
||||
<x-application.settings-section id="container-section" title="Container"
|
||||
description="Docker options, configuration, and log forwarding for the database container.">
|
||||
<x-forms.input
|
||||
helper="Add supported docker run options used when the container starts. Unsupported options can interfere with Coolify automation."
|
||||
placeholder="--cap-add SYS_ADMIN --device=/dev/fuse"
|
||||
id="customDockerRunOptions" label="Custom Docker options" canGate="update"
|
||||
:canResource="$database" />
|
||||
@if ($isPasswordHiddenForMember)
|
||||
<x-forms.input label="Custom Docker options" disabled value="Hidden (only admins can view)" />
|
||||
@else
|
||||
<x-forms.input
|
||||
helper="Add supported docker run options used when the container starts. Unsupported options can interfere with Coolify automation."
|
||||
placeholder="--cap-add SYS_ADMIN --device=/dev/fuse"
|
||||
id="customDockerRunOptions" label="Custom Docker options" canGate="update"
|
||||
:canResource="$database" />
|
||||
@endif
|
||||
<x-application.settings-subsection id="configuration-section" title="Configuration" helper="Override only the Redis directives you need. All other defaults remain active.">
|
||||
@if ($isPasswordHiddenForMember)
|
||||
<x-forms.input label="Custom Redis configuration" disabled value="Hidden (only admins can view)" />
|
||||
|
||||
@@ -31,11 +31,15 @@
|
||||
|
||||
<x-application.settings-section id="container-section" title="Container"
|
||||
description="Docker options and log forwarding for the database container.">
|
||||
<x-forms.input
|
||||
helper="Add supported docker run options used when the container starts. Unsupported options can interfere with Coolify automation."
|
||||
placeholder="--cap-add SYS_ADMIN --device=/dev/fuse"
|
||||
id="customDockerRunOptions" label="Custom Docker options" canGate="update"
|
||||
:canResource="$database" />
|
||||
@if ($isPasswordHiddenForMember)
|
||||
<x-forms.input label="Custom Docker options" disabled value="Hidden (only admins can view)" />
|
||||
@else
|
||||
<x-forms.input
|
||||
helper="Add supported docker run options used when the container starts. Unsupported options can interfere with Coolify automation."
|
||||
placeholder="--cap-add SYS_ADMIN --device=/dev/fuse"
|
||||
id="customDockerRunOptions" label="Custom Docker options" canGate="update"
|
||||
:canResource="$database" />
|
||||
@endif
|
||||
<x-application.settings-subsection id="log-delivery-section" title="Log delivery" helper="Forward container logs to the drain configured on the server.">
|
||||
<x-forms.listbox canGate="update" :canResource="$database" id="isLogDrainEnabled" label="Log drain" live onChange="instantSaveAdvanced"
|
||||
:disabled="! auth()->user()->can('update', $database)" :options="[
|
||||
|
||||
+8
-38
@@ -50,51 +50,22 @@ function manualWebhookSecretAbilityHeaders(User $user, int $teamId, array $abili
|
||||
}
|
||||
|
||||
describe('update', function () {
|
||||
test('write-only token cannot set a new manual webhook secret', function (string $field) {
|
||||
test('write-only token can set a new manual webhook secret', function (string $field) {
|
||||
$this->withHeaders(manualWebhookSecretAbilityHeaders($this->user, $this->team->id, ['write']))
|
||||
->patchJson("/api/v1/applications/{$this->application->uuid}", [$field => 'attacker-secret-0123456789'])
|
||||
->assertForbidden()
|
||||
->assertJsonPath('message', 'Missing required permissions: deploy. A manual webhook secret lets anyone who knows it start a deployment, so it needs a token with the deploy permission.');
|
||||
->patchJson("/api/v1/applications/{$this->application->uuid}", [$field => 'new-secret-0123456789'])
|
||||
->assertOk();
|
||||
|
||||
$application = $this->application->fresh();
|
||||
expect($application->manual_webhook_secret_github)->toBe('existing-secret-0123456789');
|
||||
expect($application->{$field})->not->toBe('attacker-secret-0123456789');
|
||||
expect($this->application->fresh()->{$field})->toBe('new-secret-0123456789');
|
||||
})->with([
|
||||
'manual_webhook_secret_github',
|
||||
'manual_webhook_secret_gitlab',
|
||||
'manual_webhook_secret_bitbucket',
|
||||
'manual_webhook_secret_gitea',
|
||||
]);
|
||||
|
||||
test('write-only token can update other fields and send the stored secret back', function () {
|
||||
$headers = manualWebhookSecretAbilityHeaders($this->user, $this->team->id, ['write']);
|
||||
|
||||
$this->withHeaders($headers)
|
||||
->patchJson("/api/v1/applications/{$this->application->uuid}", ['description' => 'changed'])
|
||||
->assertOk();
|
||||
$this->withHeaders($headers)
|
||||
->patchJson("/api/v1/applications/{$this->application->uuid}", [
|
||||
'description' => 'changed again',
|
||||
'manual_webhook_secret_github' => 'existing-secret-0123456789',
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
expect($this->application->fresh())
|
||||
->description->toBe('changed again')
|
||||
->manual_webhook_secret_github->toBe('existing-secret-0123456789');
|
||||
});
|
||||
|
||||
test('write and deploy token can set a new manual webhook secret', function () {
|
||||
$this->withHeaders(manualWebhookSecretAbilityHeaders($this->user, $this->team->id, ['write', 'deploy']))
|
||||
->patchJson("/api/v1/applications/{$this->application->uuid}", ['manual_webhook_secret_gitlab' => 'new-gitlab-secret-0123456789'])
|
||||
->assertOk();
|
||||
|
||||
expect($this->application->fresh()->manual_webhook_secret_gitlab)->toBe('new-gitlab-secret-0123456789');
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', function () {
|
||||
test('write-only token cannot create an application with a manual webhook secret', function () {
|
||||
test('write-only token can create an application with a manual webhook secret', function () {
|
||||
$this->withHeaders(manualWebhookSecretAbilityHeaders($this->user, $this->team->id, ['write']))
|
||||
->postJson('/api/v1/applications/dockerimage', [
|
||||
'project_uuid' => $this->project->uuid,
|
||||
@@ -102,11 +73,10 @@ describe('create', function () {
|
||||
'server_uuid' => $this->server->uuid,
|
||||
'docker_registry_image_name' => 'nginx',
|
||||
'ports_exposes' => '80',
|
||||
'manual_webhook_secret_github' => 'attacker-secret-0123456789',
|
||||
'manual_webhook_secret_github' => 'new-secret-0123456789',
|
||||
])
|
||||
->assertForbidden()
|
||||
->assertJsonPath('message', fn (string $message) => str_contains($message, 'manual webhook secret'));
|
||||
->assertCreated();
|
||||
|
||||
expect(Application::count())->toBe(1);
|
||||
expect(Application::latest('id')->first()->manual_webhook_secret_github)->toBe('new-secret-0123456789');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Project\Application\General;
|
||||
use App\Livewire\Project\Shared\ConfigurationChecker;
|
||||
use App\Livewire\Project\Shared\HealthChecks;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Str;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
const APP_SECRET_COLUMNS = [
|
||||
'install_command' => 'installCommand',
|
||||
'build_command' => 'buildCommand',
|
||||
'start_command' => 'startCommand',
|
||||
'pre_deployment_command' => 'preDeploymentCommand',
|
||||
'post_deployment_command' => 'postDeploymentCommand',
|
||||
'custom_docker_run_options' => 'customDockerRunOptions',
|
||||
'docker_compose_custom_start_command' => 'dockerComposeCustomStartCommand',
|
||||
'docker_compose_custom_build_command' => 'dockerComposeCustomBuildCommand',
|
||||
'custom_nginx_configuration' => 'customNginxConfiguration',
|
||||
'dockerfile' => 'dockerfile',
|
||||
];
|
||||
|
||||
const APP_SECRET_HEALTH_CHECK_COLUMNS = [
|
||||
'health_check_command' => 'healthCheckCommand',
|
||||
'health_check_response_text' => 'healthCheckResponseText',
|
||||
];
|
||||
|
||||
beforeEach(function () {
|
||||
Queue::fake();
|
||||
$this->withoutVite();
|
||||
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->owner = User::factory()->create();
|
||||
$this->member = User::factory()->create();
|
||||
$this->team->members()->attach($this->owner->id, ['role' => 'owner']);
|
||||
$this->team->members()->attach($this->member->id, ['role' => 'member']);
|
||||
|
||||
$server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$destination = StandaloneDocker::query()->where('server_id', $server->id)->firstOrFail();
|
||||
$project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
|
||||
$this->application = Application::factory()->create([
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
]);
|
||||
$this->application->forceFill(applicationSecretValues())->save();
|
||||
$this->application = $this->application->fresh();
|
||||
});
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
function applicationSecretValues(): array
|
||||
{
|
||||
$values = [];
|
||||
foreach ([...array_keys(APP_SECRET_COLUMNS), ...array_keys(APP_SECRET_HEALTH_CHECK_COLUMNS)] as $column) {
|
||||
$values[$column] = "secret-{$column}-value";
|
||||
}
|
||||
$values['custom_labels'] = base64_encode('traefik.http.middlewares.auth.basicauth.users=secret-custom_labels-value');
|
||||
|
||||
return $values;
|
||||
}
|
||||
|
||||
function expectApplicationSecretsAbsent(string $text): void
|
||||
{
|
||||
foreach ([...array_keys(APP_SECRET_COLUMNS), ...array_keys(APP_SECRET_HEALTH_CHECK_COLUMNS), 'custom_labels'] as $column) {
|
||||
expect($text)->not->toContain("secret-{$column}-value");
|
||||
}
|
||||
}
|
||||
|
||||
function expectApplicationSecretsUnchanged(Application $application): void
|
||||
{
|
||||
$stored = $application->fresh();
|
||||
foreach (applicationSecretValues() as $column => $value) {
|
||||
expect($stored->getAttribute($column))->toBe($value);
|
||||
}
|
||||
}
|
||||
|
||||
function applicationApiToken(User $user, array $abilities): string
|
||||
{
|
||||
$plainTextToken = Str::random(40);
|
||||
$token = $user->tokens()->create([
|
||||
'name' => 'test-token',
|
||||
'token' => hash('sha256', $plainTextToken),
|
||||
'abilities' => $abilities,
|
||||
'team_id' => test()->team->id,
|
||||
]);
|
||||
|
||||
return $token->getKey().'|'.$plainTextToken;
|
||||
}
|
||||
|
||||
it('keeps application commands, Dockerfile, and labels out of member Livewire state', function () {
|
||||
$this->actingAs($this->member);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$general = Livewire::test(General::class, ['application' => $this->application]);
|
||||
foreach (APP_SECRET_COLUMNS as $property) {
|
||||
$general->assertSet($property, null);
|
||||
}
|
||||
$general->assertSet('customLabels', null);
|
||||
|
||||
$healthChecks = Livewire::test(HealthChecks::class, ['resource' => $this->application]);
|
||||
foreach (APP_SECRET_HEALTH_CHECK_COLUMNS as $property) {
|
||||
$healthChecks->assertSet($property, null);
|
||||
}
|
||||
|
||||
expectApplicationSecretsAbsent(
|
||||
json_encode($general->snapshot).$general->html().json_encode($healthChecks->snapshot).$healthChecks->html()
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps application commands, Dockerfile, and labels available to an owner', function () {
|
||||
$this->actingAs($this->owner);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$general = Livewire::test(General::class, ['application' => $this->application]);
|
||||
foreach (APP_SECRET_COLUMNS as $column => $property) {
|
||||
$general->assertSet($property, "secret-{$column}-value");
|
||||
}
|
||||
expect($general->get('customLabels'))->toContain('secret-custom_labels-value');
|
||||
|
||||
$healthChecks = Livewire::test(HealthChecks::class, ['resource' => $this->application]);
|
||||
foreach (APP_SECRET_HEALTH_CHECK_COLUMNS as $column => $property) {
|
||||
$healthChecks->assertSet($property, "secret-{$column}-value");
|
||||
}
|
||||
});
|
||||
|
||||
it('does not let a member save clear the hidden values', function () {
|
||||
$this->actingAs($this->member);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
foreach (['submit', 'instantSave'] as $method) {
|
||||
Livewire::test(General::class, ['application' => $this->application])->call($method);
|
||||
}
|
||||
foreach (['submit', 'instantSave', 'toggleHealthcheck'] as $method) {
|
||||
Livewire::test(HealthChecks::class, ['resource' => $this->application])->call($method);
|
||||
}
|
||||
|
||||
expectApplicationSecretsUnchanged($this->application);
|
||||
});
|
||||
|
||||
it('keeps an owner save from changing the values', function () {
|
||||
$this->actingAs($this->owner);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
Livewire::test(HealthChecks::class, ['resource' => $this->application])->call('submit');
|
||||
|
||||
expectApplicationSecretsUnchanged($this->application);
|
||||
});
|
||||
|
||||
it('hides application commands and configuration from a read token', function (string $path) {
|
||||
$response = $this->withToken(applicationApiToken($this->member, ['read']))
|
||||
->getJson(str_replace('{uuid}', $this->application->uuid, $path))
|
||||
->assertOk();
|
||||
|
||||
expectApplicationSecretsAbsent($response->getContent());
|
||||
})->with([
|
||||
'application' => '/api/v1/applications/{uuid}',
|
||||
'application list' => '/api/v1/applications',
|
||||
'resources' => '/api/v1/resources',
|
||||
]);
|
||||
|
||||
it('shows application commands and configuration to a read:sensitive token', function (string $path) {
|
||||
$response = $this->withToken(applicationApiToken($this->owner, ['read', 'read:sensitive']))
|
||||
->getJson(str_replace('{uuid}', $this->application->uuid, $path))
|
||||
->assertOk();
|
||||
|
||||
foreach (applicationSecretValues() as $value) {
|
||||
expect($response->getContent())->toContain(str_replace('/', '\/', $value));
|
||||
}
|
||||
})->with([
|
||||
'application' => '/api/v1/applications/{uuid}',
|
||||
'application list' => '/api/v1/applications',
|
||||
'resources' => '/api/v1/resources',
|
||||
]);
|
||||
|
||||
it('redacts command and configuration changes for members in the pending change list', function () {
|
||||
$deployment = ApplicationDeploymentQueue::create([
|
||||
'application_id' => (string) $this->application->id,
|
||||
'deployment_uuid' => (string) Str::uuid(),
|
||||
'status' => 'finished',
|
||||
'commit' => 'HEAD',
|
||||
]);
|
||||
$this->application->markDeploymentConfigurationApplied($deployment);
|
||||
|
||||
$changed = collect(applicationSecretValues())->map(fn (string $value, string $column) => $column === 'custom_labels'
|
||||
? base64_encode('traefik.http.middlewares.auth.basicauth.users=secret-new-custom_labels-value')
|
||||
: str_replace('secret-', 'secret-new-', $value))->all();
|
||||
$this->application->forceFill($changed)->save();
|
||||
|
||||
$this->actingAs($this->member);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$component = Livewire::test(ConfigurationChecker::class, ['resource' => $this->application->refresh()]);
|
||||
|
||||
expect($component->get('isConfigurationChanged'))->toBeTrue();
|
||||
$text = json_encode($component->snapshot).$component->html();
|
||||
expectApplicationSecretsAbsent($text);
|
||||
expect($text)->not->toContain('secret-new-');
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Project\Database\Clickhouse\General as ClickhouseGeneral;
|
||||
use App\Livewire\Project\Database\Dragonfly\General as DragonflyGeneral;
|
||||
use App\Livewire\Project\Database\Dragonfly\StatusInfo as DragonflyStatusInfo;
|
||||
use App\Livewire\Project\Database\Keydb\General as KeydbGeneral;
|
||||
use App\Livewire\Project\Database\Keydb\StatusInfo as KeydbStatusInfo;
|
||||
@@ -9,6 +11,7 @@ use App\Livewire\Project\Database\Mysql\General as MysqlGeneral;
|
||||
use App\Livewire\Project\Database\Postgresql\General as PostgresqlGeneral;
|
||||
use App\Livewire\Project\Database\Redis\General as RedisGeneral;
|
||||
use App\Livewire\Project\Database\Redis\StatusInfo as RedisStatusInfo;
|
||||
use App\Livewire\Project\Database\Sqlite\General as SqliteGeneral;
|
||||
use App\Models\Environment;
|
||||
use App\Models\EnvironmentVariable;
|
||||
use App\Models\InstanceSettings;
|
||||
@@ -239,3 +242,129 @@ it('still returns the connection url of an unlocked redis password in the api',
|
||||
->assertOk()
|
||||
->assertJsonPath('internal_db_url', "redis://default:visible-redis-secret@{$redis->uuid}:6379/0");
|
||||
});
|
||||
|
||||
dataset('database-api-config', [
|
||||
'postgresql' => ['create_standalone_postgresql', 'postgresqls', ['postgres_conf' => "primary_conninfo = 'password=secret-config-value'"]],
|
||||
'mysql' => ['create_standalone_mysql', 'mysqls', ['mysql_conf' => 'password=secret-config-value']],
|
||||
'mariadb' => ['create_standalone_mariadb', 'mariadbs', ['mariadb_conf' => 'password=secret-config-value']],
|
||||
'mongodb' => ['create_standalone_mongodb', 'mongodbs', ['mongo_conf' => 'keyFile: secret-config-value']],
|
||||
'redis' => ['create_standalone_redis', 'redis', ['redis_conf' => 'masterauth secret-config-value']],
|
||||
'keydb' => ['create_standalone_keydb', null, ['keydb_conf' => 'masterauth secret-config-value']],
|
||||
'dragonfly' => ['create_standalone_dragonfly', null, []],
|
||||
'clickhouse' => ['create_standalone_clickhouse', null, []],
|
||||
]);
|
||||
|
||||
function apiTokenHeaders(User $user, Team $team, array $abilities): array
|
||||
{
|
||||
$token = $user->createToken('config-test', $abilities);
|
||||
DB::table('personal_access_tokens')->where('id', $token->accessToken->id)->update(['team_id' => $team->id]);
|
||||
|
||||
return ['Authorization' => 'Bearer '.$token->plainTextToken];
|
||||
}
|
||||
|
||||
it('hides custom configs and docker run options from a token without sensitive read access', function (string $createDatabase, ?string $environmentRelation, array $config) {
|
||||
$database = createDatabaseWithConfig($createDatabase, [
|
||||
...$config,
|
||||
'custom_docker_run_options' => '--env API_TOKEN=secret-docker-value',
|
||||
]);
|
||||
session(['currentTeam' => $this->team]);
|
||||
$project = $this->environment->project;
|
||||
|
||||
foreach ([
|
||||
[$this->member, ['read']],
|
||||
[$this->member, ['read', 'read:sensitive']],
|
||||
[$this->owner, ['read']],
|
||||
] as [$user, $abilities]) {
|
||||
$headers = apiTokenHeaders($user, $this->team, $abilities);
|
||||
|
||||
$single = $this->withHeaders($headers)->getJson("/api/v1/databases/{$database->uuid}")->assertOk();
|
||||
expect($single->json('uuid'))->toBe($database->uuid)
|
||||
->and($single->getContent())->not->toContain('secret-config-value')->not->toContain('secret-docker-value');
|
||||
|
||||
$list = $this->withHeaders($headers)->getJson('/api/v1/databases')->assertOk();
|
||||
expect($list->getContent())->toContain($database->uuid)
|
||||
->not->toContain('secret-config-value')->not->toContain('secret-docker-value');
|
||||
|
||||
if ($environmentRelation !== null) {
|
||||
$environment = $this->withHeaders($headers)->getJson("/api/v1/projects/{$project->uuid}/{$this->environment->uuid}")->assertOk();
|
||||
expect($environment->getContent())->toContain($database->uuid)
|
||||
->not->toContain('secret-config-value')->not->toContain('secret-docker-value');
|
||||
}
|
||||
}
|
||||
})->with('database-api-config');
|
||||
|
||||
it('returns custom configs and docker run options to an owner token with sensitive read access', function (string $createDatabase, ?string $environmentRelation, array $config) {
|
||||
$database = createDatabaseWithConfig($createDatabase, [
|
||||
...$config,
|
||||
'custom_docker_run_options' => '--env API_TOKEN=secret-docker-value',
|
||||
]);
|
||||
session(['currentTeam' => $this->team]);
|
||||
$headers = apiTokenHeaders($this->owner, $this->team, ['read', 'read:sensitive']);
|
||||
|
||||
$single = $this->withHeaders($headers)->getJson("/api/v1/databases/{$database->uuid}")->assertOk()
|
||||
->assertJsonPath('custom_docker_run_options', '--env API_TOKEN=secret-docker-value');
|
||||
foreach ($config as $key => $value) {
|
||||
$single->assertJsonPath($key, $value);
|
||||
}
|
||||
|
||||
if ($environmentRelation !== null) {
|
||||
$project = $this->environment->project;
|
||||
$this->withHeaders($headers)->getJson("/api/v1/projects/{$project->uuid}/{$this->environment->uuid}")->assertOk()
|
||||
->assertJsonPath("{$environmentRelation}.0.custom_docker_run_options", '--env API_TOKEN=secret-docker-value');
|
||||
}
|
||||
})->with('database-api-config');
|
||||
|
||||
dataset('database-docker-options-components', [
|
||||
'postgresql' => ['create_standalone_postgresql', PostgresqlGeneral::class],
|
||||
'mysql' => ['create_standalone_mysql', MysqlGeneral::class],
|
||||
'mariadb' => ['create_standalone_mariadb', MariadbGeneral::class],
|
||||
'mongodb' => ['create_standalone_mongodb', MongodbGeneral::class],
|
||||
'redis' => ['create_standalone_redis', RedisGeneral::class],
|
||||
'keydb' => ['create_standalone_keydb', KeydbGeneral::class],
|
||||
'dragonfly' => ['create_standalone_dragonfly', DragonflyGeneral::class],
|
||||
'clickhouse' => ['create_standalone_clickhouse', ClickhouseGeneral::class],
|
||||
'sqlite' => ['create_standalone_sqlite', SqliteGeneral::class],
|
||||
]);
|
||||
|
||||
it('keeps custom docker run options out of member state and html', function (string $createDatabase, string $generalClass) {
|
||||
$database = createDatabaseWithConfig($createDatabase, ['custom_docker_run_options' => '--env API_TOKEN=secret-docker-value']);
|
||||
$ownTeam = Team::factory()->create();
|
||||
$ownTeam->members()->attach($this->member->id, ['role' => 'owner']);
|
||||
|
||||
$this->actingAs($this->member);
|
||||
foreach ([$this->team, $ownTeam] as $sessionTeam) {
|
||||
session(['currentTeam' => $sessionTeam]);
|
||||
|
||||
$general = Livewire::test($generalClass, ['database' => $database])
|
||||
->assertSet('customDockerRunOptions', null);
|
||||
expect(json_encode($general->snapshot))->not->toContain('secret-docker-value')
|
||||
->and($general->html())->not->toContain('secret-docker-value')
|
||||
->and($general->html())->toContain('Hidden (only admins can view)');
|
||||
}
|
||||
})->with('database-docker-options-components');
|
||||
|
||||
it('keeps custom docker run options available to an owner', function (string $createDatabase, string $generalClass) {
|
||||
$database = createDatabaseWithConfig($createDatabase, ['custom_docker_run_options' => '--env API_TOKEN=secret-docker-value']);
|
||||
|
||||
$this->actingAs($this->owner);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
Livewire::test($generalClass, ['database' => $database])
|
||||
->assertSet('customDockerRunOptions', '--env API_TOKEN=secret-docker-value');
|
||||
})->with('database-docker-options-components');
|
||||
|
||||
it('does not let a member save over hidden custom configs and docker run options', function (string $createDatabase, string $generalClass) {
|
||||
$database = createDatabaseWithConfig($createDatabase, ['custom_docker_run_options' => '--env API_TOKEN=secret-docker-value']);
|
||||
|
||||
$this->actingAs($this->member);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
Livewire::test($generalClass, ['database' => $database])
|
||||
->set('name', 'renamed-by-member')
|
||||
->call('submit')
|
||||
->call('instantSaveAdvanced');
|
||||
|
||||
$database = $database->fresh();
|
||||
expect($database->name)->not->toBe('renamed-by-member')
|
||||
->and($database->custom_docker_run_options)->toBe('--env API_TOKEN=secret-docker-value');
|
||||
})->with('database-docker-options-components');
|
||||
|
||||
@@ -31,13 +31,13 @@ test('builds database-specific restore commands', function (string $class, ?stri
|
||||
expect($command)->toContain($needle)->toContain("'/tmp/restore file'");
|
||||
})->with([
|
||||
'postgresql' => [StandalonePostgresql::class, null, 'pg_restore'],
|
||||
'mysql' => [StandaloneMysql::class, null, 'mysql -u $MYSQL_USER'],
|
||||
'mariadb' => [StandaloneMariadb::class, null, 'mariadb -u $MARIADB_USER'],
|
||||
'mysql' => [StandaloneMysql::class, null, 'mysql $client_safety -u $MYSQL_USER'],
|
||||
'mariadb' => [StandaloneMariadb::class, null, 'mariadb $client_safety -u $MARIADB_USER'],
|
||||
'mongodb' => [StandaloneMongodb::class, null, 'mongorestore'],
|
||||
'sqlite' => [StandaloneSqlite::class, null, '.restore'],
|
||||
'service postgres' => [ServiceDatabase::class, 'postgresql', 'pg_restore'],
|
||||
'service mysql' => [ServiceDatabase::class, 'mysql', 'mysql -u $MYSQL_USER'],
|
||||
'service mariadb' => [ServiceDatabase::class, 'mariadb', 'mariadb -u $MARIADB_USER'],
|
||||
'service mysql' => [ServiceDatabase::class, 'mysql', 'mysql $client_safety -u $MYSQL_USER'],
|
||||
'service mariadb' => [ServiceDatabase::class, 'mariadb', 'mariadb $client_safety -u $MARIADB_USER'],
|
||||
'service mongo' => [ServiceDatabase::class, 'mongodb', 'mongorestore'],
|
||||
]);
|
||||
|
||||
@@ -51,10 +51,10 @@ test('decompresses gzip backups for single-database mysql and mariadb restores',
|
||||
->toContain('is_gzip() { [ "$(head -c 2 "$backup" | od -An -tx1 | tr -d \' \n\')" = 1f8b ]; }')
|
||||
->toEndWith("stream | {$client}");
|
||||
})->with([
|
||||
'mysql' => [StandaloneMysql::class, null, 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE'],
|
||||
'mariadb' => [StandaloneMariadb::class, null, 'mariadb -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE'],
|
||||
'service mysql' => [ServiceDatabase::class, 'mysql', 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE'],
|
||||
'service mariadb' => [ServiceDatabase::class, 'mariadb', 'mariadb -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE'],
|
||||
'mysql' => [StandaloneMysql::class, null, 'mysql $client_safety -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE'],
|
||||
'mariadb' => [StandaloneMariadb::class, null, 'mariadb $client_safety -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE'],
|
||||
'service mysql' => [ServiceDatabase::class, 'mysql', 'mysql $client_safety -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE'],
|
||||
'service mariadb' => [ServiceDatabase::class, 'mariadb', 'mariadb $client_safety -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE'],
|
||||
]);
|
||||
|
||||
test('builds dump-all commands and postgres safety scan', function () {
|
||||
@@ -158,7 +158,7 @@ test('skips the system databases of MySQL and MariaDB all-databases backups by d
|
||||
expect($command)->toContain('command -v awk')
|
||||
->toMatch('/stream \| \{?\s*awk /')
|
||||
->toContain('"mysql"')->toContain('"sys"')->toContain('"performance_schema"')->toContain('"information_schema"')
|
||||
->toContain("| {$binary} -u root");
|
||||
->toContain("| {$binary} \$client_safety -u root");
|
||||
})->with([
|
||||
'mysql' => [StandaloneMysql::class, 'mysql'],
|
||||
'mariadb' => [StandaloneMariadb::class, 'mariadb'],
|
||||
@@ -168,7 +168,7 @@ test('restores the system databases of MySQL and MariaDB all-databases backups w
|
||||
$command = (new DatabaseImportCommandBuilder)->buildRestoreCommand(importResource($class), '/tmp/backup.sql', true, restoreMysqlUsers: true);
|
||||
|
||||
expect($command)->not->toContain('awk')
|
||||
->toContain("stream | {$binary} -u root");
|
||||
->toContain("stream | {$binary} \$client_safety -u root");
|
||||
})->with([
|
||||
'mysql' => [StandaloneMysql::class, 'mysql'],
|
||||
'mariadb' => [StandaloneMariadb::class, 'mariadb'],
|
||||
|
||||
@@ -250,6 +250,7 @@ function restoreScriptRun(string $engine, string $contents, bool $dumpAll = fals
|
||||
$escapedStdin = escapeshellarg($dir.'/stdin');
|
||||
file_put_contents($dir.'/bin/'.$client, <<<SH
|
||||
#!/bin/sh
|
||||
if [ "\$*" = '--help' ]; then printf '%s\\n' "\${STUB_HELP:-}"; exit 0; fi
|
||||
header=''
|
||||
call=\$(wc -l < {$escapedLog} | tr -d ' ')
|
||||
if [ ! -t 0 ]; then
|
||||
@@ -735,6 +736,47 @@ test('rejects unsupported MySQL and MariaDB backups before calling any client',
|
||||
'dump of two databases without dump-all' => ['mysql-two-databases', false, 'This backup contains more than one database.'],
|
||||
]);
|
||||
|
||||
test('restores MySQL and MariaDB dumps with client commands disabled when the client supports it', function (string $engine, string $help, string $flag, bool $dumpAll) {
|
||||
$run = restoreScriptRun($engine, restoreScriptFixture('mysql-sql'), $dumpAll, env: ['STUB_HELP' => $help]);
|
||||
|
||||
expect($run['exit'])->toBe(0, $run['stderr'])
|
||||
->and(end($run['calls'])['args'])->toStartWith($flag.' -u ')
|
||||
->and(end($run['calls'])['stdin'])->toBe(restoreScriptFixture('mysql-sql'));
|
||||
})->with(['mysql', 'mariadb'])->with([
|
||||
'MariaDB client' => [' --sandbox Disallow commands that access the file system', '--sandbox'],
|
||||
'MySQL client' => [' --commands Whether to enable or disable processing of local mysql commands.', '--commands=FALSE'],
|
||||
])->with(['single database' => false, 'all databases' => true]);
|
||||
|
||||
test('refuses MySQL and MariaDB dumps with client commands before calling any client', function (string $engine, string $command, bool $dumpAll) {
|
||||
$dump = "-- MySQL dump 10.13\n\nCREATE TABLE `items` (`id` int);\n{$command}\nINSERT INTO `items` VALUES (1);\n";
|
||||
|
||||
foreach ([$dump, gzencode($dump)] as $contents) {
|
||||
$run = restoreScriptRun($engine, $contents, $dumpAll);
|
||||
|
||||
expect($run['exit'])->toBe(1)
|
||||
->and($run['stderr'])->toContain('client command')->toContain('Nothing was changed.')
|
||||
->and($run['calls'])->toBe([]);
|
||||
}
|
||||
})->with(['mysql', 'mariadb'])->with([
|
||||
'system' => 'system id',
|
||||
'indented SYSTEM' => ' SYSTEM id;',
|
||||
'\\!' => '\\! id',
|
||||
'\\! after SQL' => 'SELECT 1; \\! id',
|
||||
'tee' => 'tee /etc/cron.d/x',
|
||||
'\\T' => '\\T /etc/cron.d/x',
|
||||
'pager' => 'pager sh',
|
||||
'source' => 'source /etc/shadow',
|
||||
'\\.' => '\\. /etc/shadow',
|
||||
])->with(['single database' => false, 'all databases' => true]);
|
||||
|
||||
test('restores MySQL and MariaDB dumps whose data only looks like client commands', function (string $engine) {
|
||||
$dump = "-- MySQL dump 10.13\n/*M!999999\\- enable the sandbox mode */\nCREATE TABLE `items` (`t` text, `system` int);\nINSERT INTO `items` VALUES ('a\\\\! b\\\\\\\\T c\\'s \\\\. \\r\\n',1),('system id',2);\n";
|
||||
$run = restoreScriptRun($engine, $dump);
|
||||
|
||||
expect($run['exit'])->toBe(0, $run['stderr'])
|
||||
->and(end($run['calls'])['stdin'])->toBe($dump);
|
||||
})->with(['mysql', 'mariadb']);
|
||||
|
||||
test('restores mongodump archives', function (string $fixture, bool $replaceExisting, bool $gzip) {
|
||||
$run = restoreScriptRun('mongodb', restoreScriptFixture($fixture), false, $replaceExisting);
|
||||
|
||||
|
||||
@@ -162,7 +162,7 @@ test('buildRestoreCommand handles MySQL without dumpAll', function () {
|
||||
$result = $component->buildRestoreCommand('/tmp/test.dump');
|
||||
|
||||
expect($result)->toStartWith("backup='/tmp/test.dump'");
|
||||
expect($result)->toContain('stream | mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE');
|
||||
expect($result)->toContain('stream | mysql $client_safety -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE');
|
||||
expect($result)->not->toContain("< '/tmp/test.dump'");
|
||||
});
|
||||
|
||||
@@ -173,7 +173,7 @@ test('buildRestoreCommand handles MariaDB without dumpAll', function () {
|
||||
$result = $component->buildRestoreCommand('/tmp/test.dump');
|
||||
|
||||
expect($result)->toStartWith("backup='/tmp/test.dump'");
|
||||
expect($result)->toContain('stream | mariadb -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE');
|
||||
expect($result)->toContain('stream | mariadb $client_safety -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE');
|
||||
expect($result)->not->toContain("< '/tmp/test.dump'");
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
use App\Notifications\Dto\PushoverMessage;
|
||||
use Illuminate\Log\Events\MessageLogged;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
it('builds the payload without writing to the log', function () {
|
||||
$logged = [];
|
||||
Log::listen(function (MessageLogged $event) use (&$logged) {
|
||||
$logged[] = $event->message;
|
||||
});
|
||||
|
||||
$payload = (new PushoverMessage(
|
||||
title: 'Deployment failed',
|
||||
message: 'Your deployment failed.',
|
||||
buttons: [['text' => 'Open', 'url' => 'https://coolify.example.com/deployments/1']],
|
||||
level: 'error',
|
||||
))->toPayload('app-token', 'user-key');
|
||||
|
||||
expect($payload)
|
||||
->token->toBe('app-token')
|
||||
->user->toBe('user-key')
|
||||
->and($logged)->toBeEmpty();
|
||||
});
|
||||
Reference in New Issue
Block a user