Refactor how to we handle some sensitive env variables.

This commit is contained in:
arabcoders
2025-05-14 22:54:18 +03:00
parent 4a4c9ddb54
commit 870fc607a9
7 changed files with 76 additions and 237 deletions

41
FAQ.md
View File

@@ -487,38 +487,6 @@ https://watchstate.example.org {
---
# WS_API_AUTO
The `WS_API_AUTO` environment variable is designed to **automate the initial configuration process**, particularly
useful for users who access the WebUI from multiple browsers or devices. Since the WebUI requires API settings to be
configured before use, enabling this variable allows the system to auto-configure itself.
To enable it, write `WS_API_AUTO=true` to `/config/.env` file, note the file may not exist, and you may need to create
it.
## Why You Might Use It
You may consider using this if:
- You're operating in a **secure, local environment**.
- You want to **automate setup** across multiple devices or browsers without repeatedly entering API details.
## Why You Should **NOT** Use It (Recommended)
Enabling this poses a **serious security risk**:
- It **exposes your API key** publicly through the endpoint `/v1/api/system/auto`.
- Anyone (or any bot) that can access the WebUI can retrieve your API key and gain **access** to any and all data that
is exposed by the API including your media servers API keys.
**If WatchState is exposed to the internet, do not enable this setting.**
> [!IMPORTANT]
> The `WS_API_AUTO` variable is a **major security risk**. It should only be used in isolated or trusted environments.
> We strongly recommend keeping this option disabled.
---
# How to disable the included cache server and use an external cache server?
To disable the built-in cache server and connect to an external Redis instance, follow these steps:
@@ -913,7 +881,8 @@ In order to sync the watch progress between media backends, you need to enable t
$ docker exec -ti watchstate console system:env -k WS_SYNC_PROGRESS -e true
```
For best experience, you should enable the [webhook](guides/webhooks.md) feature for the media backends you want to sync the watch
For best experience, you should enable the [webhook](guides/webhooks.md) feature for the media backends you want to sync
the watch
progress,
however, if you are unable to do so, the `Tasks > import` task will also generate progress watch events. However, it's
not as reliable as the `Webhooks` or as fast. using `Webhooks` is the recommended way and offers the best experience.
@@ -922,8 +891,10 @@ To check if there is any watch progress events being registered, You can go to `
`on_progress` events, if you are seeing those, this means the progress is being synced. Check the `Tasks logs` to see
the event log.
If this is setup and working you may be ok with changing the `WS_CRON_IMPORT_AT/WS_CRON_EXPORT_AT` schedule to something less frequenet as
the sync progress working will update the progress near realtime. For example you could change these tasks to run daily instead of hourly.
If this is setup and working you may be ok with changing the `WS_CRON_IMPORT_AT/WS_CRON_EXPORT_AT` schedule to something
less frequenet as
the sync progress working will update the progress near realtime. For example you could change these tasks to run daily
instead of hourly.
```
WS_CRON_IMPORT_AT=0 11 * * *

View File

@@ -18,13 +18,13 @@ use Monolog\Level;
return (function () {
$inContainer = inContainer();
$progressTimeCheck = fn (int $v, int $d): int => 0 === $v || $v >= 180 ? $v : $d;
$progressTimeCheck = fn(int $v, int $d): int => 0 === $v || $v >= 180 ? $v : $d;
$config = [
'name' => 'WatchState',
'version' => '$(version_via_ci)',
'tz' => env('WS_TZ', env('TZ', 'UTC')),
'path' => fixPath(env('WS_DATA_PATH', fn () => $inContainer ? '/config' : __DIR__ . '/../var')),
'path' => fixPath(env('WS_DATA_PATH', fn() => $inContainer ? '/config' : __DIR__ . '/../var')),
'logs' => [
'context' => (bool)env('WS_LOGS_CONTEXT', false),
'prune' => [
@@ -35,7 +35,6 @@ return (function () {
'prefix' => '/v1/api',
'key' => env('WS_API_KEY', null),
'secure' => (bool)env('WS_SECURE_API_ENDPOINTS', false),
'auto' => (bool)env('WS_API_AUTO', false),
'pattern_match' => [
'backend' => '[a-zA-Z0-9_\-]+',
'ubackend' => '[a-zA-Z0-9_\-\@]+',
@@ -45,7 +44,7 @@ return (function () {
'encode' => JSON_INVALID_UTF8_IGNORE | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE,
'headers' => [
'Content-Type' => 'application/json',
'X-Application-Version' => fn () => getAppVersion(),
'X-Application-Version' => fn() => getAppVersion(),
'Access-Control-Allow-Origin' => '*',
],
],
@@ -155,14 +154,14 @@ return (function () {
$config['profiler'] = [
'save' => (bool)env('WS_PROFILER_SAVE', true),
'path' => env('WS_PROFILER_PATH', fn () => ag($config, 'tmpDir') . '/profiler'),
'path' => env('WS_PROFILER_PATH', fn() => ag($config, 'tmpDir') . '/profiler'),
'collector' => env('WS_PROFILER_COLLECTOR', null),
];
$config['cache'] = [
'prefix' => env('WS_CACHE_PREFIX', null),
'url' => env('WS_CACHE_URL', 'redis://127.0.0.1:6379'),
'path' => env('WS_CACHE_PATH', fn () => ag($config, 'tmpDir') . '/cache'),
'path' => env('WS_CACHE_PATH', fn() => ag($config, 'tmpDir') . '/cache'),
];
$config['logger'] = [

View File

@@ -146,12 +146,6 @@ return (function () {
'description' => 'Expose debug information in the API when an error occurs.',
'type' => 'bool',
],
[
'key' => 'WS_API_AUTO',
'description' => 'PUBLICLY EXPOSE the api token for automated WebUI configuration. This should NEVER be enabled if WatchState is exposed to the internet.',
'danger' => true,
'type' => 'bool',
],
[
'key' => 'WS_CONSOLE_ENABLE_ALL',
'description' => 'All executing all commands in the console. They must be prefixed with $',
@@ -232,7 +226,7 @@ return (function () {
],
[
'key' => 'WS_SYSTEM_USER',
'description' => '(NOT IMPLEMENTED YET) The login user name',
'description' => 'The login user name',
'type' => 'string',
'validate' => function (mixed $value): string {
if (!is_numeric($value) && empty($value)) {
@@ -240,15 +234,18 @@ return (function () {
}
if (false === isValidName($value)) {
throw new ValidationException('Invalid username. Username can only contains [lower case a-z, 0-9 and _].');
throw new ValidationException(
'Invalid username. Username can only contains [lower case a-z, 0-9 and _].'
);
}
return $value;
},
'mask' => true,
'hidden' => true,
],
[
'key' => 'WS_SYSTEM_PASSWORD',
'description' => '(NOT IMPLEMENTED YET) The login password. The given plaintext password will be converted to hash.',
'description' => 'The login password. The given plaintext password will be converted to hash.',
'type' => 'string',
'validate' => function (mixed $value): string {
if (empty($value)) {
@@ -270,6 +267,7 @@ return (function () {
return $prefix . $hash;
},
'mask' => true,
'hidden' => true,
],
];

View File

@@ -10,19 +10,19 @@
<div class="field is-grouped">
<div class="control has-icons-left" v-if="toggleFilter || query">
<input type="search" v-model.lazy="query" class="input" id="filter"
placeholder="Filter displayed content">
<span class="icon is-left"><i class="fas fa-filter" /></span>
placeholder="Filter displayed content">
<span class="icon is-left"><i class="fas fa-filter"/></span>
</div>
<div class="control">
<button class="button is-danger is-light" @click="toggleFilter = !toggleFilter">
<span class="icon"><i class="fas fa-filter" /></span>
<span class="icon"><i class="fas fa-filter"/></span>
</button>
</div>
<p class="control">
<button class="button is-primary" v-tooltip.bottom="'Add new variable'" @click="toggleForm = !toggleForm"
:disabled="isLoading">
:disabled="isLoading">
<span class="icon">
<i class="fas fa-add"></i>
</span>
@@ -30,7 +30,7 @@
</p>
<p class="control">
<button class="button is-info" @click="loadContent" :disabled="isLoading || toggleForm"
:class="{ 'is-loading': isLoading }">
:class="{ 'is-loading': isLoading }">
<span class="icon"><i class="fas fa-sync"></i></span>
</button>
</p>
@@ -45,9 +45,9 @@
<div class="column is-12" v-if="!toggleForm && filteredRows.length < 1">
<Message v-if="isLoading" message_class="has-background-info-90 has-text-dark" title="Loading"
icon="fas fa-spinner fa-spin" message="Loading data. Please wait..." />
icon="fas fa-spinner fa-spin" message="Loading data. Please wait..."/>
<Message v-else message_class="has-background-warning-90 has-text-dark"
:title="query ? 'No results' : 'Information'" icon="fas fa-info-circle">
:title="query ? 'No results' : 'Information'" icon="fas fa-info-circle">
<p v-if="query">
No environment variables found matching <strong>{{ query }}</strong>. Please try a different filter.
</p>
@@ -88,25 +88,25 @@
<div class="field">
<div class="field has-addons">
<div class="control is-expanded">
<input class="input" id="api_token" v-model="form_value" required placeholder="Masked value"
:type="false === form_expose ? 'password' : 'text'">
<input class="input" id="form_value" v-model="form_value" required placeholder="Masked value"
:type="false === form_expose ? 'password' : 'text'">
</div>
<div class="control">
<button type="button" class="button is-primary" @click="form_expose = !form_expose">
<span class="icon" v-if="!form_expose"><i class="fas fa-eye" /></span>
<span class="icon" v-else><i class="fas fa-eye-slash" /></span>
<span class="icon" v-if="!form_expose"><i class="fas fa-eye"/></span>
<span class="icon" v-else><i class="fas fa-eye-slash"/></span>
</button>
</div>
</div>
<div>
<p class="help" v-html="getHelp(form_key)" />
<p class="help title is-6" v-html="getHelp(form_key)"/>
</div>
</div>
</div>
<div v-else class="control has-icons-left">
<template v-if="'bool' === form_type">
<input id="form_value" type="checkbox" class="switch is-success" :checked="fixBool(form_value)"
@change="form_value = !fixBool(form_value)">
@change="form_value = !fixBool(form_value)">
<label for="form_value">
<template v-if="fixBool(form_value)">On (True)</template>
<template v-else>Off (False)</template>
@@ -114,7 +114,7 @@
</template>
<template v-else-if="'int' === form_type">
<input class="input" id="form_value" type="number" placeholder="Value" v-model="form_value"
pattern="[0-9]*" inputmode="numeric">
pattern="[0-9]*" inputmode="numeric">
<div class="icon is-small is-left">
<i class="fas fa-font"></i>
</div>
@@ -124,7 +124,7 @@
<div class="icon is-small is-left"><i class="fas fa-font"></i></div>
</template>
<div>
<p class="help" v-html="getHelp(form_key)"></p>
<p class="help title is-6" v-html="getHelp(form_key)"></p>
</div>
</div>
</div>
@@ -150,10 +150,11 @@
</div>
</form>
</div>
<div v-else class="column is-12" v-if="filteredRows">
<div class="columns is-multiline">
<div class="column" v-for="item in filteredRows" :key="item.key"
:class="{ 'is-4': !item?.danger, 'is-12': item.danger }">
:class="{ 'is-4': !item?.danger, 'is-12': item.danger }">
<div class="card" :class="{ 'is-danger': item?.danger }">
<header class="card-header">
<p class="card-header-title is-unselectable">
@@ -171,7 +172,7 @@
</template>
</p>
<span class="card-header-icon" v-if="item.mask" @click="item.mask = false"
v-tooltip="'Unmask the value'">
v-tooltip="'Unmask the value'">
<span class="icon"><i class="fas fa-unlock"></i></span>
</span>
</header>
@@ -187,9 +188,10 @@
</span>
</p>
<p v-else class="is-text-overflow is-clickable is-unselectable"
:class="{ 'is-masked': item.mask, 'is-unselectable': item.mask }"
@click="(e) => e.target.classList.toggle('is-text-overflow')">
{{ item.value }}</p>
:class="{ 'is-masked': item.mask, 'is-unselectable': item.mask }"
@click="(e) => e.target.classList.toggle('is-text-overflow')">
{{ item.value }}
</p>
<p v-if="item?.danger" class="title is-5 has-text-danger">
{{ item.description }}
@@ -229,7 +231,7 @@
<div class="column is-12">
<Message message_class="has-background-info-90 has-text-dark" :toggle="show_page_tips"
@toggle="show_page_tips = !show_page_tips" :use-toggle="true" title="Tips" icon="fas fa-info-circle">
@toggle="show_page_tips = !show_page_tips" :use-toggle="true" title="Tips" icon="fas fa-info-circle">
<ul>
<li>Some variables values are masked, to unmask them click on icon <i class="fa fa-unlock"></i>.</li>
<li>Some values are too large to fit into the view, clicking on the value will show the full value.</li>
@@ -250,13 +252,13 @@
<script setup>
import 'assets/css/bulma-switch.css'
import request from '~/utils/request'
import { awaitElement, copyText, notification } from '~/utils/index'
import { useStorage } from '@vueuse/core'
import {awaitElement, copyText, notification} from '~/utils/index'
import {useStorage} from '@vueuse/core'
import Message from '~/components/Message'
const route = useRoute()
useHead({ title: 'Environment Variables' })
useHead({title: 'Environment Variables'})
const items = ref([])
const toggleForm = ref(false)
@@ -292,10 +294,15 @@ const loadContent = async () => {
if (item && route.query?.value && !item?.value) {
item.value = route.query.value
}
editEnv(item)
if (!item) {
notification('error', 'Error', `Invalid key '${route.query.edit}'.`, 2000)
await cancelForm()
} else {
editEnv(item);
}
}
} catch (e) {
notification('error', 'Error', e.message, 5000)
notification('error', 'Error', `Error. ${e.message}`, 5000)
} finally {
isLoading.value = false
}
@@ -307,10 +314,10 @@ const deleteEnv = async env => {
}
try {
const response = await request(`/system/env/${env.key}`, { method: 'DELETE' })
const response = await request(`/system/env/${env.key}`, {method: 'DELETE'})
if (200 !== response.status) {
json = await parse_api_response(response)
const json = await parse_api_response(response)
notification('error', 'Error', `${json.error.code}: ${json.error.message}`, 5000)
return
}
@@ -345,7 +352,7 @@ const addVariable = async () => {
try {
const response = await request(`/system/env/${key}`, {
method: 'POST',
body: JSON.stringify({ value: form_value.value })
body: JSON.stringify({value: form_value.value})
})
if (304 === response.status) {
@@ -383,7 +390,7 @@ const editEnv = env => {
form_mask.value = env.mask
toggleForm.value = true
if (!useRoute().query.edit) {
useRouter().push({ 'path': '/env', query: { 'edit': env.key } })
useRouter().push({'path': '/env', query: {'edit': env.key}})
}
}
@@ -395,12 +402,12 @@ const cancelForm = async () => {
form_mask.value = false
toggleForm.value = false
if (route.query?.callback) {
await navigateTo({ path: route.query.callback })
await navigateTo({path: route.query.callback})
return
}
if (route.query?.edit || route.query?.value) {
await useRouter().push({ path: '/env' })
await useRouter().push({path: '/env'})
}
}
@@ -408,7 +415,7 @@ watch(toggleForm, async value => {
if (!value) {
await cancelForm()
} else {
awaitElement('#env_page_title', (_, el) => el.scrollIntoView({ behavior: 'smooth' }))
awaitElement('#env_page_title', (_, el) => el.scrollIntoView({behavior: 'smooth'}))
}
})
@@ -426,7 +433,7 @@ const keyChanged = () => {
form_value.value = false
}
});
useRouter().push({ 'path': '/env', query: { 'edit': form_key.value } })
useRouter().push({'path': '/env', query: {'edit': form_key.value}})
}
const getHelp = key => {

View File

@@ -1,60 +0,0 @@
<?php
declare(strict_types=1);
namespace App\API\System;
use App\Libs\Attributes\Route\Post;
use App\Libs\Config;
use App\Libs\DataUtil;
use App\Libs\Enums\Http\Status;
use App\Libs\Mappers\ImportInterface as iImport;
use App\Libs\Stream;
use App\Libs\Traits\APITraits;
use Psr\Http\Message\ResponseInterface as iResponse;
use Psr\Http\Message\ServerRequestInterface as iRequest;
use Psr\Log\LoggerInterface as iLogger;
use Throwable;
final class AutoConfig
{
use APITraits;
public const string URL = '%{api.prefix}/system/auto';
public function __construct(private readonly iImport $mapper, private readonly iLogger $logger)
{
}
#[Post(self::URL . '[/]', name: 'system.autoconfig')]
public function __invoke(iRequest $request): iResponse
{
$isEnabled = false;
try {
$initial_file = Config::get('path') . '/config/disable_auto_config.txt';
if (false === file_exists($initial_file)) {
$uc = $this->getUserContext($request, $this->mapper, $this->logger);
$isEnabled = 'main' === $uc->name && count($uc->config) < 1;
$stream = Stream::make($initial_file, 'w+');
$stream->write(r('Auto configure was called and disabled at {time}', [
'time' => makeDate('now'),
]));
$stream->close();
}
} catch (Throwable $e) {
syslog(LOG_ERR, __METHOD__ . ' Exception: ' . $e->getMessage() . ' ' . $e->getTraceAsString());
}
if (false === $isEnabled && false === (bool)Config::get('api.auto', false)) {
return api_error('auto configuration is disabled.', Status::FORBIDDEN);
}
$data = DataUtil::fromRequest($request);
return api_response(Status::OK, [
'url' => $data->get('origin', ag($_SERVER, 'HTTP_ORIGIN', 'localhost')),
'path' => Config::get('api.prefix'),
'token' => Config::get('api.key'),
]);
}
}

View File

@@ -33,6 +33,10 @@ final class Env
continue;
}
if (true === (bool)ag($info, 'hidden', false)) {
continue;
}
$info['value'] = $this->setType($info, $this->envFile->get($info['key']));
}
@@ -50,11 +54,14 @@ final class Env
if (array_key_exists('validate', $info)) {
unset($info['validate']);
}
if (true === (bool)ag($info, 'hidden', false)) {
continue;
}
$list[] = $info;
}
if (true === (bool)$params->get('set', false)) {
$list = array_filter($list, fn ($info) => $this->envFile->has($info['key']));
$list = array_filter($list, fn($info) => $this->envFile->has($info['key']));
}
return api_response(Status::OK, [
@@ -82,7 +89,7 @@ final class Env
return api_response(Status::OK, [
'key' => $key,
'value' => $this->settype($spec, ag($spec, 'value', fn () => $this->envFile->get($key))),
'value' => $this->settype($spec, ag($spec, 'value', fn() => $this->envFile->get($key))),
'description' => ag($spec, 'description'),
'type' => ag($spec, 'type'),
'mask' => (bool)ag($spec, 'mask', false),
@@ -103,12 +110,17 @@ final class Env
return api_error(r("Invalid key '{key}' was given.", ['key' => $key]), Status::BAD_REQUEST);
}
$isHidden = true === (bool)ag($spec, 'hidden', false);
if ($isHidden && false === $request->getAttribute('INTERNAL_REQUEST', false)) {
return api_error(r("Key '{key}' is not set.", ['key' => $key]), Status::NOT_FOUND);
}
if ('DELETE' === $request->getMethod()) {
$this->envFile->remove($key)->persist();
return api_response(Status::OK, [
'key' => $key,
'value' => $this->setType($spec, ag($spec, 'value', fn () => $this->envFile->get($key))),
'value' => $this->setType($spec, ag($spec, 'value', fn() => $this->envFile->get($key))),
'description' => ag($spec, 'description'),
'type' => ag($spec, 'type'),
'mask' => (bool)ag($spec, 'mask', false),

View File

@@ -6,7 +6,6 @@ namespace App\Commands\System;
use App\Command;
use App\Libs\Attributes\Route\Cli;
use App\Libs\Config;
use App\Libs\Enums\Http\Status;
use Symfony\Component\Console\Input\InputInterface as iInput;
use Symfony\Component\Console\Input\InputOption;
@@ -28,99 +27,12 @@ final class EnvCommand extends Command
protected function configure(): void
{
$this->setName(self::ROUTE)
->setDescription('Show/edit environment variables.')
->setDescription('Manage Environment Variables.')
->addOption('key', 'k', InputOption::VALUE_REQUIRED, 'Key to update.')
->addOption('set', 'e', InputOption::VALUE_REQUIRED, 'Value to set.')
->addOption('delete', 'd', InputOption::VALUE_NONE, 'Delete key.')
->addOption('list', 'l', InputOption::VALUE_NONE, 'List All Supported keys.')
->addOption('expose', 'x', InputOption::VALUE_NONE, 'Expose Hidden values.')
->setHelp(
r(
<<<HELP
This command display the environment variables that was loaded during execution of the tool.
-------------------------------
<notice>[ Environment variables rules ]</notice>
-------------------------------
* The key MUST be in CAPITAL LETTERS. For example [<flag>WS_CRON_IMPORT</flag>].
* The key MUST start with [<flag>WS_</flag>]. For example [<flag>WS_CRON_EXPORT</flag>].
* The value is simple string. No complex data types are allowed. or shell expansion variables.
* The value MUST be in one line. No multi-line values are allowed.
* The key SHOULD attempt to mirror the key path in default config, If not applicable or otherwise impossible it
should then use an approximate path.
-------
<notice>[ FAQ ]</notice>
-------
<question># How to load environment variables?</question>
For <comment>WatchState</comment> specific environment variables, we recommend using the <comment>WebUI</comment>,
to manage the environment variables. However, you can also use this command to manage the environment variables.
We use this file to load your environment variables:
- <flag>{path}</flag>/<comment>.env</comment>
To load container specific variables i,e, the keys that does not start with <comment>WS_</comment> prefix,
you can use the <comment>compose.yaml</comment> file.
For example,
-------------------------------
services:
watchstate:
image: ghcr.io/arabcoders/watchstate:latest
restart: unless-stopped
container_name: watchstate
<flag>environment:</flag>
- <flag>HTTP_PORT</flag>=<value>8080</value>
- <flag>DISABLE_CACHE</flag>=<value>1</value>
.......
-------------------------------
<question># How to set environment variables?</question>
To set an environment variable, you can use the following command:
{cmd} <cmd>{route}</cmd> <flag>-k <value>ENV_NAME</value> -e <value>ENV_VALUE</value></flag>
<notice>Note: if you are using a space within the value you need to use the long form --set, for example:
{cmd} <cmd>{route}</cmd> <flag>-k <value>ENV_NAME</value> --set=<notice>"</notice><value>ENV VALUE</value><notice>"</notice></flag>
As you can notice the spaced value is wrapped with double <value>""</value> quotes.</notice>
<question># How to see all possible environment variables?</question>
{cmd} <cmd>{route}</cmd> <flag>--list</flag>
<question># How to delete environment variable?</question>
{cmd} <cmd>{route}</cmd> <flag>-d -k</flag> <value>ENV_NAME</value>
<question># How to get specific environment variable value?</question>
{cmd} <cmd>{route}</cmd> <flag>-k</flag> <value>ENV_NAME</value>
<notice>This will show the hidden value if the environment variable marked as sensitive.</notice>
<question># How to expose the hidden values for secret environment variables?</question>
You can use the <flag>--expose</flag> flag to expose the hidden values. for both <flag>--list</flag>
or just the normal table display. For example:
{cmd} <cmd>{route}</cmd> <flag>--expose</flag>
HELP,
[
'cmd' => trim(commandContext()),
'route' => self::ROUTE,
'path' => after(Config::get('path') . '/config', ROOT_PATH),
]
)
);
->addOption('expose', 'x', InputOption::VALUE_NONE, 'Expose Hidden values.');
}
/**