Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/host-http-patch-write-verb.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
"ftw": minor
---

Lua host: `host.http_patch(url, body, headers)` — the verb REST device APIs
use for state-changing writes, added for the NIBE Solar PV surplus feed
(srcfl/ftw#537). It is gated by a new, explicit `capabilities.http.allow_write`
beyond the plain `capabilities.http` grant, so granting HTTP for telemetry
never implicitly grants the ability to mutate a device. Scope is exactly
PATCH: `http_get` stays a read and `http_post` stays under the plain grant
unchanged (existing drivers POST to query-style APIs), so no existing HTTP
driver changes behaviour. Without the grant a driver gets an error string and
the request never leaves the host.

The verb shares the allowlist, TLS-pinning, 1 MB response cap and managed-write
accounting of the other verbs, and — unlike `http_post` — refuses to follow
redirects: Go re-issues a redirected `PATCH` (301/302/303) as a body-less GET,
which would otherwise report success for a device write that never landed.
13 changes: 12 additions & 1 deletion config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -105,18 +105,29 @@ drivers:
# "fingeravtryck" shown in the myUplink app, or from `openssl s_client
# -connect <ip>:8443 | openssl x509 -fingerprint -sha256`). The host then
# trusts exactly that one cert — never a blanket insecure-skip-verify.
# Leave the pump in read-only mode (aidMode off); this driver never writes.
# Read-only by default: the driver issues no writes at all. (The pump-side
# write gate is the installer's read-only/read-write choice for the Local
# REST API, menu 7.5.15 — NOT "aid mode", which is a compressor-off fault
# state unrelated to API permissions.) One opt-in write path exists — the
# pump's native Solar PV surplus feed (srcfl/ftw#537, developed in
# srcfl/device-drivers). It needs BOTH the host capabilities.http.allow_write
# grant below AND write.solar_pv: true in the driver config; leave either off
# and the driver stays read-only.
# - name: nibe
# lua: drivers/nibe_local.lua
# config:
# host: 192.168.1.180
# port: 8443
# username: <local-api-username>
# password: <local-api-password>
# # write: # opt-in Solar PV feed (off unless present)
# # solar_pv: true
# # max_w: 9000 # REQUIRED clamp ceiling (site PV nameplate, W)
# capabilities:
# http:
# allowed_hosts: ["192.168.1.180:8443"]
# tls_pin_sha256: "<64-hex-char certificate fingerprint>"
# # allow_write: true # host gate for host.http_patch; default off

api:
port: 8080
Expand Down
28 changes: 25 additions & 3 deletions docs/nibe-local.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,29 @@ S320, …) directly over your LAN through the pump's **Local REST API**. It is
the on-prem twin of the MyUplink cloud driver (`drivers/myuplink.lua`): same
`hp_*` telemetry, no cloud account, no OAuth, no internet round-trip.

It is **read-only**. The pump is left in `aidMode: off` and the driver issues
no writes — it cannot actuate anything, so it cannot cause harm.
The bundled driver is **read-only** — it issues no writes, so it cannot
actuate anything. Note the write gate on the pump side is the installer's
read-only / read-write choice for the Local REST API (installer menu 7.5.15),
**not** "aid mode": aid mode is the pump's compressor-off fault-recovery
state and has nothing to do with API permissions. (Earlier revisions of this
document conflated the two.) A pump whose API is read-only silently refuses
writes — HTTP 200 with a per-point `error: read only value` result.

The driver's opt-in **write path** (feeding the pump's native Solar PV
surplus input, registers 2107/2109) is developed in the driver catalog at
[srcfl/device-drivers](https://github.com/srcfl/device-drivers) and tracked
in [#537](https://github.com/srcfl/ftw/issues/537); it arrives here through
the signed driver channel, stays off without `write.solar_pv: true` plus
`capabilities.http.allow_write: true`, and needs a core with
`host.http_patch`.

**Decommissioning a write-enabled setup.** Every automatic safeguard around
the feed (dead-man's switch, default-mode clear, startup orphan sweep) runs
inside FTW — none of them can fire once FTW is gone, and the pump's own
timeout for a silently stopped feed is undocumented. Before uninstalling FTW
or permanently disabling the feed, turn the **Solar PV input (2107) off on
the pump** — or set the Local REST API back to **read-only (menu 7.5.15)**
— so no stale surplus value can stand with nobody left to clear it.

## Why the local API (vs. the cloud or raw Modbus)

Expand Down Expand Up @@ -36,7 +57,8 @@ flag. That means:
```

Use the hex value (colons and case don't matter — they're normalised).
3. Leave the pump in **read-only** mode for this driver.
3. Leave the Local REST API in **read-only** mode (installer menu 7.5.15)
unless you are deliberately enabling the Solar PV write path.

## Configuration

Expand Down
8 changes: 8 additions & 0 deletions go/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -844,6 +844,14 @@ type HTTPCapability struct {
// for this driver only; when empty, standard verification against the
// system roots applies (unchanged for every existing HTTP driver).
TLSPinSHA256 string `yaml:"tls_pin_sha256,omitempty" json:"tls_pin_sha256,omitempty"`
// AllowWrite grants host.http_patch — the verb REST device APIs use for
// state-changing writes — as a separate, explicit operator decision, the
// HTTP twin of a read-only Modbus driver versus one allowed to write
// registers. Scope is exactly http_patch: http_get stays a read and
// http_post stays under the plain HTTP grant (existing drivers POST to
// query-style APIs), so granting HTTP for telemetry never implicitly
// grants the ability to mutate a device. Default off.
AllowWrite bool `yaml:"allow_write,omitempty" json:"allow_write,omitempty"`
}

// WSCapability grants WebSocket (ws://, wss://) access. Same allowlist
Expand Down
14 changes: 13 additions & 1 deletion go/internal/drivers/host.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,14 @@ type HostEnv struct {
// verification (unchanged default for all existing HTTP drivers).
// Populated from driver config `capabilities.http.tls_pin_sha256`.
HTTPTLSPinSHA256 string
WS WSCap // nil → ws_* calls return ErrNoCapability
// HTTPAllowWrite gates host.http_patch, and only http_patch —
// http_get stays a read and http_post stays under the plain HTTP
// capability (existing drivers POST to query-style APIs). http_patch
// returns an error string unless this is set, so granting HTTP for
// telemetry never implicitly grants device mutation. Populated from
// driver config `capabilities.http.allow_write`.
HTTPAllowWrite bool
WS WSCap // nil → ws_* calls return ErrNoCapability
// WSAllowedHosts mirrors HTTPAllowedHosts but for ws://+wss:// URLs
// passed to host.ws_open. Same matching semantics; empty = any host.
WSAllowedHosts []string
Expand Down Expand Up @@ -383,6 +390,11 @@ func (h *HostEnv) WithSerial(s SerialCap) *HostEnv { h.Serial = s; return h }
// WithHTTP enables the HTTP capability.
func (h *HostEnv) WithHTTP() *HostEnv { h.HTTP = true; return h }

// WithHTTPAllowWrite grants the mutating HTTP verb host.http_patch. Scoped
// to http_patch only; http_get/http_post are unaffected. See
// HostEnv.HTTPAllowWrite.
func (h *HostEnv) WithHTTPAllowWrite() *HostEnv { h.HTTPAllowWrite = true; return h }

// WithHTTPAllowedHosts installs an allowlist. An empty / nil slice
// means "any host" (backward compatible). Matched against URL host.
func (h *HostEnv) WithHTTPAllowedHosts(hosts []string) *HostEnv {
Expand Down
71 changes: 71 additions & 0 deletions go/internal/drivers/lua.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
// host.json_encode(t) -- Lua table → JSON string
// host.http_get(url, headers) -- HTTP GET, returns (body, nil) or (nil, err)
// host.http_post(url, body, headers) -- HTTP POST, returns (body, nil) or (nil, err)
// host.http_patch(url, body, headers) -- HTTP PATCH (write); needs capabilities.http.allow_write
// host.ws_open(url, headers) -- open WebSocket; (true, nil) or (nil, err)
// host.ws_send(text) -- send one text frame; (true, nil) or (nil, err)
// host.ws_messages() -- drain inbound frames; "" entry = EOF
Expand Down Expand Up @@ -1072,6 +1073,8 @@ func registerHost(L *lua.LState, env *HostEnv) {
// ---- HTTP capability ----
// host.http_get(url, headers?) → (body, nil) or (nil, error_string)
// host.http_post(url, body, headers?) → (body, nil) or (nil, error_string)
// host.http_patch(url, body, headers?) → (body, nil) or (nil, error_string);
// the mutating verb, gated by capabilities.http.allow_write (default off)
// headers is an optional Lua table {["Content-Type"]="application/json", ...}
rawTLSPin := strings.TrimSpace(env.HTTPTLSPinSHA256)
tlsPin := normalizeHexFingerprint(rawTLSPin)
Expand Down Expand Up @@ -1153,6 +1156,13 @@ func registerHost(L *lua.LState, env *HostEnv) {
if len(via) >= 10 {
return fmt.Errorf("stopped after 10 redirects")
}
// A redirected PATCH is unsafe: Go re-issues 301/302/303 as a
// body-less GET, so the device write silently never lands while the
// call still reports success. Refuse it — a write must reach the
// host it was checked against, or fail loudly.
if len(via) > 0 && via[0].Method == "PATCH" {
return fmt.Errorf("redirect not followed for PATCH (a redirected write cannot be verified)")
}
if ok, reason := hostAllowed(req.URL.String()); !ok {
return fmt.Errorf("redirect blocked: %s", reason)
}
Expand Down Expand Up @@ -1297,6 +1307,67 @@ func registerHost(L *lua.LState, env *HostEnv) {
return 1
}))

// host.http_patch(url, body, headers?) → (body, nil) or (nil, error_string).
// The mutating verb REST device APIs use for state-changing writes (a NIBE
// heat pump's Solar PV surplus feed, srcfl/ftw#537). Unlike http_post it is
// gated by an explicit capabilities.http.allow_write beyond the plain HTTP
// grant, so granting HTTP for telemetry never implicitly grants the ability
// to mutate a device. Same allowlist, TLS pinning and 1MB response cap as
// the other verbs.
host.RawSetString("http_patch", L.NewFunction(func(L *lua.LState) int {
if !env.HTTP {
L.Push(lua.LNil)
L.Push(lua.LString("http: capability not granted"))
return 2
}
if !env.HTTPAllowWrite {
L.Push(lua.LNil)
L.Push(lua.LString("http: write not granted (set capabilities.http.allow_write)"))
return 2
}
if err := env.allowWrite("http.patch"); err != nil {
L.Push(lua.LNil)
L.Push(lua.LString(err.Error()))
return 2
}
url := L.CheckString(1)
if ok, reason := hostAllowed(url); !ok {
L.Push(lua.LNil)
L.Push(lua.LString("http: " + reason))
return 2
}
payload := L.CheckString(2)
req, err := net_http.NewRequest("PATCH", url, strings.NewReader(payload))
if err != nil {
L.Push(lua.LNil)
L.Push(lua.LString(err.Error()))
return 2
}
req.Header.Set("Content-Type", "application/json")
applyHeaders(req, L, 3)
resp, err := httpClient.Do(req)
if err != nil {
L.Push(lua.LNil)
L.Push(lua.LString(err.Error()))
return 2
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
L.Push(lua.LNil)
L.Push(lua.LString(err.Error()))
return 2
}
if resp.StatusCode >= 400 {
L.Push(lua.LNil)
L.Push(lua.LString(fmt.Sprintf("HTTP %d: %s", resp.StatusCode, string(body))))
return 2
}
env.recordWriteEvidence("write_ack")
L.Push(lua.LString(string(body)))
return 1
}))

// ---- WebSocket capability ----
// host.ws_open(url, headers?) → (true, nil) or (nil, error_string)
// host.ws_send(text) → (true, nil) or (nil, error_string)
Expand Down
Loading