Merge commit from fork
* Config: require admin password for script plugins * refactor
This commit is contained in:
parent
a193236395
commit
4c907c5afa
17 changed files with 594 additions and 207 deletions
27
assets/js/components/Auth/AdminPasswordPrompt.vue
Normal file
27
assets/js/components/Auth/AdminPasswordPrompt.vue
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
<template>
|
||||||
|
<div class="alert alert-warning mt-4 pb-0" role="alert" data-testid="admin-password-prompt">
|
||||||
|
<p class="fw-bold mb-2">{{ $t("config.adminPassword.title") }}</p>
|
||||||
|
<p>{{ $t("config.adminPassword.description") }}</p>
|
||||||
|
<PasswordInput
|
||||||
|
class="text-body"
|
||||||
|
:password="password"
|
||||||
|
:error="invalid ? $t('config.adminPassword.invalid') : ''"
|
||||||
|
@update:password="$emit('update:password', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { defineComponent } from "vue";
|
||||||
|
import PasswordInput from "./PasswordInput.vue";
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: "AdminPasswordPrompt",
|
||||||
|
components: { PasswordInput },
|
||||||
|
props: {
|
||||||
|
password: { type: String, default: "" },
|
||||||
|
invalid: Boolean,
|
||||||
|
},
|
||||||
|
emits: ["update:password"],
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
|
<slot name="before-test"></slot>
|
||||||
|
|
||||||
<TestResult
|
<TestResult
|
||||||
v-if="testState"
|
v-if="testState"
|
||||||
v-bind="testState"
|
v-bind="testState"
|
||||||
|
|
|
||||||
|
|
@ -179,6 +179,13 @@
|
||||||
@remove="handleRemove"
|
@remove="handleRemove"
|
||||||
@test="testManually"
|
@test="testManually"
|
||||||
>
|
>
|
||||||
|
<template #before-test>
|
||||||
|
<AdminPasswordPrompt
|
||||||
|
v-if="adminPasswordRequired"
|
||||||
|
v-model:password="adminPasswordValue"
|
||||||
|
:invalid="adminPasswordInvalid"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
<template #after-test>
|
<template #after-test>
|
||||||
<slot name="after-test" :values="values"></slot>
|
<slot name="after-test" :values="values"></slot>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -207,6 +214,7 @@ import AuthConnectButton from "../AuthConnectButton.vue";
|
||||||
import { initialTestState, performTest } from "../utils/test";
|
import { initialTestState, performTest } from "../utils/test";
|
||||||
import { reportValidityInModal } from "../utils/reportValidityInModal";
|
import { reportValidityInModal } from "../utils/reportValidityInModal";
|
||||||
import { initialAuthState, prepareAuthLogin } from "../utils/authProvider";
|
import { initialAuthState, prepareAuthLogin } from "../utils/authProvider";
|
||||||
|
import AdminPasswordPrompt from "@/components/Auth/AdminPasswordPrompt.vue";
|
||||||
import sleep from "@/utils/sleep";
|
import sleep from "@/utils/sleep";
|
||||||
import { ConfigType } from "@/types/evcc";
|
import { ConfigType } from "@/types/evcc";
|
||||||
import type { DeviceType, Timeout } from "@/types/evcc";
|
import type { DeviceType, Timeout } from "@/types/evcc";
|
||||||
|
|
@ -223,6 +231,7 @@ import {
|
||||||
applyDefaultsFromTemplate,
|
applyDefaultsFromTemplate,
|
||||||
createDeviceUtils,
|
createDeviceUtils,
|
||||||
fetchServiceValues,
|
fetchServiceValues,
|
||||||
|
ADMIN_PASSWORD_REQUIRED,
|
||||||
} from "./index";
|
} from "./index";
|
||||||
import deepEqual from "@/utils/deepEqual";
|
import deepEqual from "@/utils/deepEqual";
|
||||||
|
|
||||||
|
|
@ -244,6 +253,7 @@ export default defineComponent({
|
||||||
YamlEntry,
|
YamlEntry,
|
||||||
AuthCodeDisplay,
|
AuthCodeDisplay,
|
||||||
AuthConnectButton,
|
AuthConnectButton,
|
||||||
|
AdminPasswordPrompt,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
deviceType: { type: String as PropType<DeviceType>, required: true },
|
deviceType: { type: String as PropType<DeviceType>, required: true },
|
||||||
|
|
@ -313,6 +323,9 @@ export default defineComponent({
|
||||||
test: initialTestState(),
|
test: initialTestState(),
|
||||||
serviceValues: {} as Record<string, string[]>,
|
serviceValues: {} as Record<string, string[]>,
|
||||||
serviceValuesTimer: null as Timeout | null,
|
serviceValuesTimer: null as Timeout | null,
|
||||||
|
adminPasswordValue: "",
|
||||||
|
adminPasswordRequired: false,
|
||||||
|
adminPasswordInvalid: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
|
@ -546,10 +559,15 @@ export default defineComponent({
|
||||||
if (this.test.isError || this.test.isSuccess) {
|
if (this.test.isError || this.test.isSuccess) {
|
||||||
this.test = initialTestState();
|
this.test = initialTestState();
|
||||||
}
|
}
|
||||||
|
this.adminPasswordRequired = false;
|
||||||
|
this.adminPasswordInvalid = false;
|
||||||
this.updateServiceValues();
|
this.updateServiceValues();
|
||||||
},
|
},
|
||||||
deep: true,
|
deep: true,
|
||||||
},
|
},
|
||||||
|
adminPasswordValue() {
|
||||||
|
this.adminPasswordInvalid = false;
|
||||||
|
},
|
||||||
authValues: {
|
authValues: {
|
||||||
handler() {
|
handler() {
|
||||||
if (this.authRequired) {
|
if (this.authRequired) {
|
||||||
|
|
@ -699,22 +717,35 @@ export default defineComponent({
|
||||||
|
|
||||||
this.saving = true;
|
this.saving = true;
|
||||||
try {
|
try {
|
||||||
const { name } = await this.device.create(this.apiData, force);
|
const res = await this.device.create(this.apiData, force, this.adminPasswordValue);
|
||||||
|
this.applyAdminPasswordState(res.status);
|
||||||
|
if (res.status === ADMIN_PASSWORD_REQUIRED) {
|
||||||
|
this.saving = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.saving = false;
|
this.saving = false;
|
||||||
this.succeeded = true;
|
this.succeeded = true;
|
||||||
await sleep(500);
|
await sleep(500);
|
||||||
this.$emit("added", name);
|
this.$emit("added", res.data.name);
|
||||||
await closeModal({ action: "added", name });
|
await closeModal({ action: "added", name: res.data.name });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
handleError(e, "create failed");
|
|
||||||
this.saving = false;
|
this.saving = false;
|
||||||
|
handleError(e, "create failed");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async testManually() {
|
async testManually() {
|
||||||
await performTest(this.test, this.testDevice, this.$refs["form"] as HTMLFormElement);
|
await performTest(this.test, this.testDevice, this.$refs["form"] as HTMLFormElement);
|
||||||
},
|
},
|
||||||
async testDevice() {
|
async testDevice() {
|
||||||
return this.device.test(this.id, this.apiData);
|
const res = await this.device.test(this.id, this.apiData, this.adminPasswordValue);
|
||||||
|
this.applyAdminPasswordState(res.status);
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
// reveal the admin password field when required, flag it invalid if a password was already sent
|
||||||
|
applyAdminPasswordState(status: number) {
|
||||||
|
this.adminPasswordRequired = status === ADMIN_PASSWORD_REQUIRED;
|
||||||
|
this.adminPasswordInvalid =
|
||||||
|
status === ADMIN_PASSWORD_REQUIRED && !!this.adminPasswordValue;
|
||||||
},
|
},
|
||||||
async update(force = false) {
|
async update(force = false) {
|
||||||
if (this.test.isUnknown && !force) {
|
if (this.test.isUnknown && !force) {
|
||||||
|
|
@ -729,16 +760,26 @@ export default defineComponent({
|
||||||
}
|
}
|
||||||
this.saving = true;
|
this.saving = true;
|
||||||
try {
|
try {
|
||||||
await this.device.update(this.id!, this.apiData, force);
|
const res = await this.device.update(
|
||||||
|
this.id!,
|
||||||
|
this.apiData,
|
||||||
|
force,
|
||||||
|
this.adminPasswordValue
|
||||||
|
);
|
||||||
|
this.applyAdminPasswordState(res.status);
|
||||||
|
if (res.status === ADMIN_PASSWORD_REQUIRED) {
|
||||||
|
this.saving = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.saving = false;
|
this.saving = false;
|
||||||
this.succeeded = true;
|
this.succeeded = true;
|
||||||
await sleep(500);
|
await sleep(500);
|
||||||
this.$emit("updated");
|
this.$emit("updated");
|
||||||
await closeModal({ action: "updated" });
|
await closeModal({ action: "updated" });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
this.saving = false;
|
||||||
console.error("update failed", e);
|
console.error("update failed", e);
|
||||||
handleError(e, "update failed");
|
handleError(e, "update failed");
|
||||||
this.saving = false;
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async remove() {
|
async remove() {
|
||||||
|
|
@ -752,6 +793,8 @@ export default defineComponent({
|
||||||
},
|
},
|
||||||
handleOpen() {
|
handleOpen() {
|
||||||
this.isModalVisible = true;
|
this.isModalVisible = true;
|
||||||
|
this.adminPasswordRequired = false;
|
||||||
|
this.adminPasswordInvalid = false;
|
||||||
},
|
},
|
||||||
handleClose() {
|
handleClose() {
|
||||||
this.$emit("close");
|
this.$emit("close");
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,16 @@ import { ConfigType } from "@/types/evcc";
|
||||||
import api from "@/api";
|
import api from "@/api";
|
||||||
import { extractPlaceholders, replacePlaceholders } from "@/utils/placeholder";
|
import { extractPlaceholders, replacePlaceholders } from "@/utils/placeholder";
|
||||||
|
|
||||||
|
// config write needs the admin password (script plugin)
|
||||||
|
export const ADMIN_PASSWORD_REQUIRED = 428;
|
||||||
|
|
||||||
|
const allowAdminPasswordRequired = (status: number) =>
|
||||||
|
(status >= 200 && status < 300) || status === ADMIN_PASSWORD_REQUIRED;
|
||||||
|
|
||||||
|
function adminPasswordHeader(adminPassword = ""): Record<string, string> {
|
||||||
|
return adminPassword ? { "X-Admin-Password": adminPassword } : {};
|
||||||
|
}
|
||||||
|
|
||||||
export type Product = {
|
export type Product = {
|
||||||
group: string;
|
group: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -189,17 +199,25 @@ export const fetchServiceValues = async (
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createDeviceUtils(deviceType: DeviceType) {
|
export function createDeviceUtils(deviceType: DeviceType) {
|
||||||
function test(id: number | undefined, data: any) {
|
function test(id: number | undefined, data: any, adminPassword = "") {
|
||||||
let url = `config/test/${deviceType}`;
|
let url = `config/test/${deviceType}`;
|
||||||
if (id !== undefined) {
|
if (id !== undefined) {
|
||||||
url += `/merge/${id}`;
|
url += `/merge/${id}`;
|
||||||
}
|
}
|
||||||
return api.post(url, data);
|
const opts = {
|
||||||
|
headers: adminPasswordHeader(adminPassword),
|
||||||
|
validateStatus: allowAdminPasswordRequired,
|
||||||
|
};
|
||||||
|
return api.post(url, data, opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
function update(id: number, data: any, force = false) {
|
function update(id: number, data: any, force = false, adminPassword = "") {
|
||||||
const params = { force };
|
const opts = {
|
||||||
return api.put(`config/devices/${deviceType}/${id}`, data, { params });
|
headers: adminPasswordHeader(adminPassword),
|
||||||
|
validateStatus: allowAdminPasswordRequired,
|
||||||
|
params: { force },
|
||||||
|
};
|
||||||
|
return api.put(`config/devices/${deviceType}/${id}`, data, opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
function remove(id: number) {
|
function remove(id: number) {
|
||||||
|
|
@ -211,10 +229,13 @@ export function createDeviceUtils(deviceType: DeviceType) {
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function create(data: any, force = false) {
|
function create(data: any, force = false, adminPassword = "") {
|
||||||
const params = { force };
|
const opts = {
|
||||||
const response = await api.post(`config/devices/${deviceType}`, data, { params });
|
headers: adminPasswordHeader(adminPassword),
|
||||||
return response.data;
|
validateStatus: allowAdminPasswordRequired,
|
||||||
|
params: { force },
|
||||||
|
};
|
||||||
|
return api.post(`config/devices/${deviceType}`, data, opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadProducts(lang?: string, usage?: string) {
|
async function loadProducts(lang?: string, usage?: string) {
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,6 @@ export default {
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.editor {
|
.editor {
|
||||||
border: 1px solid var(--bs-border-color);
|
border: 1px solid var(--bs-border-color);
|
||||||
min-height: 150px;
|
|
||||||
}
|
}
|
||||||
.editor-loading {
|
.editor-loading {
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
|
|
|
||||||
|
|
@ -47,17 +47,7 @@ export default {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@import "../../../css/breakpoints.css";
|
|
||||||
|
|
||||||
.editor-container {
|
.editor-container {
|
||||||
width: 100%;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
margin: 0 -1rem 0 -1.25rem;
|
|
||||||
}
|
|
||||||
/* reset margins on lg */
|
|
||||||
@media (--lg-and-up) {
|
|
||||||
.editor-container {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import type { AxiosResponse } from "axios";
|
import type { AxiosResponse } from "axios";
|
||||||
import sleep from "@/utils/sleep";
|
import sleep from "@/utils/sleep";
|
||||||
|
import { ADMIN_PASSWORD_REQUIRED } from "../DeviceModal/index";
|
||||||
import { reportValidityInModal } from "./reportValidityInModal";
|
import { reportValidityInModal } from "./reportValidityInModal";
|
||||||
|
|
||||||
export type TestState = {
|
export type TestState = {
|
||||||
|
|
@ -36,6 +37,10 @@ export const performTest = async (
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
try {
|
try {
|
||||||
const res = await api();
|
const res = await api();
|
||||||
|
if (res.status === ADMIN_PASSWORD_REQUIRED) {
|
||||||
|
state.isUnknown = true; // not testable until the admin password is provided
|
||||||
|
return false;
|
||||||
|
}
|
||||||
state.isError = false;
|
state.isError = false;
|
||||||
state.error = null;
|
state.error = null;
|
||||||
state.errorLine = null;
|
state.errorLine = null;
|
||||||
|
|
|
||||||
|
|
@ -88,11 +88,21 @@ achieves the same effect (the previous key stops working immediately).
|
||||||
| State / read-only / basic charging control | Public | |
|
| State / read-only / basic charging control | Public | |
|
||||||
| Set or update admin password | Public | admin password |
|
| Set or update admin password | Public | admin password |
|
||||||
| Configuration | Secure | |
|
| Configuration | Secure | |
|
||||||
|
| Configuration embedding a script plugin | Critical | api key or admin password |
|
||||||
| System: logs, cache, shutdown | Secure | |
|
| System: logs, cache, shutdown | Secure | |
|
||||||
| API key status | Secure | |
|
| API key status | Secure | |
|
||||||
| System: backup / restore / reset | Critical | api key or admin password |
|
| System: backup / restore / reset | Critical | api key or admin password |
|
||||||
| API key regenerate | Critical | admin password |
|
| API key regenerate | Critical | admin password |
|
||||||
|
|
||||||
|
Device test, create, and update (`/api/config/test/{class}` and `/api/config/devices/{class}`)
|
||||||
|
instantiate a config immediately, so a `script` plugin in the payload runs a shell command on the
|
||||||
|
server. Because that command could read credentials a session is not otherwise allowed to see (for
|
||||||
|
example the contents of the database), these requests are treated as Critical when the config embeds
|
||||||
|
a script plugin, at any nesting depth. A session caller must supply the admin password in the
|
||||||
|
`X-Admin-Password` header; an API-key caller passes without it. The `go` (yaegi) and `js` (otto)
|
||||||
|
plugins are excluded: their interpreters are sandboxed to pure computation and cannot read files,
|
||||||
|
spawn processes, or open network connections.
|
||||||
|
|
||||||
**Public** endpoints accept any caller. **Secure** endpoints require a
|
**Public** endpoints accept any caller. **Secure** endpoints require a
|
||||||
valid session (cookie or API key). **Critical** endpoints require extra
|
valid session (cookie or API key). **Critical** endpoints require extra
|
||||||
authentication in the form of an admin password (or, for some, an API
|
authentication in the form of an admin password (or, for some, an API
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,11 @@
|
||||||
"hex": "Hex-Farbe"
|
"hex": "Hex-Farbe"
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
|
"adminPassword": {
|
||||||
|
"description": "Das Skript-Plugin führt einen Befehl auf Systemebene aus, der mit den Rechten des evcc-Prozesses läuft. Gib dein Administrator-Passwort erneut ein, um fortzufahren.",
|
||||||
|
"invalid": "Ungültiges Passwort. Bitte versuche es erneut.",
|
||||||
|
"title": "Potenziell gefährliche Aktion"
|
||||||
|
},
|
||||||
"apiKey": {
|
"apiKey": {
|
||||||
"description": "Gibt Skripten und Automatisierungsaufgaben wie geplanten Backups sicheren Zugriff, ohne dein Administrator-Passwort zu teilen.",
|
"description": "Gibt Skripten und Automatisierungsaufgaben wie geplanten Backups sicheren Zugriff, ohne dein Administrator-Passwort zu teilen.",
|
||||||
"exampleLabel": "Ausprobieren: Backup mit curl herunterladen",
|
"exampleLabel": "Ausprobieren: Backup mit curl herunterladen",
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,11 @@
|
||||||
"hex": "Hex color"
|
"hex": "Hex color"
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
|
"adminPassword": {
|
||||||
|
"description": "The script plugin executes a system-level command that runs with the permissions of the evcc process. Re-enter your admin password to continue.",
|
||||||
|
"invalid": "Invalid password. Please try again.",
|
||||||
|
"title": "Potentially dangerous operation"
|
||||||
|
},
|
||||||
"apiKey": {
|
"apiKey": {
|
||||||
"description": "Give scripts and automation tasks like scheduled backups secure access without sharing your admin password.",
|
"description": "Give scripts and automation tasks like scheduled backups secure access without sharing your admin password.",
|
||||||
"exampleLabel": "Try it: download a backup with curl",
|
"exampleLabel": "Try it: download a backup with curl",
|
||||||
|
|
|
||||||
|
|
@ -298,11 +298,11 @@ func (s *HTTPd) RegisterSystemHandler(site *core.Site, pub publisher, cache *uti
|
||||||
"devicestatus": {"GET", "/devices/{class:[a-z]+}/{name:[a-zA-Z0-9_.:-]+}/status", deviceStatusHandler},
|
"devicestatus": {"GET", "/devices/{class:[a-z]+}/{name:[a-zA-Z0-9_.:-]+}/status", deviceStatusHandler},
|
||||||
"dirty": {"GET", "/dirty", getHandler(ConfigDirty)},
|
"dirty": {"GET", "/dirty", getHandler(ConfigDirty)},
|
||||||
"evccyaml": {"GET", "/evcc.yaml", configYamlHandler(configFile)},
|
"evccyaml": {"GET", "/evcc.yaml", configYamlHandler(configFile)},
|
||||||
"newdevice": {"POST", "/devices/{class:[a-z]+}", newDeviceHandler},
|
"newdevice": {"POST", "/devices/{class:[a-z]+}", newDeviceHandler(auth)},
|
||||||
"updatedevice": {"PUT", "/devices/{class:[a-z]+}/{id:[0-9.]+}", updateDeviceHandler},
|
"updatedevice": {"PUT", "/devices/{class:[a-z]+}/{id:[0-9.]+}", updateDeviceHandler(auth)},
|
||||||
"deletedevice": {"DELETE", "/devices/{class:[a-z]+}/{id:[0-9.]+}", deleteDeviceHandler(site)},
|
"deletedevice": {"DELETE", "/devices/{class:[a-z]+}/{id:[0-9.]+}", deleteDeviceHandler(site)},
|
||||||
"testconfig": {"POST", "/test/{class:[a-z]+}", testConfigHandler},
|
"testconfig": {"POST", "/test/{class:[a-z]+}", testConfigHandler(auth)},
|
||||||
"testmerged": {"POST", "/test/{class:[a-z]+}/merge/{id:[0-9.]+}", testConfigHandler},
|
"testmerged": {"POST", "/test/{class:[a-z]+}/merge/{id:[0-9.]+}", testConfigHandler(auth)},
|
||||||
"interval": {"POST", "/interval/{value:[0-9.]+}", settingsSetDurationHandler(keys.Interval, pub)},
|
"interval": {"POST", "/interval/{value:[0-9.]+}", settingsSetDurationHandler(keys.Interval, pub)},
|
||||||
"updatesponsortoken": {"POST", "/sponsortoken", updateSponsortokenHandler(pub)},
|
"updatesponsortoken": {"POST", "/sponsortoken", updateSponsortokenHandler(pub)},
|
||||||
"deletesponsortoken": {"DELETE", "/sponsortoken", deleteSponsorTokenHandler(pub)},
|
"deletesponsortoken": {"DELETE", "/sponsortoken", deleteSponsorTokenHandler(pub)},
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -241,6 +242,21 @@ func regenerateApiKeyHandler(authObject auth.Auth) http.HandlerFunc {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// requireCriticalConfigAuth guards script-plugin configs: API key passes; session users must supply the admin password.
|
||||||
|
func requireCriticalConfigAuth(w http.ResponseWriter, r *http.Request, authObject auth.Auth, req configReq) bool {
|
||||||
|
if authObject.GetAuthMode() == auth.Disabled || !configHasCriticalPlugin(req) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if key := apiKeyFromRequest(r); key != "" && authObject.ValidateApiKey(key) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if !authObject.IsAdminPasswordValid(r.Header.Get("X-Admin-Password")) {
|
||||||
|
jsonError(w, http.StatusPreconditionRequired, errors.New("admin password required"))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// ensureDbAuth guards /db/ endpoints: API key Bearer passes directly;
|
// ensureDbAuth guards /db/ endpoints: API key Bearer passes directly;
|
||||||
// session users must also supply the admin password in X-Admin-Password header.
|
// session users must also supply the admin password in X-Admin-Password header.
|
||||||
func ensureDbAuth(authObject auth.Auth) mux.MiddlewareFunc {
|
func ensureDbAuth(authObject auth.Auth) mux.MiddlewareFunc {
|
||||||
|
|
|
||||||
74
server/http_auth_test.go
Normal file
74
server/http_auth_test.go
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/evcc-io/evcc/util/auth"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeAuth is a minimal auth.Auth stub for gate tests.
|
||||||
|
type fakeAuth struct {
|
||||||
|
mode auth.AuthMode
|
||||||
|
password string
|
||||||
|
apiKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f fakeAuth) GetAuthMode() auth.AuthMode { return f.mode }
|
||||||
|
func (f fakeAuth) IsAdminPasswordValid(pw string) bool { return pw != "" && pw == f.password }
|
||||||
|
func (f fakeAuth) ValidateApiKey(key string) bool { return key != "" && key == f.apiKey }
|
||||||
|
func (f fakeAuth) SetAuthMode(auth.AuthMode) {}
|
||||||
|
func (f fakeAuth) RemoveAdminPassword() {}
|
||||||
|
func (f fakeAuth) SetAdminPassword(string) error { return nil }
|
||||||
|
func (f fakeAuth) GenerateJwtToken(time.Duration) (string, error) { return "", nil }
|
||||||
|
func (f fakeAuth) ValidateJwtToken(string) bool { return true }
|
||||||
|
func (f fakeAuth) IsAdminPasswordConfigured() bool { return f.password != "" }
|
||||||
|
func (f fakeAuth) SetApiKey() (string, error) { return "", nil }
|
||||||
|
func (f fakeAuth) IsApiKeyConfigured() bool { return f.apiKey != "" }
|
||||||
|
|
||||||
|
func TestRequireCriticalConfig(t *testing.T) {
|
||||||
|
const pw = "secret"
|
||||||
|
const key = "evcc_token"
|
||||||
|
scriptReq := configReq{Yaml: "power:\n source: script\n cmd: echo 1"}
|
||||||
|
benignReq := configReq{Yaml: "power:\n source: http\n uri: http://localhost"}
|
||||||
|
|
||||||
|
base := fakeAuth{mode: auth.Enabled, password: pw, apiKey: key}
|
||||||
|
|
||||||
|
tc := []struct {
|
||||||
|
name string
|
||||||
|
req configReq
|
||||||
|
header map[string]string
|
||||||
|
mode auth.AuthMode
|
||||||
|
ok bool
|
||||||
|
}{
|
||||||
|
{"no critical plugin passes", benignReq, nil, auth.Enabled, true},
|
||||||
|
{"disabled mode passes", scriptReq, nil, auth.Disabled, true},
|
||||||
|
{"session without password rejected", scriptReq, nil, auth.Enabled, false},
|
||||||
|
{"session with wrong password rejected", scriptReq, map[string]string{"X-Admin-Password": "nope"}, auth.Enabled, false},
|
||||||
|
{"session with valid password passes", scriptReq, map[string]string{"X-Admin-Password": pw}, auth.Enabled, true},
|
||||||
|
{"valid api key passes without password", scriptReq, map[string]string{"Authorization": "Bearer " + key}, auth.Enabled, true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tc {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
a := base
|
||||||
|
a.mode = tc.mode
|
||||||
|
|
||||||
|
r := httptest.NewRequest(http.MethodPost, "/api/config/test/meter", nil)
|
||||||
|
for k, v := range tc.header {
|
||||||
|
r.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
ok := requireCriticalConfigAuth(w, r, a, tc.req)
|
||||||
|
|
||||||
|
assert.Equal(t, tc.ok, ok)
|
||||||
|
if !tc.ok {
|
||||||
|
assert.Equal(t, http.StatusPreconditionRequired, w.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -21,6 +21,7 @@ import (
|
||||||
"github.com/evcc-io/evcc/meter"
|
"github.com/evcc-io/evcc/meter"
|
||||||
"github.com/evcc-io/evcc/server/db/settings"
|
"github.com/evcc-io/evcc/server/db/settings"
|
||||||
"github.com/evcc-io/evcc/tariff"
|
"github.com/evcc-io/evcc/tariff"
|
||||||
|
"github.com/evcc-io/evcc/util/auth"
|
||||||
"github.com/evcc-io/evcc/util/config"
|
"github.com/evcc-io/evcc/util/config"
|
||||||
"github.com/evcc-io/evcc/util/templates"
|
"github.com/evcc-io/evcc/util/templates"
|
||||||
"github.com/evcc-io/evcc/vehicle"
|
"github.com/evcc-io/evcc/vehicle"
|
||||||
|
|
@ -308,66 +309,72 @@ func newDevice[T any](ctx context.Context, class templates.Class, req configReq,
|
||||||
}
|
}
|
||||||
|
|
||||||
// newDeviceHandler creates a new device by class
|
// newDeviceHandler creates a new device by class
|
||||||
func newDeviceHandler(w http.ResponseWriter, r *http.Request) {
|
func newDeviceHandler(authObject auth.Auth) http.HandlerFunc {
|
||||||
vars := mux.Vars(r)
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
|
||||||
class, err := templates.ClassString(vars["class"])
|
class, err := templates.ClassString(vars["class"])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
jsonError(w, http.StatusBadRequest, err)
|
jsonError(w, http.StatusBadRequest, err)
|
||||||
return
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := decodeDeviceConfig(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, http.StatusBadRequest, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !requireCriticalConfigAuth(w, r, authObject, req) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var conf *config.Config
|
||||||
|
ctx, cancel, done := startDeviceTimeout()
|
||||||
|
|
||||||
|
force := r.URL.Query().Get("force") == "true"
|
||||||
|
|
||||||
|
switch class {
|
||||||
|
case templates.Charger:
|
||||||
|
conf, err = newDevice(ctx, class, req, charger.NewFromConfig, config.Chargers(), force)
|
||||||
|
|
||||||
|
case templates.Meter:
|
||||||
|
conf, err = newDevice(ctx, class, req, meter.NewFromConfig, config.Meters(), force)
|
||||||
|
|
||||||
|
case templates.Vehicle:
|
||||||
|
conf, err = newDevice(ctx, class, req, vehicle.NewFromConfig, config.Vehicles(), force)
|
||||||
|
|
||||||
|
case templates.Circuit:
|
||||||
|
conf, err = newDevice(ctx, class, req, circuit.NewFromConfig, config.Circuits(), force)
|
||||||
|
|
||||||
|
case templates.Tariff:
|
||||||
|
conf, err = newDevice(ctx, class, req, tariff.NewFromConfig, config.Tariffs(), force)
|
||||||
|
|
||||||
|
case templates.Messenger:
|
||||||
|
conf, err = newDevice(ctx, class, req, messenger.NewFromConfig, config.Messengers(), force)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
cancel()
|
||||||
|
jsonError(w, http.StatusBadRequest, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// prevent context from being cancelled
|
||||||
|
close(done)
|
||||||
|
|
||||||
|
setConfigDirty()
|
||||||
|
|
||||||
|
res := struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}{
|
||||||
|
ID: conf.ID,
|
||||||
|
Name: config.NameForID(conf.ID),
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonWrite(w, res)
|
||||||
}
|
}
|
||||||
|
|
||||||
req, err := decodeDeviceConfig(r.Body)
|
|
||||||
if err != nil {
|
|
||||||
jsonError(w, http.StatusBadRequest, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var conf *config.Config
|
|
||||||
ctx, cancel, done := startDeviceTimeout()
|
|
||||||
|
|
||||||
force := r.URL.Query().Get("force") == "true"
|
|
||||||
|
|
||||||
switch class {
|
|
||||||
case templates.Charger:
|
|
||||||
conf, err = newDevice(ctx, class, req, charger.NewFromConfig, config.Chargers(), force)
|
|
||||||
|
|
||||||
case templates.Meter:
|
|
||||||
conf, err = newDevice(ctx, class, req, meter.NewFromConfig, config.Meters(), force)
|
|
||||||
|
|
||||||
case templates.Vehicle:
|
|
||||||
conf, err = newDevice(ctx, class, req, vehicle.NewFromConfig, config.Vehicles(), force)
|
|
||||||
|
|
||||||
case templates.Circuit:
|
|
||||||
conf, err = newDevice(ctx, class, req, circuit.NewFromConfig, config.Circuits(), force)
|
|
||||||
|
|
||||||
case templates.Tariff:
|
|
||||||
conf, err = newDevice(ctx, class, req, tariff.NewFromConfig, config.Tariffs(), force)
|
|
||||||
|
|
||||||
case templates.Messenger:
|
|
||||||
conf, err = newDevice(ctx, class, req, messenger.NewFromConfig, config.Messengers(), force)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
cancel()
|
|
||||||
jsonError(w, http.StatusBadRequest, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// prevent context from being cancelled
|
|
||||||
close(done)
|
|
||||||
|
|
||||||
setConfigDirty()
|
|
||||||
|
|
||||||
res := struct {
|
|
||||||
ID int `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
}{
|
|
||||||
ID: conf.ID,
|
|
||||||
Name: config.NameForID(conf.ID),
|
|
||||||
}
|
|
||||||
|
|
||||||
jsonWrite(w, res)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateDevice[T any](ctx context.Context, id int, class templates.Class, req configReq, newFromConf newFromConfFunc[T], h config.Handler[T], force bool) error {
|
func updateDevice[T any](ctx context.Context, id int, class templates.Class, req configReq, newFromConf newFromConfFunc[T], h config.Handler[T], force bool) error {
|
||||||
|
|
@ -388,69 +395,75 @@ func updateDevice[T any](ctx context.Context, id int, class templates.Class, req
|
||||||
}
|
}
|
||||||
|
|
||||||
// updateDeviceHandler updates database device's configuration by class
|
// updateDeviceHandler updates database device's configuration by class
|
||||||
func updateDeviceHandler(w http.ResponseWriter, r *http.Request) {
|
func updateDeviceHandler(authObject auth.Auth) http.HandlerFunc {
|
||||||
vars := mux.Vars(r)
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
|
||||||
class, err := templates.ClassString(vars["class"])
|
class, err := templates.ClassString(vars["class"])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
jsonError(w, http.StatusBadRequest, err)
|
jsonError(w, http.StatusBadRequest, err)
|
||||||
return
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := strconv.Atoi(vars["id"])
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, http.StatusBadRequest, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := decodeDeviceConfig(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, http.StatusBadRequest, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !requireCriticalConfigAuth(w, r, authObject, req) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel, done := startDeviceTimeout()
|
||||||
|
|
||||||
|
force := r.URL.Query().Get("force") == "true"
|
||||||
|
|
||||||
|
switch class {
|
||||||
|
case templates.Charger:
|
||||||
|
err = updateDevice(ctx, id, class, req, charger.NewFromConfig, config.Chargers(), force)
|
||||||
|
|
||||||
|
case templates.Meter:
|
||||||
|
err = updateDevice(ctx, id, class, req, meter.NewFromConfig, config.Meters(), force)
|
||||||
|
|
||||||
|
case templates.Vehicle:
|
||||||
|
err = updateDevice(ctx, id, class, req, vehicle.NewFromConfig, config.Vehicles(), force)
|
||||||
|
|
||||||
|
case templates.Circuit:
|
||||||
|
err = updateDevice(ctx, id, class, req, circuit.NewFromConfig, config.Circuits(), force)
|
||||||
|
|
||||||
|
case templates.Tariff:
|
||||||
|
err = updateDevice(ctx, id, class, req, tariff.NewFromConfig, config.Tariffs(), force)
|
||||||
|
|
||||||
|
case templates.Messenger:
|
||||||
|
err = updateDevice(ctx, id, class, req, messenger.NewFromConfig, config.Messengers(), force)
|
||||||
|
}
|
||||||
|
|
||||||
|
setConfigDirty()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
cancel()
|
||||||
|
jsonError(w, http.StatusBadRequest, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// prevent context from being cancelled
|
||||||
|
close(done)
|
||||||
|
|
||||||
|
res := struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
}{
|
||||||
|
ID: id,
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonWrite(w, res)
|
||||||
}
|
}
|
||||||
|
|
||||||
id, err := strconv.Atoi(vars["id"])
|
|
||||||
if err != nil {
|
|
||||||
jsonError(w, http.StatusBadRequest, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := decodeDeviceConfig(r.Body)
|
|
||||||
if err != nil {
|
|
||||||
jsonError(w, http.StatusBadRequest, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, cancel, done := startDeviceTimeout()
|
|
||||||
|
|
||||||
force := r.URL.Query().Get("force") == "true"
|
|
||||||
|
|
||||||
switch class {
|
|
||||||
case templates.Charger:
|
|
||||||
err = updateDevice(ctx, id, class, req, charger.NewFromConfig, config.Chargers(), force)
|
|
||||||
|
|
||||||
case templates.Meter:
|
|
||||||
err = updateDevice(ctx, id, class, req, meter.NewFromConfig, config.Meters(), force)
|
|
||||||
|
|
||||||
case templates.Vehicle:
|
|
||||||
err = updateDevice(ctx, id, class, req, vehicle.NewFromConfig, config.Vehicles(), force)
|
|
||||||
|
|
||||||
case templates.Circuit:
|
|
||||||
err = updateDevice(ctx, id, class, req, circuit.NewFromConfig, config.Circuits(), force)
|
|
||||||
|
|
||||||
case templates.Tariff:
|
|
||||||
err = updateDevice(ctx, id, class, req, tariff.NewFromConfig, config.Tariffs(), force)
|
|
||||||
|
|
||||||
case templates.Messenger:
|
|
||||||
err = updateDevice(ctx, id, class, req, messenger.NewFromConfig, config.Messengers(), force)
|
|
||||||
}
|
|
||||||
|
|
||||||
setConfigDirty()
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
cancel()
|
|
||||||
jsonError(w, http.StatusBadRequest, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// prevent context from being cancelled
|
|
||||||
close(done)
|
|
||||||
|
|
||||||
res := struct {
|
|
||||||
ID int `json:"id"`
|
|
||||||
}{
|
|
||||||
ID: id,
|
|
||||||
}
|
|
||||||
|
|
||||||
jsonWrite(w, res)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func configurableDevice[T any](name string, h config.Handler[T]) (config.ConfigurableDevice[T], error) {
|
func configurableDevice[T any](name string, h config.Handler[T]) (config.ConfigurableDevice[T], error) {
|
||||||
|
|
@ -644,67 +657,73 @@ func testConfig[T any](ctx context.Context, id int, class templates.Class, req c
|
||||||
}
|
}
|
||||||
|
|
||||||
// testConfigHandler tests a configuration by class
|
// testConfigHandler tests a configuration by class
|
||||||
func testConfigHandler(w http.ResponseWriter, r *http.Request) {
|
func testConfigHandler(authObject auth.Auth) http.HandlerFunc {
|
||||||
vars := mux.Vars(r)
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
|
||||||
class, err := templates.ClassString(vars["class"])
|
class, err := templates.ClassString(vars["class"])
|
||||||
if err != nil {
|
|
||||||
jsonError(w, http.StatusBadRequest, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var id int
|
|
||||||
if vars["id"] != "" {
|
|
||||||
// test existing device with updated config
|
|
||||||
id, err = strconv.Atoi(vars["id"])
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
jsonError(w, http.StatusBadRequest, err)
|
jsonError(w, http.StatusBadRequest, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var id int
|
||||||
|
if vars["id"] != "" {
|
||||||
|
// test existing device with updated config
|
||||||
|
id, err = strconv.Atoi(vars["id"])
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, http.StatusBadRequest, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := decodeDeviceConfig(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, http.StatusBadRequest, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !requireCriticalConfigAuth(w, r, authObject, req) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var instance any
|
||||||
|
ctx, cancel, done := startDeviceTimeout()
|
||||||
|
|
||||||
|
switch class {
|
||||||
|
case templates.Charger:
|
||||||
|
instance, err = testConfig(ctx, id, class, req, charger.NewFromConfig, config.Chargers())
|
||||||
|
|
||||||
|
case templates.Meter:
|
||||||
|
instance, err = testConfig(ctx, id, class, req, meter.NewFromConfig, config.Meters())
|
||||||
|
|
||||||
|
case templates.Vehicle:
|
||||||
|
instance, err = testConfig(ctx, id, class, req, vehicle.NewFromConfig, config.Vehicles())
|
||||||
|
|
||||||
|
case templates.Circuit:
|
||||||
|
instance, err = testConfig(ctx, id, class, req, circuit.NewFromConfig, config.Circuits())
|
||||||
|
|
||||||
|
case templates.Tariff:
|
||||||
|
instance, err = testConfig(ctx, id, class, req, tariff.NewFromConfig, config.Tariffs())
|
||||||
|
|
||||||
|
case templates.Messenger:
|
||||||
|
instance, err = testConfig(ctx, id, class, req, messenger.NewFromConfig, config.Messengers())
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
cancel()
|
||||||
|
jsonError(w, http.StatusBadRequest, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// prevent context from being cancelled during test
|
||||||
|
close(done)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// bound the value-probe phase so a blocking getter cannot stall the response
|
||||||
|
probeCtx, probeCancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||||
|
defer probeCancel()
|
||||||
|
|
||||||
|
jsonWrite(w, testInstance(probeCtx, instance))
|
||||||
}
|
}
|
||||||
|
|
||||||
req, err := decodeDeviceConfig(r.Body)
|
|
||||||
if err != nil {
|
|
||||||
jsonError(w, http.StatusBadRequest, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var instance any
|
|
||||||
ctx, cancel, done := startDeviceTimeout()
|
|
||||||
|
|
||||||
switch class {
|
|
||||||
case templates.Charger:
|
|
||||||
instance, err = testConfig(ctx, id, class, req, charger.NewFromConfig, config.Chargers())
|
|
||||||
|
|
||||||
case templates.Meter:
|
|
||||||
instance, err = testConfig(ctx, id, class, req, meter.NewFromConfig, config.Meters())
|
|
||||||
|
|
||||||
case templates.Vehicle:
|
|
||||||
instance, err = testConfig(ctx, id, class, req, vehicle.NewFromConfig, config.Vehicles())
|
|
||||||
|
|
||||||
case templates.Circuit:
|
|
||||||
instance, err = testConfig(ctx, id, class, req, circuit.NewFromConfig, config.Circuits())
|
|
||||||
|
|
||||||
case templates.Tariff:
|
|
||||||
instance, err = testConfig(ctx, id, class, req, tariff.NewFromConfig, config.Tariffs())
|
|
||||||
|
|
||||||
case templates.Messenger:
|
|
||||||
instance, err = testConfig(ctx, id, class, req, messenger.NewFromConfig, config.Messengers())
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
cancel()
|
|
||||||
jsonError(w, http.StatusBadRequest, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// prevent context from being cancelled during test
|
|
||||||
close(done)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
// bound the value-probe phase so a blocking getter cannot stall the response
|
|
||||||
probeCtx, probeCancel := context.WithTimeout(r.Context(), 10*time.Second)
|
|
||||||
defer probeCancel()
|
|
||||||
|
|
||||||
jsonWrite(w, testInstance(probeCtx, instance))
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -528,6 +528,38 @@ func (maskedTransformer) Transformer(typ reflect.Type) func(dst, src reflect.Val
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var criticalPluginSources = []string{"script"}
|
||||||
|
|
||||||
|
func configHasCriticalPlugin(req configReq) bool {
|
||||||
|
if req.Yaml != "" {
|
||||||
|
var m map[string]any
|
||||||
|
if err := yaml.Unmarshal([]byte(req.Yaml), &m); err != nil {
|
||||||
|
return false // malformed yaml already rejected by decodeDeviceConfig
|
||||||
|
}
|
||||||
|
return valueHasCriticalSource(m)
|
||||||
|
}
|
||||||
|
return valueHasCriticalSource(req.Other)
|
||||||
|
}
|
||||||
|
|
||||||
|
func valueHasCriticalSource(v any) bool {
|
||||||
|
switch t := v.(type) {
|
||||||
|
case map[string]any:
|
||||||
|
for k, val := range t {
|
||||||
|
if strings.EqualFold(k, "source") {
|
||||||
|
if s, ok := val.(string); ok && slices.Contains(criticalPluginSources, strings.ToLower(strings.TrimSpace(s))) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if valueHasCriticalSource(val) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case []any:
|
||||||
|
return slices.ContainsFunc(t, valueHasCriticalSource)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// decodeDeviceConfig extracts device configuration and yaml details
|
// decodeDeviceConfig extracts device configuration and yaml details
|
||||||
func decodeDeviceConfig(r io.Reader) (configReq, error) {
|
func decodeDeviceConfig(r io.Reader) (configReq, error) {
|
||||||
var res configReq
|
var res configReq
|
||||||
|
|
|
||||||
|
|
@ -210,6 +210,38 @@ func TestMergeMaskedFiltersBehavior(t *testing.T) {
|
||||||
assert.NotContains(t, result, "outdatedField")
|
assert.NotContains(t, result, "outdatedField")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestConfigHasCriticalPlugin(t *testing.T) {
|
||||||
|
tc := []struct {
|
||||||
|
name string
|
||||||
|
yaml string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"script top level", "power:\n source: script\n cmd: echo 1", true},
|
||||||
|
{"script case insensitive", "power:\n Source: SCRIPT\n cmd: echo 1", true},
|
||||||
|
{"script nested in calc", "power:\n source: calc\n add:\n - source: const\n value: 1\n - source: script\n cmd: echo 1", true},
|
||||||
|
{"script nested in sequence set", "power:\n source: sequence\n set:\n - source: script\n cmd: echo 1", true},
|
||||||
|
{"script nested in js transformation", "power:\n source: js\n script: x\n in:\n - name: x\n type: float\n source: script\n cmd: echo 1", true},
|
||||||
|
{"js without script", "power:\n source: js\n script: \"x = 1\"", false},
|
||||||
|
{"go without script", "power:\n source: go\n script: \"return 1\"", false},
|
||||||
|
{"http without script", "power:\n source: http\n uri: http://localhost", false},
|
||||||
|
{"plain template", "power: 100", false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tc {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
assert.Equal(t, tc.want, configHasCriticalPlugin(configReq{Yaml: tc.yaml}))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// non-yaml custom config carried in Other
|
||||||
|
assert.True(t, configHasCriticalPlugin(configReq{
|
||||||
|
Other: map[string]any{"power": map[string]any{"source": "script", "cmd": "echo 1"}},
|
||||||
|
}))
|
||||||
|
assert.False(t, configHasCriticalPlugin(configReq{
|
||||||
|
Other: map[string]any{"template": "tesla"},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
func TestFilterValidTemplateParams(t *testing.T) {
|
func TestFilterValidTemplateParams(t *testing.T) {
|
||||||
conf := map[string]any{
|
conf := map[string]any{
|
||||||
"template": "generic",
|
"template": "generic",
|
||||||
|
|
|
||||||
107
tests/config-script-plugin.spec.ts
Normal file
107
tests/config-script-plugin.spec.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
import { test, expect, type Page } from "@playwright/test";
|
||||||
|
import { start, stop, baseUrl } from "./evcc";
|
||||||
|
import { expectModalVisible, expectModalHidden, editorClear, editorPaste } from "./utils";
|
||||||
|
|
||||||
|
test.use({ baseURL: baseUrl() });
|
||||||
|
|
||||||
|
test.afterEach(async () => {
|
||||||
|
await stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
const SCRIPT_YAML = `power:
|
||||||
|
source: script
|
||||||
|
cmd: echo 9999`;
|
||||||
|
|
||||||
|
const UPDATED_YAML = `power:
|
||||||
|
source: script
|
||||||
|
cmd: echo 8888`;
|
||||||
|
|
||||||
|
async function login(page: Page) {
|
||||||
|
const loginModal = page.getByTestId("login-modal");
|
||||||
|
await expectModalVisible(loginModal);
|
||||||
|
await loginModal.getByLabel("Administrator Password").fill("secret");
|
||||||
|
await loginModal.getByRole("button", { name: "Login" }).click();
|
||||||
|
await expectModalHidden(loginModal);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addCustomGridMeter(page: Page, yaml: string) {
|
||||||
|
await page.getByRole("button", { name: "Add grid meter" }).click();
|
||||||
|
const modal = page.getByTestId("meter-modal");
|
||||||
|
await expectModalVisible(modal);
|
||||||
|
await modal.getByLabel("Manufacturer").selectOption("User-defined device");
|
||||||
|
const editor = modal.getByTestId("yaml-editor");
|
||||||
|
await expect(editor).toBeVisible();
|
||||||
|
await editorClear(editor);
|
||||||
|
await editorPaste(editor, page, yaml);
|
||||||
|
return modal;
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("script plugin requires admin password", async () => {
|
||||||
|
test("caches password across validate and create", async ({ page }) => {
|
||||||
|
await start(undefined, "password.sql", "");
|
||||||
|
await page.goto("/#/config");
|
||||||
|
await login(page);
|
||||||
|
|
||||||
|
const meterModal = await addCustomGridMeter(page, SCRIPT_YAML);
|
||||||
|
const prompt = meterModal.getByTestId("admin-password-prompt");
|
||||||
|
const validate = meterModal.getByTestId("test-result").getByRole("link", { name: "validate" });
|
||||||
|
|
||||||
|
// validate without password reveals the field
|
||||||
|
await validate.click();
|
||||||
|
await expect(prompt).toBeVisible();
|
||||||
|
|
||||||
|
// editing the config hides the field again
|
||||||
|
await editorClear(meterModal.getByTestId("yaml-editor"));
|
||||||
|
await editorPaste(meterModal.getByTestId("yaml-editor"), page, SCRIPT_YAML);
|
||||||
|
await expect(prompt).not.toBeVisible();
|
||||||
|
|
||||||
|
// wrong password keeps the field with an invalid hint
|
||||||
|
await validate.click();
|
||||||
|
await expect(prompt).toBeVisible();
|
||||||
|
await prompt.getByLabel("Administrator Password").fill("wrong");
|
||||||
|
await validate.click();
|
||||||
|
await expect(prompt.getByText("Invalid password. Please try again.")).toBeVisible();
|
||||||
|
|
||||||
|
// correct password validates and hides the field
|
||||||
|
await prompt.getByLabel("Administrator Password").fill("secret");
|
||||||
|
await validate.click();
|
||||||
|
await expect(meterModal.getByTestId("test-result")).toContainText("Status: successful");
|
||||||
|
await expect(prompt).not.toBeVisible();
|
||||||
|
|
||||||
|
// save reuses the cached password, no field reappears
|
||||||
|
await meterModal.getByRole("button", { name: "Save" }).click();
|
||||||
|
await expect(prompt).not.toBeVisible();
|
||||||
|
await expectModalHidden(meterModal);
|
||||||
|
await expect(page.getByTestId("grid")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("re-prompts on update after reload", async ({ page }) => {
|
||||||
|
await start(undefined, "password.sql", "");
|
||||||
|
await page.goto("/#/config");
|
||||||
|
await login(page);
|
||||||
|
|
||||||
|
// create a script meter (enter the password once)
|
||||||
|
const meterModal = await addCustomGridMeter(page, SCRIPT_YAML);
|
||||||
|
const prompt = meterModal.getByTestId("admin-password-prompt");
|
||||||
|
await meterModal.getByRole("button", { name: "Save" }).click();
|
||||||
|
await expect(prompt).toBeVisible();
|
||||||
|
await prompt.getByLabel("Administrator Password").fill("secret");
|
||||||
|
await meterModal.getByRole("button", { name: "Save" }).click();
|
||||||
|
await expectModalHidden(meterModal);
|
||||||
|
await expect(page.getByTestId("grid")).toBeVisible();
|
||||||
|
|
||||||
|
// reload drops the cached password
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
// editing and saving the existing device prompts again
|
||||||
|
await page.getByTestId("grid").getByRole("button", { name: "edit" }).click();
|
||||||
|
await expectModalVisible(meterModal);
|
||||||
|
await editorClear(meterModal.getByTestId("yaml-editor"));
|
||||||
|
await editorPaste(meterModal.getByTestId("yaml-editor"), page, UPDATED_YAML);
|
||||||
|
await meterModal.getByRole("button", { name: "Save" }).click();
|
||||||
|
await expect(prompt).toBeVisible();
|
||||||
|
await prompt.getByLabel("Administrator Password").fill("secret");
|
||||||
|
await meterModal.getByRole("button", { name: "Save" }).click();
|
||||||
|
await expectModalHidden(meterModal);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue