diff --git a/assets/js/components/Config/DeviceTags.vue b/assets/js/components/Config/DeviceTags.vue index e99acd7d8..60670cb0d 100644 --- a/assets/js/components/Config/DeviceTags.vue +++ b/assets/js/components/Config/DeviceTags.vue @@ -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"); diff --git a/assets/js/components/Config/Remote/RemoteClientCreate.vue b/assets/js/components/Config/Remote/RemoteClientCreate.vue new file mode 100644 index 000000000..96e915dbf --- /dev/null +++ b/assets/js/components/Config/Remote/RemoteClientCreate.vue @@ -0,0 +1,97 @@ + + + {{ $t("config.remote.addClientDescription") }} + + + + + + + + + + + + {{ opt.name }} + + + + + + + + {{ $t("config.general.cancel") }} + + + {{ $t("config.remote.createClient") }} + + + + + + + diff --git a/assets/js/components/Config/Remote/RemoteClientList.vue b/assets/js/components/Config/Remote/RemoteClientList.vue new file mode 100644 index 000000000..13ce440eb --- /dev/null +++ b/assets/js/components/Config/Remote/RemoteClientList.vue @@ -0,0 +1,102 @@ + + + {{ $t("config.remote.clients") }} + + + {{ client.username }} + + + {{ expiryLabel(client) }} + + + {{ $t("config.remote.active") }} + + + {{ activityLabel(client) }} + + + + + + + + + + {{ $t("config.remote.noClients") }} + + + + + {{ $t("config.remote.addClient") }} + + + + + + diff --git a/assets/js/components/Config/Remote/RemoteClientReveal.vue b/assets/js/components/Config/Remote/RemoteClientReveal.vue new file mode 100644 index 000000000..4e61bc346 --- /dev/null +++ b/assets/js/components/Config/Remote/RemoteClientReveal.vue @@ -0,0 +1,125 @@ + + + + + + + + iOS + + + + + Android + + + + + {{ $t("config.remote.qrScan") }} + + + + + + + + + + + + + {{ serverUrl }} + + + + + + + + + + + + + {{ $t("general.note") }} + {{ $t("config.remote.passwordOnce") }} + + + + + {{ $t("config.remote.done") }} + + + + + + + + diff --git a/assets/js/components/Config/Remote/RemoteModal.vue b/assets/js/components/Config/Remote/RemoteModal.vue new file mode 100644 index 000000000..e2b29e8d2 --- /dev/null +++ b/assets/js/components/Config/Remote/RemoteModal.vue @@ -0,0 +1,211 @@ + + + + Development preview. Not ready for general use. Use with caution and monitor your system + closely. Feedback welcome! + + + + + + {{ $t("config.remote.description") }} + + + + {{ $t("config.remote.enableLabel") }} + + + {{ $t("config.remote.connected") }} + + + {{ $t("config.remote.disconnected") }} + + + + + + + + + + + + + + {{ $t("config.remote.loginBlocked") }} + + + + + + + + + + + + + diff --git a/assets/js/components/MaterialIcon/RemoteAccess.vue b/assets/js/components/MaterialIcon/RemoteAccess.vue new file mode 100644 index 000000000..9d1b0401c --- /dev/null +++ b/assets/js/components/MaterialIcon/RemoteAccess.vue @@ -0,0 +1,18 @@ + + + + + + + diff --git a/assets/js/mixins/formatter.ts b/assets/js/mixins/formatter.ts index bf946f769..8562f5972 100644 --- a/assets/js/mixins/formatter.ts +++ b/assets/js/mixins/formatter.ts @@ -180,6 +180,10 @@ export default defineComponent({ const formatter = new Intl.DurationFormat(this.$i18n?.locale, { style }); return formatter.format({ minutes, hours }); }, + fmtDurationParts(parts: Record) { + // @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"); diff --git a/assets/js/types/evcc.ts b/assets/js/types/evcc.ts index d53863682..6f613f2d4 100644 --- a/assets/js/types/evcc.ts +++ b/assets/js/types/evcc.ts @@ -93,6 +93,7 @@ export interface State { shm?: ShmConfig; sponsor?: ConfigStatus; eebus?: ConfigStatus; + remote?: Remote; modbusproxy?: ModbusProxy[]; messaging?: ConfigStatus; messagingEvents?: MessagingEvents; @@ -484,6 +485,29 @@ export type Certificate = { private: string; }; +export type Remote = ConfigStatus; + +export type RemoteConfig = { + enabled: boolean; +}; + +export type RemoteStatus = { + connected: boolean; + url?: string; + loginBlocked: boolean; + lastSeen?: Record; +}; + +export type RemoteClient = { + username: string; + createdAt: string; + expiresAt?: string; +}; + +export type RemoteClientCreated = RemoteClient & { + password: string; +}; + export type Eebus = ConfigStatus; export type EebusConfig = { diff --git a/assets/js/utils/remote.ts b/assets/js/utils/remote.ts new file mode 100644 index 000000000..c96617b2a --- /dev/null +++ b/assets/js/utils/remote.ts @@ -0,0 +1,10 @@ +const ACTIVE_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes + +export function isRemoteClientActive( + lastSeen: Record | undefined, + username: string +): boolean { + const seen = lastSeen?.[username]; + if (!seen) return false; + return Date.now() - new Date(seen).getTime() < ACTIVE_THRESHOLD_MS; +} diff --git a/assets/js/views/Config.vue b/assets/js/views/Config.vue index affb87be4..e6fba841d 100644 --- a/assets/js/views/Config.vue +++ b/assets/js/views/Config.vue @@ -321,6 +321,19 @@ + + + + + + + @@ -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; }, diff --git a/cmd/root.go b/cmd/root.go index a1d10adab..be00a6c64 100644 --- a/cmd/root.go +++ b/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 { diff --git a/core/keys/global.go b/core/keys/global.go index f8fa5eba1..354ad27bc 100644 --- a/core/keys/global.go +++ b/core/keys/global.go @@ -30,6 +30,9 @@ const ( Telemetry = "telemetry" Optimizer = "optimizer" DemoMode = "demoMode" + Remote = "remote" + RemoteClients = "remoteClients" + RemoteLastSeen = "remoteLastSeen" AuthDisabled = "authDisabled" AuthProviders = "authProviders" ) diff --git a/go.mod b/go.mod index c597feb3e..27a5a776b 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index 405e56c3c..427b45073 100644 --- a/go.sum +++ b/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= diff --git a/i18n/de.json b/i18n/de.json index 4e34cb620..3e24344f7 100644 --- a/i18n/de.json +++ b/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", diff --git a/i18n/en.json b/i18n/en.json index f87439e35..b17667740 100644 --- a/i18n/en.json +++ b/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", diff --git a/package-lock.json b/package-lock.json index 829f0c83a..55d5578b7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 17d725fde..ee0fe7d6f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/server/http.go b/server/http.go index a0c4cfe4f..a39d2d45a 100644 --- a/server/http.go +++ b/server/http.go @@ -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{} }, diff --git a/server/http_remote_handler.go b/server/http_remote_handler.go new file mode 100644 index 000000000..5b7b417a2 --- /dev/null +++ b/server/http_remote_handler.go @@ -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) + } +} diff --git a/server/remote/clients.go b/server/remote/clients.go new file mode 100644 index 000000000..f4907aec6 --- /dev/null +++ b/server/remote/clients.go @@ -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 +} diff --git a/server/remote/ratelimit.go b/server/remote/ratelimit.go new file mode 100644 index 000000000..76c43930b --- /dev/null +++ b/server/remote/ratelimit.go @@ -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()) +} diff --git a/server/remote/ratelimit_test.go b/server/remote/ratelimit_test.go new file mode 100644 index 000000000..f0f1a7077 --- /dev/null +++ b/server/remote/ratelimit_test.go @@ -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()) + }) +} diff --git a/server/remote/remote.go b/server/remote/remote.go new file mode 100644 index 000000000..80158dc10 --- /dev/null +++ b/server/remote/remote.go @@ -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()} +} diff --git a/server/remote/tunnel.go b/server/remote/tunnel.go new file mode 100644 index 000000000..303acf92b --- /dev/null +++ b/server/remote/tunnel.go @@ -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) + }) +}
{{ $t("config.remote.addClientDescription") }}
+ + + {{ serverUrl }} + + +
{{ $t("config.remote.description") }}