diff --git a/.changeset/host-http-patch-write-verb.md b/.changeset/host-http-patch-write-verb.md new file mode 100644 index 00000000..cd46d2a9 --- /dev/null +++ b/.changeset/host-http-patch-write-verb.md @@ -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. diff --git a/config.example.yaml b/config.example.yaml index fb4c712d..0999020f 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -105,7 +105,14 @@ drivers: # "fingeravtryck" shown in the myUplink app, or from `openssl s_client # -connect :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: @@ -113,10 +120,14 @@ drivers: # port: 8443 # username: # 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 diff --git a/docs/nibe-local.md b/docs/nibe-local.md index 2971325f..ac6d1ade 100644 --- a/docs/nibe-local.md +++ b/docs/nibe-local.md @@ -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) @@ -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 diff --git a/go/internal/config/config.go b/go/internal/config/config.go index e36c4611..074d84e8 100644 --- a/go/internal/config/config.go +++ b/go/internal/config/config.go @@ -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 diff --git a/go/internal/drivers/host.go b/go/internal/drivers/host.go index 69df79b9..2cf80f08 100644 --- a/go/internal/drivers/host.go +++ b/go/internal/drivers/host.go @@ -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 @@ -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 { diff --git a/go/internal/drivers/lua.go b/go/internal/drivers/lua.go index 9522847d..bb62185c 100644 --- a/go/internal/drivers/lua.go +++ b/go/internal/drivers/lua.go @@ -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 @@ -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) @@ -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) } @@ -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) diff --git a/go/internal/drivers/lua_http_patch_test.go b/go/internal/drivers/lua_http_patch_test.go new file mode 100644 index 00000000..ed592bd9 --- /dev/null +++ b/go/internal/drivers/lua_http_patch_test.go @@ -0,0 +1,205 @@ +package drivers + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + + "github.com/srcfl/ftw/go/internal/telemetry" +) + +// host.http_patch is the first mutating HTTP verb exposed to Lua, added for +// the NIBE Solar PV feed (issue #537). It is gated twice: the plain http +// capability AND capabilities.http.allow_write. These tests pin both gates +// and the wire format, because a silent regression here would let any HTTP +// driver mutate device state with only a read grant. + +// runPatchDriver runs a one-shot driver that PATCHes targetURL with a fixed +// JSON body and reports the outcome through metrics, mirroring the style of +// runHTTPDriverWithEnv. The specific denial gate is surfaced as a metric so +// tests can assert WHICH gate fired, not just that something failed. +func runPatchDriver(t *testing.T, env *HostEnv, targetURL string) (ok bool, deniedBy string) { + t.Helper() + src := ` + function driver_init() end + function driver_poll() + local body, err = host.http_patch("` + targetURL + `", + '[{"variableId":5202,"integerValue":3000}]', + { Authorization = "Basic dTpw" }) + if err then + host.emit_metric("patch_err", 1) + local e = tostring(err) + if string.find(e, "capability not granted", 1, true) then + host.emit_metric("denied_http_cap", 1) + elseif string.find(e, "write not granted", 1, true) then + host.emit_metric("denied_write_cap", 1) + elseif string.find(e, "redirect", 1, true) then + host.emit_metric("denied_redirect", 1) + end + host.log("info", "ERR:" .. e) + else + host.emit_metric("patch_ok", 1) + end + return 60000 + end + function driver_command() end + function driver_default_mode() end + function driver_cleanup() end + ` + path := filepath.Join(t.TempDir(), "drv.lua") + if err := os.WriteFile(path, []byte(src), 0644); err != nil { + t.Fatal(err) + } + d, err := NewLuaDriver(path, env) + if err != nil { + t.Fatalf("load driver: %v", err) + } + defer d.Cleanup() + if err := d.Init(context.Background(), nil); err != nil { + t.Fatalf("init: %v", err) + } + if _, err := d.Poll(context.Background()); err != nil { + t.Fatalf("poll: %v", err) + } + if v, _, found := env.Telemetry.LatestMetric(env.DriverName, "patch_ok"); found && v == 1 { + return true, "" + } + for _, gate := range []string{"denied_http_cap", "denied_write_cap", "denied_redirect"} { + if v, _, found := env.Telemetry.LatestMetric(env.DriverName, gate); found && v == 1 { + return false, gate + } + } + return false, "other" +} + +func TestLuaHTTPPatchSendsMethodBodyAndHeaders(t *testing.T) { + var gotMethod, gotBody, gotCT, gotAuth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod = r.Method + b, _ := io.ReadAll(r.Body) + gotBody = string(b) + gotCT = r.Header.Get("Content-Type") + gotAuth = r.Header.Get("Authorization") + _, _ = w.Write([]byte(`[{"status":"modified"}]`)) + })) + defer srv.Close() + + env := NewHostEnv("patcher", telemetry.NewStore()).WithHTTP().WithHTTPAllowWrite() + ok, errText := runPatchDriver(t, env, srv.URL+"/api/v1/devices/1/points") + if !ok { + t.Fatalf("patch failed: %s", errText) + } + if gotMethod != "PATCH" { + t.Errorf("method = %q, want PATCH", gotMethod) + } + if gotBody != `[{"variableId":5202,"integerValue":3000}]` { + t.Errorf("body = %q", gotBody) + } + if gotCT != "application/json" { + t.Errorf("content-type = %q", gotCT) + } + if gotAuth != "Basic dTpw" { + t.Errorf("authorization header not forwarded, got %q", gotAuth) + } +} + +func TestLuaHTTPPatchRequiresAllowWrite(t *testing.T) { + var hits atomic.Int64 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + hits.Add(1) + _, _ = w.Write([]byte(`ok`)) + })) + defer srv.Close() + + // http granted, allow_write NOT granted: the request must never leave. + env := NewHostEnv("readonly", telemetry.NewStore()).WithHTTP() + ok, deniedBy := runPatchDriver(t, env, srv.URL) + if ok { + t.Fatal("http_patch succeeded without capabilities.http.allow_write") + } + if deniedBy != "denied_write_cap" { + t.Errorf("denied by %q, want the allow_write gate", deniedBy) + } + if hits.Load() != 0 { + t.Fatalf("server was reached %d times despite missing allow_write", hits.Load()) + } +} + +func TestLuaHTTPPatchWithoutHTTPCapability(t *testing.T) { + // allow_write alone must not open the door either — both grants are + // needed, and the request must never leave the host (the hit counter is + // what makes this test able to catch a deleted gate; a dead port alone + // would pass on connection-refused). + var hits atomic.Int64 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + hits.Add(1) + _, _ = w.Write([]byte(`ok`)) + })) + defer srv.Close() + + env := NewHostEnv("nohttp", telemetry.NewStore()).WithHTTPAllowWrite() + ok, deniedBy := runPatchDriver(t, env, srv.URL) + if ok { + t.Fatal("http_patch succeeded without the http capability") + } + if deniedBy != "denied_http_cap" { + t.Errorf("denied by %q, want the http capability gate", deniedBy) + } + if hits.Load() != 0 { + t.Fatalf("server was reached %d times despite missing http capability", hits.Load()) + } +} + +func TestLuaHTTPPatchRefusesRedirects(t *testing.T) { + // Go's HTTP client re-issues most redirected writes as body-less GETs, + // which would make http_patch report success for a write that never + // reached the device. A redirected PATCH must surface as an error. + var followUps atomic.Int64 + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + followUps.Add(1) + _, _ = w.Write([]byte(`ok`)) + })) + defer target.Close() + redirector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, target.URL, http.StatusFound) + })) + defer redirector.Close() + + env := NewHostEnv("redirected", telemetry.NewStore()).WithHTTP().WithHTTPAllowWrite() + ok, deniedBy := runPatchDriver(t, env, redirector.URL) + if ok { + t.Fatal("a redirected PATCH must not report success") + } + if deniedBy != "denied_redirect" { + t.Errorf("denied by %q, want the redirect refusal", deniedBy) + } + if followUps.Load() != 0 { + t.Fatalf("redirect target was reached %d times — the write was converted to a GET", followUps.Load()) + } +} + +func TestLuaHTTPPatchRespectsAllowlist(t *testing.T) { + var hits atomic.Int64 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + hits.Add(1) + _, _ = w.Write([]byte(`ok`)) + })) + defer srv.Close() + srvHost := strings.TrimPrefix(srv.URL, "http://") + + env := NewHostEnv("fenced", telemetry.NewStore()).WithHTTP().WithHTTPAllowWrite(). + WithHTTPAllowedHosts([]string{"10.99.99.99"}) + ok, _ := runPatchDriver(t, env, srv.URL) + if ok { + t.Fatal("http_patch escaped the allowed_hosts fence") + } + if hits.Load() != 0 { + t.Fatalf("server was reached despite the allowlist (host %s)", srvHost) + } +} diff --git a/go/internal/drivers/registry.go b/go/internal/drivers/registry.go index 1bd7860c..29f99c31 100644 --- a/go/internal/drivers/registry.go +++ b/go/internal/drivers/registry.go @@ -231,6 +231,9 @@ func (r *Registry) Add(ctx context.Context, cfg config.Driver) error { if pin := strings.TrimSpace(cfg.Capabilities.HTTP.TLSPinSHA256); pin != "" { env.WithHTTPTLSPin(pin) } + if cfg.Capabilities.HTTP.AllowWrite { + env.WithHTTPAllowWrite() + } } if cfg.Capabilities.WebSocket != nil { env.WithWS(NewGorillaWS(cfg.Name))