Add remote access (#28688)
This commit is contained in:
parent
16f2f89d42
commit
9b8ac11777
25 changed files with 1778 additions and 14 deletions
|
|
@ -175,11 +175,21 @@ export default {
|
|||
},
|
||||
methods: {
|
||||
valueClasses(entry) {
|
||||
return {
|
||||
"value--error": !!entry.error,
|
||||
"value--warning": entry.warning,
|
||||
"value--muted": entry.muted || entry.value === false,
|
||||
};
|
||||
if (entry.error) {
|
||||
return "value--error";
|
||||
}
|
||||
if (entry.warning) {
|
||||
return "value--warning";
|
||||
}
|
||||
if (
|
||||
entry.muted ||
|
||||
entry.value === false ||
|
||||
entry.value === null ||
|
||||
entry.value === undefined
|
||||
) {
|
||||
return "value--muted";
|
||||
}
|
||||
return "";
|
||||
},
|
||||
fmtDeviceValue(entry) {
|
||||
const { name, value } = entry;
|
||||
|
|
@ -221,7 +231,9 @@ export default {
|
|||
case "singlePhase":
|
||||
case "enabled":
|
||||
case "configured":
|
||||
case "connected":
|
||||
case "dimmed":
|
||||
case "loginBlocked":
|
||||
return value
|
||||
? this.$t("config.deviceValue.yes")
|
||||
: this.$t("config.deviceValue.no");
|
||||
|
|
|
|||
97
assets/js/components/Config/Remote/RemoteClientCreate.vue
Normal file
97
assets/js/components/Config/Remote/RemoteClientCreate.vue
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
<template>
|
||||
<div>
|
||||
<p>{{ $t("config.remote.addClientDescription") }}</p>
|
||||
<form @submit.prevent="submit">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<FormRow
|
||||
id="newClientDeviceName"
|
||||
:label="$t('config.remote.deviceName')"
|
||||
example="blue-iphone"
|
||||
>
|
||||
<input
|
||||
id="newClientDeviceName"
|
||||
ref="deviceNameInput"
|
||||
v-model="username"
|
||||
name="deviceName"
|
||||
type="text"
|
||||
class="form-control border"
|
||||
pattern="[^:]+"
|
||||
autocomplete="off"
|
||||
data-lpignore="true"
|
||||
data-1p-ignore
|
||||
data-form-type="other"
|
||||
required
|
||||
/>
|
||||
</FormRow>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<FormRow id="newClientExpiration" :label="$t('config.remote.expiration')">
|
||||
<select
|
||||
id="newClientExpiration"
|
||||
v-model="expiresIn"
|
||||
class="form-select border"
|
||||
>
|
||||
<option
|
||||
v-for="opt in expirationOptions"
|
||||
:key="opt.value"
|
||||
:value="opt.value"
|
||||
>
|
||||
{{ opt.name }}
|
||||
</option>
|
||||
</select>
|
||||
</FormRow>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between mt-3">
|
||||
<button type="button" class="btn btn-outline-secondary" @click="$emit('cancel')">
|
||||
{{ $t("config.general.cancel") }}
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
{{ $t("config.remote.createClient") }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
import FormRow from "../FormRow.vue";
|
||||
import formatter from "@/mixins/formatter";
|
||||
|
||||
export default defineComponent({
|
||||
name: "RemoteClientCreate",
|
||||
components: { FormRow },
|
||||
mixins: [formatter],
|
||||
emits: ["cancel", "submit"],
|
||||
data() {
|
||||
return {
|
||||
username: "",
|
||||
expiresIn: 0,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
expirationOptions() {
|
||||
const HOUR = 60 * 60;
|
||||
const DAY = 24 * HOUR;
|
||||
return [
|
||||
{ value: 0, name: this.$t("config.remote.expirationNone") },
|
||||
{ value: HOUR, name: this.fmtDurationParts({ hours: 1 }) },
|
||||
{ value: DAY, name: this.fmtDurationParts({ days: 1 }) },
|
||||
{ value: 7 * DAY, name: this.fmtDurationParts({ weeks: 1 }) },
|
||||
];
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
(this.$refs["deviceNameInput"] as HTMLInputElement | undefined)?.focus();
|
||||
},
|
||||
methods: {
|
||||
submit() {
|
||||
const username = this.username.trim();
|
||||
if (!username) return;
|
||||
this.$emit("submit", { username, expiresIn: this.expiresIn });
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
102
assets/js/components/Config/Remote/RemoteClientList.vue
Normal file
102
assets/js/components/Config/Remote/RemoteClientList.vue
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
<template>
|
||||
<div>
|
||||
<h6 class="mb-3">{{ $t("config.remote.clients") }}</h6>
|
||||
<div
|
||||
v-for="client in clients"
|
||||
:key="client.username"
|
||||
data-testid="remote-client"
|
||||
class="mb-2"
|
||||
>
|
||||
<div
|
||||
class="d-flex align-items-center justify-content-between py-2 ps-3 pe-2 border rounded"
|
||||
:class="{ 'opacity-50': isExpired(client) }"
|
||||
>
|
||||
<div class="flex-grow-1 fw-semibold">{{ client.username }}</div>
|
||||
<div class="text-end">
|
||||
<small v-if="expiryLabel(client)" class="text-muted ms-2 me-2 text-end">
|
||||
{{ expiryLabel(client) }}
|
||||
</small>
|
||||
<span v-if="isActive(client)" class="badge bg-success ms-2 me-2">
|
||||
{{ $t("config.remote.active") }}
|
||||
</span>
|
||||
<small
|
||||
v-else-if="activityLabel(client)"
|
||||
class="text-muted ms-2 me-2 text-end d-block"
|
||||
>
|
||||
{{ activityLabel(client) }}
|
||||
</small>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-outline-secondary border-0"
|
||||
:aria-label="$t('config.remote.removeClient')"
|
||||
@click="$emit('remove', client.username)"
|
||||
>
|
||||
<shopicon-regular-trash size="s" class="flex-shrink-0"></shopicon-regular-trash>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!clients.length" class="text-muted small mb-2">
|
||||
{{ $t("config.remote.noClients") }}
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-end mt-4">
|
||||
<button type="button" class="btn btn-outline-secondary" @click="$emit('add')">
|
||||
{{ $t("config.remote.addClient") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, type PropType } from "vue";
|
||||
import "@h2d2/shopicons/es/regular/trash";
|
||||
import formatter from "@/mixins/formatter";
|
||||
import minuteTicker from "@/mixins/minuteTicker";
|
||||
import type { RemoteClient } from "@/types/evcc";
|
||||
import { isRemoteClientActive } from "@/utils/remote";
|
||||
|
||||
export default defineComponent({
|
||||
name: "RemoteClientList",
|
||||
mixins: [formatter, minuteTicker],
|
||||
props: {
|
||||
clients: { type: Array as PropType<RemoteClient[]>, required: true },
|
||||
lastSeen: { type: Object as PropType<Record<string, string>>, default: () => ({}) },
|
||||
connected: Boolean,
|
||||
},
|
||||
emits: ["add", "remove"],
|
||||
methods: {
|
||||
lastSeenMs(client: RemoteClient): number | null {
|
||||
void this.everyMinute;
|
||||
const seen = this.lastSeen[client.username];
|
||||
if (!seen) return null;
|
||||
return Date.now() - new Date(seen).getTime();
|
||||
},
|
||||
isActive(client: RemoteClient): boolean {
|
||||
return this.connected && isRemoteClientActive(this.lastSeen, client.username);
|
||||
},
|
||||
activityLabel(client: RemoteClient): string {
|
||||
const ms = this.lastSeenMs(client);
|
||||
if (ms === null) return "";
|
||||
return this.$t("config.remote.lastActive", { time: this.fmtTimeAgo(-ms) });
|
||||
},
|
||||
expiresInMs(client: RemoteClient): number | null {
|
||||
// reference everyMinute so dependents re-evaluate on each tick
|
||||
void this.everyMinute;
|
||||
if (!client.expiresAt) return null;
|
||||
return new Date(client.expiresAt).getTime() - Date.now();
|
||||
},
|
||||
isExpired(client: RemoteClient): boolean {
|
||||
const ms = this.expiresInMs(client);
|
||||
return ms !== null && ms <= 0;
|
||||
},
|
||||
expiryLabel(client: RemoteClient): string {
|
||||
const ms = this.expiresInMs(client);
|
||||
if (ms === null) return "";
|
||||
if (ms <= 0) return this.$t("config.remote.expired");
|
||||
return this.$t("config.remote.expiresIn", { time: this.fmtTimeAgo(ms) });
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
125
assets/js/components/Config/Remote/RemoteClientReveal.vue
Normal file
125
assets/js/components/Config/Remote/RemoteClientReveal.vue
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
<template>
|
||||
<div>
|
||||
<ol class="text-muted mb-2">
|
||||
<li>
|
||||
<i18n-t keypath="config.remote.qrInstall" scope="global">
|
||||
<template #ios>
|
||||
<a href="https://apps.apple.com/app/evcc-io/id6478510176" target="_blank">
|
||||
iOS
|
||||
</a>
|
||||
</template>
|
||||
<template #android>
|
||||
<a
|
||||
href="https://play.google.com/store/apps/details?id=io.evcc.android"
|
||||
target="_blank"
|
||||
>
|
||||
Android
|
||||
</a>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</li>
|
||||
<li>{{ $t("config.remote.qrScan") }}</li>
|
||||
</ol>
|
||||
<div class="text-center my-3">
|
||||
<a v-if="qrDataUrl" :href="appUrl" class="d-inline-block">
|
||||
<img :src="qrDataUrl" alt="QR Code" class="qr-code" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<hr class="my-4" />
|
||||
|
||||
<p>
|
||||
<i18n-t keypath="config.remote.manualLogin" scope="global">
|
||||
<template #url>
|
||||
<a :href="serverUrl" target="_blank" rel="noopener">{{ serverUrl }}</a>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</p>
|
||||
<FormRow id="revealUsername" :label="$t('config.remote.username')">
|
||||
<input
|
||||
id="revealUsername"
|
||||
type="text"
|
||||
class="form-control border"
|
||||
:value="client.username"
|
||||
readonly
|
||||
/>
|
||||
</FormRow>
|
||||
<FormRow id="revealPassword" :label="$t('config.remote.password')">
|
||||
<input
|
||||
id="revealPassword"
|
||||
type="text"
|
||||
class="form-control border font-monospace"
|
||||
:value="client.password"
|
||||
readonly
|
||||
/>
|
||||
<CopyLink :text="client.password" />
|
||||
</FormRow>
|
||||
|
||||
<div class="mt-4 small text-muted">
|
||||
<strong class="text-evcc">{{ $t("general.note") }}</strong>
|
||||
{{ $t("config.remote.passwordOnce") }}
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-end mt-3">
|
||||
<button type="button" class="btn btn-primary" @click="$emit('done')">
|
||||
{{ $t("config.remote.done") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, type PropType } from "vue";
|
||||
import QRCode from "qrcode";
|
||||
import FormRow from "../FormRow.vue";
|
||||
import CopyLink from "../../Helper/CopyLink.vue";
|
||||
import type { RemoteClientCreated } from "@/types/evcc";
|
||||
|
||||
export default defineComponent({
|
||||
name: "RemoteClientReveal",
|
||||
components: { FormRow, CopyLink },
|
||||
props: {
|
||||
client: { type: Object as PropType<RemoteClientCreated>, required: true },
|
||||
serverUrl: { type: String, required: true },
|
||||
},
|
||||
emits: ["done"],
|
||||
data() {
|
||||
return {
|
||||
qrDataUrl: null as string | null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
appUrl(): string {
|
||||
if (!this.client || !this.serverUrl) return "";
|
||||
const params = new URLSearchParams({
|
||||
url: this.serverUrl,
|
||||
username: this.client.username,
|
||||
password: this.client.password,
|
||||
});
|
||||
return `evcc://server?${params.toString()}`;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
appUrl: {
|
||||
immediate: true,
|
||||
async handler(url: string) {
|
||||
if (!url) return;
|
||||
try {
|
||||
this.qrDataUrl = await QRCode.toDataURL(url, {
|
||||
width: 200,
|
||||
margin: 1,
|
||||
});
|
||||
} catch {
|
||||
this.qrDataUrl = null;
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.qr-code {
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
</style>
|
||||
211
assets/js/components/Config/Remote/RemoteModal.vue
Normal file
211
assets/js/components/Config/Remote/RemoteModal.vue
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
<template>
|
||||
<GenericModal
|
||||
id="remoteModal"
|
||||
:title="modalTitle"
|
||||
config-modal-name="remote"
|
||||
data-testid="remote-modal"
|
||||
@open="onOpen"
|
||||
>
|
||||
<div class="alert alert-warning">
|
||||
Development preview. Not ready for general use. Use with caution and monitor your system
|
||||
closely. Feedback welcome!
|
||||
</div>
|
||||
<SponsorTokenRequired v-if="!isSponsor" feature class="mt-0" />
|
||||
<ErrorMessage :error="error" />
|
||||
|
||||
<template v-if="view === 'list'">
|
||||
<p>{{ $t("config.remote.description") }}</p>
|
||||
<div class="form-check form-switch my-3">
|
||||
<input
|
||||
id="remoteEnabled"
|
||||
:checked="config.enabled"
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
:disabled="!isSponsor"
|
||||
@change="change"
|
||||
/>
|
||||
<div class="form-check-label">
|
||||
<label for="remoteEnabled">{{ $t("config.remote.enableLabel") }}</label>
|
||||
<div v-if="status.url">
|
||||
<span v-if="status.connected" class="text-primary">
|
||||
{{ $t("config.remote.connected") }}
|
||||
</span>
|
||||
<span v-else class="text-muted small">
|
||||
{{ $t("config.remote.disconnected") }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="status.url" class="mt-4">
|
||||
<FormRow id="remoteServerUrl" :label="$t('config.remote.url')">
|
||||
<input
|
||||
id="remoteServerUrl"
|
||||
type="text"
|
||||
class="form-control border"
|
||||
:value="status.url"
|
||||
readonly
|
||||
/>
|
||||
</FormRow>
|
||||
|
||||
<hr class="my-4" />
|
||||
|
||||
<div v-if="status.loginBlocked" class="alert alert-danger">
|
||||
{{ $t("config.remote.loginBlocked") }}
|
||||
</div>
|
||||
|
||||
<RemoteClientList
|
||||
:clients="clients"
|
||||
:last-seen="status.lastSeen"
|
||||
:connected="status.connected"
|
||||
@add="view = 'create'"
|
||||
@remove="removeClient"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<RemoteClientCreate
|
||||
v-else-if="view === 'create'"
|
||||
@cancel="view = 'list'"
|
||||
@submit="submitCreate"
|
||||
/>
|
||||
|
||||
<RemoteClientReveal
|
||||
v-else-if="view === 'reveal' && createdClient && status.url"
|
||||
:client="createdClient"
|
||||
:server-url="status.url"
|
||||
@done="finishReveal"
|
||||
/>
|
||||
</GenericModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
import GenericModal from "../../Helper/GenericModal.vue";
|
||||
import ErrorMessage from "../../Helper/ErrorMessage.vue";
|
||||
import FormRow from "../FormRow.vue";
|
||||
import SponsorTokenRequired from "../DeviceModal/SponsorTokenRequired.vue";
|
||||
import RemoteClientList from "./RemoteClientList.vue";
|
||||
import RemoteClientCreate from "./RemoteClientCreate.vue";
|
||||
import RemoteClientReveal from "./RemoteClientReveal.vue";
|
||||
import api from "@/api";
|
||||
import type {
|
||||
Remote,
|
||||
RemoteConfig,
|
||||
RemoteStatus,
|
||||
RemoteClient,
|
||||
RemoteClientCreated,
|
||||
} from "@/types/evcc";
|
||||
import type { AxiosError } from "axios";
|
||||
|
||||
type View = "list" | "create" | "reveal";
|
||||
|
||||
export default defineComponent({
|
||||
name: "RemoteModal",
|
||||
components: {
|
||||
GenericModal,
|
||||
ErrorMessage,
|
||||
FormRow,
|
||||
SponsorTokenRequired,
|
||||
RemoteClientList,
|
||||
RemoteClientCreate,
|
||||
RemoteClientReveal,
|
||||
},
|
||||
props: {
|
||||
remote: { type: Object as () => Remote | undefined, default: undefined },
|
||||
isSponsor: Boolean,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
error: null as string | null,
|
||||
view: "list" as View,
|
||||
clients: [] as RemoteClient[],
|
||||
createdClient: null as RemoteClientCreated | null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
config(): RemoteConfig {
|
||||
return this.remote?.config ?? { enabled: false };
|
||||
},
|
||||
status(): RemoteStatus {
|
||||
return this.remote?.status ?? { connected: false, loginBlocked: false };
|
||||
},
|
||||
modalTitle(): string {
|
||||
switch (this.view) {
|
||||
case "create":
|
||||
return this.$t("config.remote.addClientTitle");
|
||||
case "reveal":
|
||||
return this.$t("config.remote.clientCreated");
|
||||
default:
|
||||
return `${this.$t("config.remote.title")} 🧪`;
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async onOpen() {
|
||||
this.error = null;
|
||||
this.view = "list";
|
||||
this.createdClient = null;
|
||||
await this.loadClients();
|
||||
},
|
||||
async loadClients() {
|
||||
if (!this.status.url) {
|
||||
this.clients = [];
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await api.get("config/remote/clients");
|
||||
this.clients = res.data || [];
|
||||
} catch (err) {
|
||||
this.handleError(err);
|
||||
}
|
||||
},
|
||||
async change(e: Event) {
|
||||
const target = e.target as HTMLInputElement;
|
||||
const checked = target.checked;
|
||||
try {
|
||||
this.error = null;
|
||||
await api.post(`config/remote/${checked}`);
|
||||
if (checked) {
|
||||
await this.loadClients();
|
||||
}
|
||||
} catch (err) {
|
||||
target.checked = !checked;
|
||||
this.handleError(err);
|
||||
}
|
||||
},
|
||||
async submitCreate(payload: { username: string; expiresIn: number }) {
|
||||
try {
|
||||
this.error = null;
|
||||
const res = await api.post("config/remote/clients", payload);
|
||||
this.createdClient = res.data as RemoteClientCreated;
|
||||
this.view = "reveal";
|
||||
} catch (err) {
|
||||
this.handleError(err);
|
||||
}
|
||||
},
|
||||
async finishReveal() {
|
||||
this.createdClient = null;
|
||||
await this.loadClients();
|
||||
this.view = "list";
|
||||
},
|
||||
async removeClient(username: string) {
|
||||
if (!window.confirm(this.$t("config.remote.confirmDelete"))) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.error = null;
|
||||
await api.delete("config/remote/clients", { params: { username } });
|
||||
await this.loadClients();
|
||||
} catch (err) {
|
||||
this.handleError(err);
|
||||
}
|
||||
},
|
||||
handleError(err: unknown) {
|
||||
const axiosErr = err as AxiosError<{ error: string }>;
|
||||
this.error = axiosErr.response?.data?.error || axiosErr.message || String(err);
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
18
assets/js/components/MaterialIcon/RemoteAccess.vue
Normal file
18
assets/js/components/MaterialIcon/RemoteAccess.vue
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<template>
|
||||
<svg :style="svgStyle" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M6.5 20q-2.275 0-3.887-1.575T1 14.575q0-1.95 1.175-3.475T5.25 9.15q.625-2.3 2.5-3.725T12 4q2.4 0 4.238 1.4T18.725 9q.125.425-.112.75t-.588.425t-.725-.075t-.55-.675q-.5-1.5-1.8-2.463T12 6Q9.925 6 8.463 7.463T7 11h-.5q-1.45 0-2.475 1.025T3 14.5t1.025 2.475T6.5 18H13q.425 0 .713.288T14 19t-.288.713T13 20zM17 20q-.425 0-.712-.288T16 19v-3q0-.425.288-.712T17 15v-1q0-.825.588-1.412T19 12t1.413.588T21 14v1q.425 0 .713.288T22 16v3q0 .425-.288.713T21 20zm1-5h2v-1q0-.425-.288-.712T19 13t-.712.288T18 14z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
import icon from "@/mixins/icon";
|
||||
|
||||
export default defineComponent({
|
||||
name: "RemoteAccessIcon",
|
||||
mixins: [icon],
|
||||
});
|
||||
</script>
|
||||
|
|
@ -180,6 +180,10 @@ export default defineComponent({
|
|||
const formatter = new Intl.DurationFormat(this.$i18n?.locale, { style });
|
||||
return formatter.format({ minutes, hours });
|
||||
},
|
||||
fmtDurationParts(parts: Record<string, number>) {
|
||||
// @ts-expect-error - Intl.DurationFormat is a new API not yet in TS types
|
||||
return new Intl.DurationFormat(this.$i18n?.locale, { style: "long" }).format(parts);
|
||||
},
|
||||
fmtDayString(date: Date) {
|
||||
const YY = `${date.getFullYear()}`;
|
||||
const MM = `${date.getMonth() + 1}`.padStart(2, "0");
|
||||
|
|
|
|||
|
|
@ -93,6 +93,7 @@ export interface State {
|
|||
shm?: ShmConfig;
|
||||
sponsor?: ConfigStatus<unknown, SponsorStatus>;
|
||||
eebus?: ConfigStatus<EebusConfig, EebusStatus>;
|
||||
remote?: Remote;
|
||||
modbusproxy?: ModbusProxy[];
|
||||
messaging?: ConfigStatus<unknown, unknown>;
|
||||
messagingEvents?: MessagingEvents;
|
||||
|
|
@ -484,6 +485,29 @@ export type Certificate = {
|
|||
private: string;
|
||||
};
|
||||
|
||||
export type Remote = ConfigStatus<RemoteConfig, RemoteStatus>;
|
||||
|
||||
export type RemoteConfig = {
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
export type RemoteStatus = {
|
||||
connected: boolean;
|
||||
url?: string;
|
||||
loginBlocked: boolean;
|
||||
lastSeen?: Record<string, string>;
|
||||
};
|
||||
|
||||
export type RemoteClient = {
|
||||
username: string;
|
||||
createdAt: string;
|
||||
expiresAt?: string;
|
||||
};
|
||||
|
||||
export type RemoteClientCreated = RemoteClient & {
|
||||
password: string;
|
||||
};
|
||||
|
||||
export type Eebus = ConfigStatus<EebusConfig, EebusStatus>;
|
||||
|
||||
export type EebusConfig = {
|
||||
|
|
|
|||
10
assets/js/utils/remote.ts
Normal file
10
assets/js/utils/remote.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
const ACTIVE_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
export function isRemoteClientActive(
|
||||
lastSeen: Record<string, string> | undefined,
|
||||
username: string
|
||||
): boolean {
|
||||
const seen = lastSeen?.[username];
|
||||
if (!seen) return false;
|
||||
return Date.now() - new Date(seen).getTime() < ACTIVE_THRESHOLD_MS;
|
||||
}
|
||||
|
|
@ -321,6 +321,19 @@
|
|||
<DeviceTags :tags="hemsTags" />
|
||||
</template>
|
||||
</DeviceCard>
|
||||
<DeviceCard
|
||||
v-if="remote"
|
||||
:title="$t('config.remote.title')"
|
||||
editable
|
||||
:unconfigured="isUnconfigured(remoteTags)"
|
||||
data-testid="remote-access"
|
||||
@edit="openModal('remote')"
|
||||
>
|
||||
<template #icon><RemoteAccessIcon /></template>
|
||||
<template #tags>
|
||||
<DeviceTags :tags="remoteTags" />
|
||||
</template>
|
||||
</DeviceCard>
|
||||
<DeviceCard
|
||||
v-if="experimental"
|
||||
:title="`${$t('config.optimizer.title')} 🧪`"
|
||||
|
|
@ -417,6 +430,7 @@
|
|||
<TelemetryModal :sponsor="sponsor" :telemetry="telemetry" />
|
||||
<OptimizerModal />
|
||||
<ExperimentalModal :experimental="experimental" />
|
||||
<RemoteModal :remote="remote" :is-sponsor="isSponsor" />
|
||||
<TitleModal @changed="loadDirty" />
|
||||
<ModbusProxyModal :is-sponsor="isSponsor" @changed="loadDirty" />
|
||||
<CircuitsModal :gridMeter="gridMeter" :extMeters="extMeters" @changed="loadDirty" />
|
||||
|
|
@ -473,6 +487,9 @@ import ModbusProxyIcon from "../components/MaterialIcon/ModbusProxy.vue";
|
|||
import ModbusProxyModal from "../components/Config/ModbusProxyModal.vue";
|
||||
import MqttIcon from "../components/MaterialIcon/Mqtt.vue";
|
||||
import MqttModal from "../components/Config/MqttModal.vue";
|
||||
import RemoteAccessIcon from "../components/MaterialIcon/RemoteAccess.vue";
|
||||
import RemoteModal from "../components/Config/Remote/RemoteModal.vue";
|
||||
import { isRemoteClientActive } from "@/utils/remote";
|
||||
import NetworkModal from "../components/Config/NetworkModal.vue";
|
||||
import NotificationIcon from "../components/MaterialIcon/Notification.vue";
|
||||
import OptimizerIcon from "../components/MaterialIcon/Optimizer.vue";
|
||||
|
|
@ -504,6 +521,7 @@ import type {
|
|||
SiteConfig,
|
||||
DeviceType,
|
||||
Notification,
|
||||
Remote,
|
||||
} from "@/types/evcc";
|
||||
import { CURRENCY, GRID_CONTROL } from "@/types/evcc";
|
||||
import { circuitTree } from "@/utils/circuits";
|
||||
|
|
@ -555,6 +573,8 @@ export default defineComponent({
|
|||
ModbusProxyModal,
|
||||
MqttIcon,
|
||||
MqttModal,
|
||||
RemoteAccessIcon,
|
||||
RemoteModal,
|
||||
NetworkModal,
|
||||
NotificationIcon,
|
||||
OptimizerIcon,
|
||||
|
|
@ -763,6 +783,30 @@ export default defineComponent({
|
|||
|
||||
return result;
|
||||
},
|
||||
remote(): Remote | undefined {
|
||||
return store.state?.remote;
|
||||
},
|
||||
remoteTags(): DeviceTags {
|
||||
const remote = this.remote;
|
||||
if (!remote?.status?.url) {
|
||||
return { configured: { value: false } };
|
||||
}
|
||||
const tags: DeviceTags = {
|
||||
enabled: { value: remote.config?.enabled },
|
||||
connected: { value: remote.status?.connected },
|
||||
};
|
||||
if (remote.status?.loginBlocked) {
|
||||
tags["loginBlocked"] = { value: true, error: true };
|
||||
}
|
||||
if (remote.status?.connected) {
|
||||
const lastSeen = remote.status?.lastSeen;
|
||||
const count = lastSeen
|
||||
? Object.keys(lastSeen).filter((u) => isRemoteClientActive(lastSeen, u)).length
|
||||
: 0;
|
||||
tags["activeClients"] = { value: count };
|
||||
}
|
||||
return tags;
|
||||
},
|
||||
sponsor() {
|
||||
return store.state?.sponsor;
|
||||
},
|
||||
|
|
|
|||
14
cmd/root.go
14
cmd/root.go
|
|
@ -22,6 +22,7 @@ import (
|
|||
"github.com/evcc-io/evcc/server/eebus"
|
||||
"github.com/evcc-io/evcc/server/mcp"
|
||||
"github.com/evcc-io/evcc/server/network"
|
||||
"github.com/evcc-io/evcc/server/remote"
|
||||
"github.com/evcc-io/evcc/server/updater"
|
||||
"github.com/evcc-io/evcc/util"
|
||||
"github.com/evcc-io/evcc/util/auth"
|
||||
|
|
@ -237,6 +238,12 @@ func runRoot(cmd *cobra.Command, args []string) {
|
|||
// publish to UI
|
||||
go socketHub.Run(pipe.NewDropper(ignoreEmpty).Pipe(tee.Attach()), cache)
|
||||
|
||||
// remote access tunnel
|
||||
var remoteAccess *remote.Remote
|
||||
if remoteHost := os.Getenv("EVCC_REMOTE_ACCESS"); remoteHost != "" {
|
||||
remoteAccess = remote.New(remoteHost, httpd.Router(), valueChan)
|
||||
}
|
||||
|
||||
// signal ui listening
|
||||
valueChan <- util.Param{Key: keys.StartupCompleted, Val: false}
|
||||
|
||||
|
|
@ -385,6 +392,11 @@ func runRoot(cmd *cobra.Command, args []string) {
|
|||
YamlSource: yamlSource.tariffs,
|
||||
}}
|
||||
|
||||
// publish remote access status
|
||||
if remoteAccess != nil {
|
||||
valueChan <- util.Param{Key: keys.Remote, Val: remoteAccess.ConfigStatus()}
|
||||
}
|
||||
|
||||
// publish system infos
|
||||
valueChan <- util.Param{Key: keys.Version, Val: util.FormattedVersion()}
|
||||
valueChan <- util.Param{Key: keys.Config, Val: viper.ConfigFileUsed()}
|
||||
|
|
@ -429,7 +441,7 @@ func runRoot(cmd *cobra.Command, args []string) {
|
|||
log.INFO.Println("evcc was stopped by user. OS should restart the service. Or restart manually.")
|
||||
err = errors.New("restart required") // https://gokrazy.org/development/process-interface/
|
||||
once.Do(func() { close(stopC) }) // signal loop to end
|
||||
}, viper.ConfigFileUsed())
|
||||
}, viper.ConfigFileUsed(), remoteAccess)
|
||||
|
||||
// show and check version, reduce api load during development
|
||||
if util.Version != util.DevVersion {
|
||||
|
|
|
|||
|
|
@ -30,6 +30,9 @@ const (
|
|||
Telemetry = "telemetry"
|
||||
Optimizer = "optimizer"
|
||||
DemoMode = "demoMode"
|
||||
Remote = "remote"
|
||||
RemoteClients = "remoteClients"
|
||||
RemoteLastSeen = "remoteLastSeen"
|
||||
AuthDisabled = "authDisabled"
|
||||
AuthProviders = "authProviders"
|
||||
)
|
||||
|
|
|
|||
2
go.mod
2
go.mod
|
|
@ -54,6 +54,7 @@ require (
|
|||
github.com/gregdel/pushover v1.4.0
|
||||
github.com/grid-x/modbus v0.0.0-20260325140807-cf9e1b9daae0
|
||||
github.com/hashicorp/go-version v1.9.0
|
||||
github.com/hashicorp/yamux v0.1.2
|
||||
github.com/hasura/go-graphql-client v0.16.0
|
||||
github.com/holoplot/go-evdev v0.0.0-20250804134636-ab1d56a1fe83
|
||||
github.com/influxdata/influxdb-client-go/v2 v2.14.0
|
||||
|
|
@ -85,6 +86,7 @@ require (
|
|||
github.com/robertkrimen/otto v0.5.1
|
||||
github.com/samber/lo v1.53.0
|
||||
github.com/sandrolain/httpcache v1.4.0
|
||||
github.com/sethvargo/go-password v0.3.1
|
||||
github.com/sirupsen/logrus v1.9.4
|
||||
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966
|
||||
github.com/smallnest/chanx v1.2.0
|
||||
|
|
|
|||
4
go.sum
4
go.sum
|
|
@ -391,6 +391,8 @@ github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO
|
|||
github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ=
|
||||
github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I=
|
||||
github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc=
|
||||
github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8=
|
||||
github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns=
|
||||
github.com/hasura/go-graphql-client v0.16.0 h1:DQLfp+djj4j5NPdJkGYym8J55hpm5etML1zqgco78Qc=
|
||||
github.com/hasura/go-graphql-client v0.16.0/go.mod h1:z/sO2T0zI+HnPNIevQcs+7xA6/gDOc8hgHMrNBzfL2c=
|
||||
github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog=
|
||||
|
|
@ -686,6 +688,8 @@ github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
|
|||
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
|
||||
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
|
||||
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
|
||||
github.com/sethvargo/go-password v0.3.1 h1:WqrLTjo7X6AcVYfC6R7GtSyuUQR9hGyAj/f1PYQZCJU=
|
||||
github.com/sethvargo/go-password v0.3.1/go.mod h1:rXofC1zT54N7R8K/h1WDUdkf9BOx5OptoxrMBcrXzvs=
|
||||
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
||||
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
|
||||
|
|
|
|||
34
i18n/de.json
34
i18n/de.json
|
|
@ -113,6 +113,7 @@
|
|||
"title": "Währung"
|
||||
},
|
||||
"deviceValue": {
|
||||
"activeClients": "Aktive Clients",
|
||||
"amount": "Anzahl",
|
||||
"broker": "Broker",
|
||||
"bucket": "Bucket",
|
||||
|
|
@ -144,6 +145,7 @@
|
|||
"hemsActiveLimit": "Aktives Limit",
|
||||
"hemsType": "Kommunikation",
|
||||
"identifier": "RFID-Kennung",
|
||||
"loginBlocked": "Login-Limit erreicht",
|
||||
"max": "max",
|
||||
"messengers": "Dienste",
|
||||
"no": "Nein",
|
||||
|
|
@ -631,6 +633,38 @@
|
|||
"titleAdd": "PV-Anlage hinzufügen",
|
||||
"titleEdit": "PV-Zähler bearbeiten"
|
||||
},
|
||||
"remote": {
|
||||
"active": "Aktiv",
|
||||
"addClient": "Client hinzufügen",
|
||||
"addClientDescription": "Zugangsdaten werden ausschließlich lokal auf deiner evcc-Instanz gespeichert und überprüft.",
|
||||
"addClientTitle": "Remote-Client hinzufügen",
|
||||
"clientCreated": "Client erstellt",
|
||||
"clients": "Clients",
|
||||
"confirmDelete": "Client löschen?",
|
||||
"connected": "Verbunden",
|
||||
"createClient": "Client erstellen",
|
||||
"description": "Externer Zugriff auf die evcc-Installation über die evcc-App. Ohne Portfreigabe oder VPN.",
|
||||
"deviceName": "Gerätename",
|
||||
"disconnected": "Nicht verbunden",
|
||||
"done": "Fertig",
|
||||
"enableLabel": "Remotezugriff aktivieren",
|
||||
"expiration": "Ablauf",
|
||||
"expirationNone": "Nie",
|
||||
"expired": "abgelaufen",
|
||||
"expiresIn": "läuft {time} ab",
|
||||
"lastActive": "aktiv {time}",
|
||||
"loginBlocked": "Remote-Logins sind wegen zu vieler fehlgeschlagener Anmeldeversuche für eine Minute gesperrt.",
|
||||
"manualLogin": "Oder melde dich manuell unter {url} in deinem Browser mit diesen Zugangsdaten an:",
|
||||
"noClients": "Noch keine Clients. Niemand kann sich verbinden.",
|
||||
"password": "Passwort",
|
||||
"passwordOnce": "Dieses Passwort wird nur einmal angezeigt. QR-Code scannen oder jetzt kopieren. Es wird später nicht mehr angezeigt.",
|
||||
"qrInstall": "Installiere die evcc-App für {ios} oder {android}.",
|
||||
"qrScan": "Scanne den Code mit der Kamera deines Smartphones, um dich zu verbinden. Klicke ihn an, wenn du bereits dein Handy nutzt.",
|
||||
"removeClient": "Client entfernen",
|
||||
"title": "Remotezugriff",
|
||||
"url": "Öffentliche URL",
|
||||
"username": "Benutzername"
|
||||
},
|
||||
"section": {
|
||||
"additionalMeter": "Zusätzliche Zähler",
|
||||
"general": "Allgemein",
|
||||
|
|
|
|||
35
i18n/en.json
35
i18n/en.json
|
|
@ -113,6 +113,7 @@
|
|||
"title": "Currency"
|
||||
},
|
||||
"deviceValue": {
|
||||
"activeClients": "Active clients",
|
||||
"amount": "Amount",
|
||||
"broker": "Broker",
|
||||
"bucket": "Bucket",
|
||||
|
|
@ -126,6 +127,7 @@
|
|||
"chargedEnergy": "Charged",
|
||||
"co2": "Grid CO₂",
|
||||
"configured": "Configured",
|
||||
"connected": "Connected",
|
||||
"connections": "Connections",
|
||||
"controllable": "Controllable",
|
||||
"currency": "Currency",
|
||||
|
|
@ -144,6 +146,7 @@
|
|||
"hemsActiveLimit": "Active limit",
|
||||
"hemsType": "Communication",
|
||||
"identifier": "RFID-Identifier",
|
||||
"loginBlocked": "Login limit reached",
|
||||
"max": "max",
|
||||
"messengers": "Services",
|
||||
"no": "no",
|
||||
|
|
@ -631,6 +634,38 @@
|
|||
"titleAdd": "Add Solar Meter",
|
||||
"titleEdit": "Edit Solar Meter"
|
||||
},
|
||||
"remote": {
|
||||
"active": "Active",
|
||||
"addClient": "Add client",
|
||||
"addClientDescription": "Credentials are stored and verified only locally on your evcc instance.",
|
||||
"addClientTitle": "Add Remote Client",
|
||||
"clientCreated": "Client created",
|
||||
"clients": "Clients",
|
||||
"confirmDelete": "Delete client?",
|
||||
"connected": "Connected",
|
||||
"createClient": "Create client",
|
||||
"description": "Access your evcc installation from anywhere using the evcc mobile app. No port forwarding or VPN required.",
|
||||
"deviceName": "Device name",
|
||||
"disconnected": "Disconnected",
|
||||
"done": "Done",
|
||||
"enableLabel": "Enable remote access",
|
||||
"expiration": "Expiration",
|
||||
"expirationNone": "Never",
|
||||
"expired": "expired",
|
||||
"expiresIn": "expires {time}",
|
||||
"lastActive": "active {time}",
|
||||
"loginBlocked": "Remote logins are blocked for one minute due to too many failed login attempts.",
|
||||
"manualLogin": "Or sign in manually at {url} in your browser using these credentials:",
|
||||
"noClients": "No clients yet. No one can connect yet.",
|
||||
"password": "Password",
|
||||
"passwordOnce": "This password is shown only once. Scan the QR code or copy it now. You won't be able to see it again.",
|
||||
"qrInstall": "Install the evcc app for {ios} or {android}.",
|
||||
"qrScan": "Scan the code with your phone's camera to connect. Click it, if you're already using your phone.",
|
||||
"removeClient": "Remove client",
|
||||
"title": "Remote Access",
|
||||
"url": "Public URL",
|
||||
"username": "Username"
|
||||
},
|
||||
"section": {
|
||||
"additionalMeter": "Additional meters",
|
||||
"general": "General",
|
||||
|
|
|
|||
255
package-lock.json
generated
255
package-lock.json
generated
|
|
@ -22,6 +22,7 @@
|
|||
"dayjs": "^1.11.20",
|
||||
"echarts": "^6.0.0",
|
||||
"monaco-editor": "0.52.2",
|
||||
"qrcode": "^1.5.4",
|
||||
"smoothscroll-polyfill": "^0.4.4",
|
||||
"snarkdown": "^2.0.0",
|
||||
"vue": "^3.5.31",
|
||||
|
|
@ -40,6 +41,7 @@
|
|||
"@types/bootstrap": "^5.2.10",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/kill-port": "^2.0.3",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/smoothscroll-polyfill": "^0.3.4",
|
||||
"@types/wait-on": "^5.3.4",
|
||||
"@types/ws": "^8.18.1",
|
||||
|
|
@ -3435,6 +3437,16 @@
|
|||
"undici-types": "~7.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/qrcode": {
|
||||
"version": "1.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
|
||||
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/readable-stream": {
|
||||
"version": "4.0.23",
|
||||
"resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-4.0.23.tgz",
|
||||
|
|
@ -4439,7 +4451,6 @@
|
|||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
|
|
@ -4449,7 +4460,6 @@
|
|||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
|
|
@ -5115,6 +5125,15 @@
|
|||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/camelcase": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001780",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001780.tgz",
|
||||
|
|
@ -5324,7 +5343,6 @@
|
|||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
|
|
@ -5337,7 +5355,6 @@
|
|||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
|
|
@ -5655,6 +5672,15 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decamelize": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/deep-eql": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
|
||||
|
|
@ -5790,6 +5816,12 @@
|
|||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/dijkstrajs": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/doctrine": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
|
||||
|
|
@ -6993,7 +7025,6 @@
|
|||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
|
|
@ -7686,7 +7717,6 @@
|
|||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
|
|
@ -9165,6 +9195,15 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/p-try": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/package-json-from-dist": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
||||
|
|
@ -9215,7 +9254,6 @@
|
|||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
|
|
@ -9346,6 +9384,15 @@
|
|||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/pngjs": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/possible-typed-array-names": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||
|
|
@ -9702,6 +9749,173 @@
|
|||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dijkstrajs": "^1.0.1",
|
||||
"pngjs": "^5.0.0",
|
||||
"yargs": "^15.3.1"
|
||||
},
|
||||
"bin": {
|
||||
"qrcode": "bin/qrcode"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/cliui": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.0",
|
||||
"wrap-ansi": "^6.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/qrcode/node_modules/find-up": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"locate-path": "^5.0.0",
|
||||
"path-exists": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/locate-path": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-locate": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/p-limit": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-try": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/p-locate": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
||||
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-limit": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/wrap-ansi": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/y18n": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/qrcode/node_modules/yargs": {
|
||||
"version": "15.4.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^6.0.0",
|
||||
"decamelize": "^1.2.0",
|
||||
"find-up": "^4.1.0",
|
||||
"get-caller-file": "^2.0.1",
|
||||
"require-directory": "^2.1.1",
|
||||
"require-main-filename": "^2.0.0",
|
||||
"set-blocking": "^2.0.0",
|
||||
"string-width": "^4.2.0",
|
||||
"which-module": "^2.0.0",
|
||||
"y18n": "^4.0.0",
|
||||
"yargs-parser": "^18.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/yargs-parser": {
|
||||
"version": "18.1.3",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"camelcase": "^5.0.0",
|
||||
"decamelize": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.15.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",
|
||||
|
|
@ -9997,6 +10211,21 @@
|
|||
"regjsparser": "bin/parser"
|
||||
}
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-main-filename": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.11",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||
|
|
@ -10298,6 +10527,12 @@
|
|||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/set-blocking": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/set-function-length": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
||||
|
|
@ -12470,6 +12705,12 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/which-module": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/which-typed-array": {
|
||||
"version": "1.1.20",
|
||||
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz",
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@
|
|||
"dayjs": "^1.11.20",
|
||||
"echarts": "^6.0.0",
|
||||
"monaco-editor": "0.52.2",
|
||||
"qrcode": "^1.5.4",
|
||||
"smoothscroll-polyfill": "^0.4.4",
|
||||
"snarkdown": "^2.0.0",
|
||||
"vue": "^3.5.31",
|
||||
|
|
@ -62,6 +63,7 @@
|
|||
"@types/bootstrap": "^5.2.10",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/kill-port": "^2.0.3",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/smoothscroll-polyfill": "^0.3.4",
|
||||
"@types/wait-on": "^5.3.4",
|
||||
"@types/ws": "^8.18.1",
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import (
|
|||
"github.com/evcc-io/evcc/hems/shm"
|
||||
"github.com/evcc-io/evcc/server/assets"
|
||||
"github.com/evcc-io/evcc/server/eebus"
|
||||
"github.com/evcc-io/evcc/server/remote"
|
||||
"github.com/evcc-io/evcc/server/service"
|
||||
"github.com/evcc-io/evcc/util"
|
||||
"github.com/evcc-io/evcc/util/auth"
|
||||
|
|
@ -225,7 +226,7 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API) {
|
|||
}
|
||||
|
||||
// RegisterSystemHandler provides system level handlers
|
||||
func (s *HTTPd) RegisterSystemHandler(site *core.Site, pub publisher, cache *util.ParamCache, auth auth.Auth, shutdown func(), configFile string) {
|
||||
func (s *HTTPd) RegisterSystemHandler(site *core.Site, pub publisher, cache *util.ParamCache, auth auth.Auth, shutdown func(), configFile string, remoteAccess *remote.Remote) {
|
||||
router := s.Server.Handler.(*mux.Router)
|
||||
|
||||
// api
|
||||
|
|
@ -299,6 +300,13 @@ func (s *HTTPd) RegisterSystemHandler(site *core.Site, pub publisher, cache *uti
|
|||
"optimizer": {"POST", "/optimizer/{value:[01truefalse]+}", boolHandler(setOptimizer(pub), getOptimizer)},
|
||||
}
|
||||
|
||||
if remoteAccess != nil {
|
||||
routes["remote"] = route{"POST", "/remote/{value:[01truefalse]+}", boolHandler(remoteAccess.Enable, remoteAccess.Enabled)}
|
||||
routes["remoteclients"] = route{"GET", "/remote/clients", remoteClientsHandler(remoteAccess)}
|
||||
routes["createremoteclient"] = route{"POST", "/remote/clients", createRemoteClientHandler(remoteAccess)}
|
||||
routes["deleteremoteclient"] = route{"DELETE", "/remote/clients", deleteRemoteClientHandler(remoteAccess)}
|
||||
}
|
||||
|
||||
// yaml handlers
|
||||
for key, fun := range map[string]func() (any, any){
|
||||
keys.Hems: func() (any, any) { return map[string]any{}, config.Typed{} },
|
||||
|
|
|
|||
62
server/http_remote_handler.go
Normal file
62
server/http_remote_handler.go
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/evcc-io/evcc/server/remote"
|
||||
)
|
||||
|
||||
// remoteClientsHandler returns the list of remote tunnel clients.
|
||||
func remoteClientsHandler(r *remote.Remote) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, _ *http.Request) {
|
||||
jsonWrite(w, r.Clients())
|
||||
}
|
||||
}
|
||||
|
||||
// createRemoteClientHandler creates a new tunnel client and returns the
|
||||
// cleartext password (shown to the user only once).
|
||||
func createRemoteClientHandler(r *remote.Remote) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, req *http.Request) {
|
||||
var body struct {
|
||||
Username string `json:"username"`
|
||||
ExpiresIn int64 `json:"expiresIn"` // seconds; 0 = never
|
||||
}
|
||||
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
client, password, err := r.CreateClient(body.Username, time.Duration(body.ExpiresIn)*time.Second)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
jsonWrite(w, struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
|
||||
}{
|
||||
Username: client.Username,
|
||||
Password: password,
|
||||
CreatedAt: client.CreatedAt,
|
||||
ExpiresAt: client.ExpiresAt,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// deleteRemoteClientHandler removes a tunnel client by username.
|
||||
// Username is passed as a query parameter to allow arbitrary characters.
|
||||
func deleteRemoteClientHandler(r *remote.Remote) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, req *http.Request) {
|
||||
username := req.URL.Query().Get("username")
|
||||
if err := r.DeleteClient(username); err != nil {
|
||||
jsonError(w, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
jsonWrite(w, true)
|
||||
}
|
||||
}
|
||||
166
server/remote/clients.go
Normal file
166
server/remote/clients.go
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
package remote
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/evcc-io/evcc/core/keys"
|
||||
"github.com/evcc-io/evcc/server/db/settings"
|
||||
"github.com/samber/lo"
|
||||
"github.com/sethvargo/go-password/password"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// dummyHash is a bcrypt hash of a random value, used to make the
|
||||
// "unknown user" path take the same time as a real password check and
|
||||
// prevent username enumeration via timing side channels.
|
||||
var dummyHash []byte
|
||||
|
||||
func init() {
|
||||
buf := make([]byte, 16)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
h, err := bcrypt.GenerateFromPassword(buf, bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
dummyHash = h
|
||||
}
|
||||
|
||||
// Client is a single tunnel basic-auth credential used by a remote client.
|
||||
type Client struct {
|
||||
Username string `json:"username"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
|
||||
}
|
||||
|
||||
type persistedClient struct {
|
||||
Client
|
||||
Hash string `json:"hash"`
|
||||
}
|
||||
|
||||
// loadClients reads the persisted client list.
|
||||
func loadClients() []persistedClient {
|
||||
var res []persistedClient
|
||||
_ = settings.Json(keys.RemoteClients, &res)
|
||||
return res
|
||||
}
|
||||
|
||||
// saveClients persists the given client list.
|
||||
func saveClients(list []persistedClient) error {
|
||||
return settings.SetJson(keys.RemoteClients, list)
|
||||
}
|
||||
|
||||
// generatePassword returns a crypto-random alphanumeric password
|
||||
// with 20 characters including 4 digits (~96 bits of entropy).
|
||||
func generatePassword() (string, error) {
|
||||
return password.Generate(20, 4, 0, false, false)
|
||||
}
|
||||
|
||||
// Clients returns the list of configured clients (without password hashes).
|
||||
func (r *Remote) Clients() []Client {
|
||||
return lo.Map(loadClients(), func(c persistedClient, _ int) Client {
|
||||
return c.Client
|
||||
})
|
||||
}
|
||||
|
||||
// CreateClient creates a new client with an auto-generated password.
|
||||
// expiresIn <= 0 means the client never expires.
|
||||
// Returns the cleartext password (shown to the user only once).
|
||||
func (r *Remote) CreateClient(username string, expiresIn time.Duration) (Client, string, error) {
|
||||
username = strings.TrimSpace(username)
|
||||
if username == "" {
|
||||
return Client{}, "", errors.New("username required")
|
||||
}
|
||||
// RFC 7617: ":" is the basic-auth separator; reject control chars too.
|
||||
for _, r := range username {
|
||||
if r == ':' || r < 0x20 || r == 0x7f {
|
||||
return Client{}, "", errors.New("username contains invalid characters")
|
||||
}
|
||||
}
|
||||
|
||||
var expires *time.Time
|
||||
if expiresIn > 0 {
|
||||
expires = new(time.Now().Add(expiresIn))
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
list := loadClients()
|
||||
for _, c := range list {
|
||||
if c.Username == username {
|
||||
return Client{}, "", fmt.Errorf("client %q already exists", username)
|
||||
}
|
||||
}
|
||||
|
||||
password, err := generatePassword()
|
||||
if err != nil {
|
||||
return Client{}, "", err
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return Client{}, "", err
|
||||
}
|
||||
|
||||
c := persistedClient{
|
||||
Client: Client{
|
||||
Username: username,
|
||||
CreatedAt: time.Now(),
|
||||
ExpiresAt: expires,
|
||||
},
|
||||
Hash: string(hash),
|
||||
}
|
||||
list = append(list, c)
|
||||
if err := saveClients(list); err != nil {
|
||||
return Client{}, "", err
|
||||
}
|
||||
|
||||
return c.Client, password, nil
|
||||
}
|
||||
|
||||
// DeleteClient removes a client by username.
|
||||
func (r *Remote) DeleteClient(username string) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
list := loadClients()
|
||||
idx := slices.IndexFunc(list, func(c persistedClient) bool {
|
||||
return c.Username == username
|
||||
})
|
||||
|
||||
if idx == -1 {
|
||||
return fmt.Errorf("client %s not found", username)
|
||||
}
|
||||
|
||||
return saveClients(slices.Delete(list, idx, idx+1))
|
||||
}
|
||||
|
||||
// Authenticate validates basic-auth credentials. Always runs bcrypt
|
||||
// (against a dummy hash on miss) to prevent username enumeration via timing.
|
||||
func (r *Remote) Authenticate(username, password string) bool {
|
||||
hash := dummyHash
|
||||
var found *persistedClient
|
||||
for _, c := range loadClients() {
|
||||
if c.Username == username {
|
||||
found = &c
|
||||
hash = []byte(c.Hash)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
valid := bcrypt.CompareHashAndPassword(hash, []byte(password)) == nil
|
||||
if !valid || found == nil {
|
||||
return false
|
||||
}
|
||||
if found.ExpiresAt != nil && time.Now().After(*found.ExpiresAt) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
51
server/remote/ratelimit.go
Normal file
51
server/remote/ratelimit.go
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
package remote
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// authRateLimiter tracks failed authentication attempts in a sliding window.
|
||||
// When the failure count exceeds the threshold, further attempts are blocked
|
||||
// to prevent brute-force attacks.
|
||||
type authRateLimiter struct {
|
||||
mu sync.Mutex
|
||||
failures []time.Time
|
||||
window time.Duration
|
||||
max int
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func newAuthRateLimiter() *authRateLimiter {
|
||||
return &authRateLimiter{
|
||||
window: time.Minute,
|
||||
max: 10,
|
||||
now: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
// allow checks whether an authentication attempt should proceed.
|
||||
func (rl *authRateLimiter) allow() bool {
|
||||
rl.mu.Lock()
|
||||
defer rl.mu.Unlock()
|
||||
|
||||
cutoff := rl.now().Add(-rl.window)
|
||||
|
||||
// prune old entries
|
||||
valid := rl.failures[:0]
|
||||
for _, t := range rl.failures {
|
||||
if t.After(cutoff) {
|
||||
valid = append(valid, t)
|
||||
}
|
||||
}
|
||||
rl.failures = valid
|
||||
|
||||
return len(rl.failures) < rl.max
|
||||
}
|
||||
|
||||
// fail records a failed authentication attempt.
|
||||
func (rl *authRateLimiter) fail() {
|
||||
rl.mu.Lock()
|
||||
defer rl.mu.Unlock()
|
||||
rl.failures = append(rl.failures, rl.now())
|
||||
}
|
||||
70
server/remote/ratelimit_test.go
Normal file
70
server/remote/ratelimit_test.go
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
package remote
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestAuthRateLimiter(t *testing.T) {
|
||||
t.Run("allows requests under threshold", func(t *testing.T) {
|
||||
rl := newAuthRateLimiter()
|
||||
|
||||
for range rl.max {
|
||||
assert.True(t, rl.allow())
|
||||
rl.fail()
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("blocks after threshold", func(t *testing.T) {
|
||||
rl := newAuthRateLimiter()
|
||||
|
||||
for range rl.max {
|
||||
rl.fail()
|
||||
}
|
||||
|
||||
assert.False(t, rl.allow())
|
||||
})
|
||||
|
||||
t.Run("recovers after window expires", func(t *testing.T) {
|
||||
now := time.Now()
|
||||
var mu sync.Mutex
|
||||
|
||||
rl := newAuthRateLimiter()
|
||||
rl.now = func() time.Time {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return now
|
||||
}
|
||||
|
||||
for range rl.max {
|
||||
rl.fail()
|
||||
}
|
||||
|
||||
assert.False(t, rl.allow())
|
||||
|
||||
// advance past window
|
||||
mu.Lock()
|
||||
now = now.Add(rl.window + time.Second)
|
||||
mu.Unlock()
|
||||
|
||||
assert.True(t, rl.allow())
|
||||
})
|
||||
|
||||
t.Run("successful auth does not count as failure", func(t *testing.T) {
|
||||
rl := newAuthRateLimiter()
|
||||
|
||||
// fill up to max-1 failures
|
||||
for range rl.max - 1 {
|
||||
rl.fail()
|
||||
}
|
||||
|
||||
// allow should still work (no fail() call = successful auth)
|
||||
assert.True(t, rl.allow())
|
||||
|
||||
// still under threshold
|
||||
assert.True(t, rl.allow())
|
||||
})
|
||||
}
|
||||
228
server/remote/remote.go
Normal file
228
server/remote/remote.go
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
package remote
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/evcc-io/evcc/api/globalconfig"
|
||||
"github.com/evcc-io/evcc/cmd/shutdown"
|
||||
"github.com/evcc-io/evcc/core/keys"
|
||||
"github.com/evcc-io/evcc/server/db/settings"
|
||||
"github.com/evcc-io/evcc/util"
|
||||
"github.com/evcc-io/evcc/util/request"
|
||||
"github.com/evcc-io/evcc/util/sponsor"
|
||||
)
|
||||
|
||||
// Settings is the persisted remote access configuration.
|
||||
type Settings struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Token string `json:"token,omitempty"`
|
||||
TunnelURL string `json:"tunnelUrl,omitempty"`
|
||||
}
|
||||
|
||||
// Remote manages the remote access tunnel lifecycle.
|
||||
type Remote struct {
|
||||
mu sync.Mutex
|
||||
cloudHost string
|
||||
settings Settings
|
||||
tunnel *Tunnel
|
||||
httpHandler http.Handler
|
||||
log *util.Logger
|
||||
publisher chan<- util.Param
|
||||
lastSeen map[string]time.Time // persisted: username → last activity
|
||||
connected map[string]int // in-memory: active connection count per user
|
||||
}
|
||||
|
||||
// New creates a new Remote manager, loads persisted settings, and connects if enabled.
|
||||
func New(cloudHost string, httpHandler http.Handler, valueChan chan<- util.Param) *Remote {
|
||||
r := &Remote{
|
||||
cloudHost: cloudHost,
|
||||
httpHandler: httpHandler,
|
||||
log: util.NewLogger("remote"),
|
||||
publisher: valueChan,
|
||||
lastSeen: make(map[string]time.Time),
|
||||
connected: make(map[string]int),
|
||||
}
|
||||
|
||||
// load saved settings
|
||||
_ = settings.Json(keys.Remote, &r.settings)
|
||||
_ = settings.Json(keys.RemoteLastSeen, &r.lastSeen)
|
||||
|
||||
if r.settings.Enabled && r.settings.Token != "" {
|
||||
go r.connect()
|
||||
}
|
||||
|
||||
shutdown.Register(r.disconnect)
|
||||
|
||||
go func() {
|
||||
for range time.Tick(time.Minute) {
|
||||
r.publish()
|
||||
}
|
||||
}()
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
// Enable enables or disables remote access. When enabling for the first time,
|
||||
// it registers with the cloud to obtain a URL and token.
|
||||
func (r *Remote) Enable(enable bool) error {
|
||||
r.mu.Lock()
|
||||
r.settings.Enabled = enable
|
||||
r.saveSettings()
|
||||
r.mu.Unlock()
|
||||
|
||||
if enable {
|
||||
// TODO why do we need a go routine for this?
|
||||
go r.connect()
|
||||
} else {
|
||||
r.disconnect()
|
||||
}
|
||||
|
||||
r.publish()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Enabled returns whether remote access is enabled.
|
||||
func (r *Remote) Enabled() bool {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.settings.Enabled
|
||||
}
|
||||
|
||||
func (r *Remote) connect() {
|
||||
r.mu.Lock()
|
||||
token := r.settings.Token
|
||||
r.mu.Unlock()
|
||||
|
||||
if token == "" {
|
||||
if err := r.register(); err != nil {
|
||||
r.log.ERROR.Printf("registration failed: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
r.log.INFO.Printf("remote access via %s", r.settings.URL)
|
||||
|
||||
tunnel := NewTunnel(r.settings.TunnelURL, r.settings.Token, r.httpHandler, r.Authenticate, r.TrackActivity, r.log, r.publish)
|
||||
|
||||
r.mu.Lock()
|
||||
r.tunnel = tunnel
|
||||
r.mu.Unlock()
|
||||
|
||||
// blocks until disconnected
|
||||
tunnel.run()
|
||||
}
|
||||
|
||||
func (r *Remote) disconnect() {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if r.tunnel != nil {
|
||||
r.tunnel.Close()
|
||||
r.tunnel = nil
|
||||
}
|
||||
}
|
||||
|
||||
type registerRequest struct {
|
||||
SponsorToken string `json:"sponsorToken"`
|
||||
}
|
||||
|
||||
type registerResponse struct {
|
||||
URL string `json:"url"`
|
||||
Token string `json:"token"`
|
||||
TunnelURL string `json:"tunnelUrl"`
|
||||
}
|
||||
|
||||
// register calls the cloud registration endpoint and persists the result.
|
||||
func (r *Remote) register() error {
|
||||
uri := fmt.Sprintf("https://%s/api/register", r.cloudHost)
|
||||
data := registerRequest{SponsorToken: sponsor.Token}
|
||||
req, _ := request.New(http.MethodPost, uri, request.MarshalJSON(data), request.JSONEncoding)
|
||||
|
||||
var res registerResponse
|
||||
|
||||
client := request.NewHelper(r.log)
|
||||
if err := client.DoJSON(req, &res); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
r.settings.URL = res.URL
|
||||
r.settings.Token = res.Token
|
||||
r.settings.TunnelURL = res.TunnelURL
|
||||
r.saveSettings()
|
||||
r.mu.Unlock()
|
||||
|
||||
r.log.INFO.Printf("registered as %s", res.URL)
|
||||
return nil
|
||||
}
|
||||
|
||||
// TrackActivity tracks remote client connections and disconnections.
|
||||
func (r *Remote) TrackActivity(username string, active bool) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if active {
|
||||
r.lastSeen[username] = time.Now()
|
||||
r.connected[username]++
|
||||
} else if r.connected[username] > 0 {
|
||||
r.connected[username]--
|
||||
}
|
||||
}
|
||||
|
||||
// saveSettings persists the current settings. Must be called with mu held.
|
||||
func (r *Remote) saveSettings() {
|
||||
if err := settings.SetJson(keys.Remote, r.settings); err != nil {
|
||||
r.log.ERROR.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
// ConfigStatus returns the current remote access config and status.
|
||||
func (r *Remote) ConfigStatus() globalconfig.ConfigStatus {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
connected := r.tunnel != nil && r.tunnel.IsConnected()
|
||||
loginBlocked := r.tunnel != nil && r.tunnel.LoginBlocked()
|
||||
|
||||
return globalconfig.ConfigStatus{
|
||||
Config: struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}{
|
||||
Enabled: r.settings.Enabled,
|
||||
},
|
||||
Status: struct {
|
||||
Connected bool `json:"connected"`
|
||||
URL string `json:"url,omitempty"`
|
||||
LoginBlocked bool `json:"loginBlocked"`
|
||||
LastSeen map[string]time.Time `json:"lastSeen,omitempty"`
|
||||
}{
|
||||
Connected: connected,
|
||||
URL: r.settings.URL,
|
||||
LoginBlocked: loginBlocked,
|
||||
LastSeen: r.lastSeen,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// publish sends the current status to the UI via the value channel.
|
||||
func (r *Remote) publish() {
|
||||
if r.publisher == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// refresh lastSeen for open connections (auth only fires once)
|
||||
r.mu.Lock()
|
||||
now := time.Now()
|
||||
for user, count := range r.connected {
|
||||
if count > 0 {
|
||||
r.lastSeen[user] = now
|
||||
}
|
||||
}
|
||||
_ = settings.SetJson(keys.RemoteLastSeen, r.lastSeen)
|
||||
r.mu.Unlock()
|
||||
|
||||
r.publisher <- util.Param{Key: keys.Remote, Val: r.ConfigStatus()}
|
||||
}
|
||||
199
server/remote/tunnel.go
Normal file
199
server/remote/tunnel.go
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
package remote
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/cenkalti/backoff/v4"
|
||||
"github.com/coder/websocket"
|
||||
"github.com/evcc-io/evcc/util"
|
||||
"github.com/evcc-io/evcc/util/sponsor"
|
||||
"github.com/hashicorp/yamux"
|
||||
)
|
||||
|
||||
// Tunnel manages a WebSocket+yamux tunnel to the cloud proxy.
|
||||
type Tunnel struct {
|
||||
tunnelURL string
|
||||
token string
|
||||
httpHandler http.Handler
|
||||
authenticate func(user, pass string) bool
|
||||
trackActivity func(username string, active bool)
|
||||
log *util.Logger
|
||||
cancel func()
|
||||
onStateChange func()
|
||||
rateLimiter *authRateLimiter
|
||||
|
||||
mu sync.Mutex
|
||||
session *yamux.Session
|
||||
}
|
||||
|
||||
// NewTunnel creates a new tunnel client.
|
||||
func NewTunnel(tunnelURL, token string, httpHandler http.Handler, authenticate func(user, pass string) bool, trackActivity func(string, bool), log *util.Logger, onStateChange func()) *Tunnel {
|
||||
return &Tunnel{
|
||||
tunnelURL: tunnelURL,
|
||||
token: token,
|
||||
httpHandler: httpHandler,
|
||||
authenticate: authenticate,
|
||||
trackActivity: trackActivity,
|
||||
log: log,
|
||||
onStateChange: onStateChange,
|
||||
rateLimiter: newAuthRateLimiter(),
|
||||
}
|
||||
}
|
||||
|
||||
// run establishes the tunnel and reconnects on failure.
|
||||
func (t *Tunnel) run() {
|
||||
bo := backoff.NewExponentialBackOff(
|
||||
backoff.WithInitialInterval(time.Second),
|
||||
backoff.WithMaxInterval(60*time.Second),
|
||||
backoff.WithMaxElapsedTime(0),
|
||||
)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.cancel = cancel
|
||||
|
||||
for {
|
||||
ok, err := t.connect(ctx)
|
||||
if err != nil && !errors.Is(err, context.Canceled) {
|
||||
t.log.ERROR.Printf("tunnel: %v", err)
|
||||
}
|
||||
|
||||
// reset backoff after successful connection
|
||||
if ok {
|
||||
bo.Reset()
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(bo.NextBackOff()):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Tunnel) connect(ctx context.Context) (bool, error) {
|
||||
conn, _, err := websocket.Dial(ctx, t.tunnelURL, &websocket.DialOptions{
|
||||
HTTPHeader: http.Header{
|
||||
"Authorization": []string{"Bearer " + t.token},
|
||||
"X-Sponsor-Token": []string{sponsor.Token},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("websocket dial: %w", err)
|
||||
}
|
||||
|
||||
netConn := websocket.NetConn(ctx, conn, websocket.MessageBinary)
|
||||
|
||||
config := yamux.DefaultConfig()
|
||||
config.LogOutput = t.log.TRACE.Writer()
|
||||
|
||||
session, err := yamux.Client(netConn, config)
|
||||
if err != nil {
|
||||
netConn.Close() // closes the underlying socket connection
|
||||
return false, fmt.Errorf("yamux client: %w", err)
|
||||
}
|
||||
|
||||
t.changeState(session, nil)
|
||||
|
||||
// accept streams from the proxy
|
||||
srv := &http.Server{
|
||||
Handler: t.basicAuthMiddleware(t.httpHandler),
|
||||
}
|
||||
|
||||
if err := srv.Serve(session); err != nil {
|
||||
t.changeState(nil, err)
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (t *Tunnel) changeState(session *yamux.Session, err error) {
|
||||
t.mu.Lock()
|
||||
t.session = session
|
||||
t.mu.Unlock()
|
||||
|
||||
if t.onStateChange != nil {
|
||||
t.onStateChange()
|
||||
}
|
||||
|
||||
if session != nil {
|
||||
t.log.INFO.Println("tunnel connected")
|
||||
} else {
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, io.EOF) {
|
||||
t.log.INFO.Println("tunnel disconnected")
|
||||
} else {
|
||||
t.log.INFO.Println("tunnel disconnected:", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// IsConnected returns whether the tunnel is currently connected.
|
||||
func (t *Tunnel) IsConnected() bool {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
return t.session != nil
|
||||
}
|
||||
|
||||
// LoginBlocked returns whether login attempts are currently blocked by the rate limiter.
|
||||
func (t *Tunnel) LoginBlocked() bool {
|
||||
return !t.rateLimiter.allow()
|
||||
}
|
||||
|
||||
// Close tears down the tunnel.
|
||||
func (t *Tunnel) Close() {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
// close websocket; produces io.EOF in yamux which it handles silently
|
||||
if t.session != nil {
|
||||
t.session.Close() // closes the underlying socket connection
|
||||
}
|
||||
|
||||
if t.cancel != nil {
|
||||
t.cancel()
|
||||
t.cancel = nil
|
||||
}
|
||||
}
|
||||
|
||||
// basicAuthMiddleware wraps a handler with HTTP basic auth, validating
|
||||
// credentials against the given authenticate function per request.
|
||||
// It rate-limits failed attempts to prevent brute-force attacks.
|
||||
func (t *Tunnel) basicAuthMiddleware(next http.Handler) http.Handler {
|
||||
rejectAuth := func(w http.ResponseWriter) {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="evcc"`)
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
user, pass, ok := r.BasicAuth()
|
||||
if !ok || t.authenticate == nil {
|
||||
rejectAuth(w)
|
||||
return
|
||||
}
|
||||
|
||||
if !t.rateLimiter.allow() {
|
||||
t.log.INFO.Printf("login blocked for %q (rate limited)", user)
|
||||
http.Error(w, "Too many failed login attempts. Try again in 1 minute.", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
|
||||
if !t.authenticate(user, pass) {
|
||||
t.rateLimiter.fail()
|
||||
t.log.INFO.Printf("failed login attempt for %q", user)
|
||||
rejectAuth(w)
|
||||
return
|
||||
}
|
||||
|
||||
if t.trackActivity != nil {
|
||||
t.trackActivity(user, true)
|
||||
defer t.trackActivity(user, false) // long-running requests (ws)
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue