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
5 changes: 5 additions & 0 deletions .changeset/cloud-consent-lan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": minor
---

Let the direct private LAN page accept Cloud terms or statistics without requiring a house password to be set first. When password protection is on, saving the choice still requires login. Show a shorter consent card, full sharing details, a clear saved message and errors that allow a retry.
8 changes: 7 additions & 1 deletion docs/protocol-cloud.md
Original file line number Diff line number Diff line change
Expand Up @@ -1407,14 +1407,20 @@ consent text.
| `GET /api/cloud` | An owner's session, or a client on the LAN. State, scopes, purposes, bytes sent in the last 24 hours of uptime, whether the byte budget is reached |
| `GET /api/cloud/stats-policy` | The same. Exact fields, purpose and group rules for a fresh consent |
| `GET /api/cloud/preview` | The same. The exact bytes that leave the house |
| `POST /api/cloud/grant {scopes, policy}` | An owner's session. On the LAN, only from loopback or with the house password. On a grant already on, it changes the scopes and keeps the key, home id and `grantKey` |
| `POST /api/cloud/grant {scopes, policy}` | An owner's session, the box itself, or a direct private LAN client. The optional house password, when on, requires a LAN login as Settings does. On a grant already on, it changes the scopes and keeps the key, home id and `grantKey` |
| `POST /api/cloud/stats {policy}` | The same gate, as a separate consent. 409 while the grant is off or a revoke waits for its receipt |
| `DELETE /api/cloud/grant`, `DELETE /api/cloud/stats` | An owner's session, or any client on the LAN, because narrowing access is safe |

A viewer's session gets 403 on every route, and so does a request with
forwarding headers. No route asks for a step-up: the box cannot verify the
app's, so the grant screen claims no more than an owner's session.

The house password is not a prerequisite for consent. With it off, a client
that can reach the box on a private network may turn Cloud or statistics on,
as it may save settings and pair an owner. This includes private VPN peers.
Public clients, public hostnames, forwarding headers and browser cross-site
writes do not gain access. CGNAT is not a private network for this choice.

- Turning FTW Cloud on pushes `cloud.granted` to all owners, and turning it
off pushes `cloud.revoked`. A change of scopes, and turning statistics on
or off, push nothing. Every change writes an audit line on the box. A grant,
Expand Down
27 changes: 15 additions & 12 deletions go/internal/api/api_cloud.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,14 @@ import (
"github.com/srcfl/ftw/go/internal/loadpoint"
)

// FTW Cloud, at the HTTP door.
// FTW Cloud over HTTP.
//
// The grant lets the box send a readable copy of the house to a Sourceful
// service, so who may turn it on is the narrowest door this API has: an owner
// over an app session, or on the LAN only from the box itself or with the
// house password. Turning it off narrows access, so any door may do that. The
// box cannot verify the app's step-up, so the grant asks for none and the
// app's consent screen claims no more than an owner's session. Statistics are
// a second consent under the grant, behind the same doors.
// A grant lets the box send a readable copy of the house to Sourceful, so
// an owner must accept it. LAN consent follows the private network and
// optional house-password rules used by Settings and owner pairing.
// Turning Cloud off narrows access, so any local client may do that.
// The box cannot verify the app's step-up, so a grant requires an owner's
// session without claiming a separate step-up. Statistics use the same check.

// CloudGrant is the box's FTW Cloud link as the routes see it.
type CloudGrant interface {
Expand All @@ -43,8 +42,9 @@ func (s *Server) cloudRoutes() {
s.handle("DELETE /api/cloud/stats", Configure, s.handleCloudStopStats, NoStepUp)
}

// cloudGate admits an owner's session, and on the LAN a local client. With
// toGrant, the LAN needs more: the box itself, or the house password.
// cloudGate admits an owner's session, and on the LAN a local client. A grant
// follows the private LAN's settings rule: the house password is optional,
// but when it is on the caller must have proved it.
func (s *Server) cloudGate(w http.ResponseWriter, r *http.Request, toGrant bool) bool {
if hasForwardingHeader(r.Header) {
writeAppLinkError(w, http.StatusForbidden, "FTW Cloud is managed in the app or on your home network")
Expand All @@ -69,8 +69,11 @@ func (s *Server) cloudGate(w http.ResponseWriter, r *http.Request, toGrant bool)
if ok, checked := lanSecretFrom(r.Context()); checked && ok {
return true
}
if !s.lanAuthEnabled() && isPrivateClient(r.RemoteAddr) {
return true
}
writeAppLinkError(w, http.StatusForbidden,
"turn FTW Cloud on in the app, or on your home network with the house password on")
"log in with the house password to save this choice, or use the owner's app")
return false
}

Expand Down Expand Up @@ -144,7 +147,7 @@ func (s *Server) handleCloudGrant(w http.ResponseWriter, r *http.Request) {
case errors.Is(err, cloudlink.ErrNoService):
writeAppLinkError(w, http.StatusServiceUnavailable, err.Error())
default:
writeAppLinkError(w, http.StatusInternalServerError, "could not turn FTW Cloud on")
writeAppLinkError(w, http.StatusInternalServerError, "could not save Cloud terms. Try again.")
}
}

Expand Down
98 changes: 93 additions & 5 deletions go/internal/api/api_cloud_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,8 @@ func loopbackRequest(method, target, body string) *http.Request {

const grantBody = `{"scopes":["ftw.live.read"],"policy":"2026-10"}`

// Turning FTW Cloud on is an owner's act: over an app session, or at the box.
// A LAN client without the house password is refused, unlike the rest of the
// LAN API, because this sends the house out of it.
// Turning FTW Cloud on follows the box's owner rules. With the optional house
// password off, a direct private LAN client may consent, as it may save settings.
func TestWhoMayTurnFTWCloudOn(t *testing.T) {
cases := []struct {
name string
Expand All @@ -78,7 +77,7 @@ func TestWhoMayTurnFTWCloudOn(t *testing.T) {
{"owner over a session", sessionRequest(http.MethodPost, "/api/cloud/grant", grantBody), http.StatusOK},
{"viewer over a session", viewerRequest(http.MethodPost, "/api/cloud/grant", grantBody), http.StatusForbidden},
{"the box itself", loopbackRequest(http.MethodPost, "/api/cloud/grant", grantBody), http.StatusOK},
{"a LAN client", localRequest(http.MethodPost, "/api/cloud/grant", grantBody), http.StatusForbidden},
{"a LAN client", localRequest(http.MethodPost, "/api/cloud/grant", grantBody), http.StatusOK},
{"a LAN client with the house password", func() *http.Request {
r := localRequest(http.MethodPost, "/api/cloud/grant", grantBody)
return r.WithContext(withLANSecret(r.Context(), true))
Expand All @@ -105,6 +104,95 @@ func TestWhoMayTurnFTWCloudOn(t *testing.T) {
}
}

func TestCloudConsentUsesTheConfiguredLANProtection(t *testing.T) {
for _, choice := range []struct{ path, body string }{
{"/api/cloud/grant", grantBody},
{"/api/cloud/stats", statsBody},
} {
t.Run(choice.path, func(t *testing.T) {
resetLANGuesses(t)
s := newLANAuthServer(t)
cloud := &stubCloud{}
s.deps.Cloud = cloud
attempt := func(req *http.Request, want, calls int) {
t.Helper()
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
s.Handler().ServeHTTP(w, req)
if w.Code != want {
t.Fatalf("got %d, want %d: %s", w.Code, want, w.Body.String())
}
if got := len(cloud.granted) + len(cloud.stats); got != calls {
t.Fatalf("Cloud writes = %d, want %d", got, calls)
}
}
attempt(localRequest(http.MethodPost, choice.path, choice.body), http.StatusOK, 1)
enableStoredLANAuth(t, s)
attempt(localRequest(http.MethodPost, choice.path, choice.body), http.StatusUnauthorized, 1)

login := postLANAuthJSON(s, http.MethodPost, "http://ftw.local:8080/api/auth/login", "192.168.1.10:43210",
`{"password":"`+testHousePassword+`"}`)
if login.Code != http.StatusOK {
t.Fatalf("login = %d: %s", login.Code, login.Body.String())
}
req := localRequest(http.MethodPost, choice.path, choice.body)
req.AddCookie(&http.Cookie{Name: lanSessionCookieName, Value: lanSessionFromRecorder(t, login)})
attempt(req, http.StatusOK, 2)

req = localRequest(http.MethodPost, choice.path, choice.body)
req.Header.Set("Authorization", "Bearer "+testHousePassword)
attempt(req, http.StatusOK, 3)
req = localRequest(http.MethodPost, choice.path, choice.body)
req.Header.Set("Authorization", "Bearer wrong-password")
attempt(req, http.StatusUnauthorized, 3)
})
}
}

func TestCloudConsentWithoutAPasswordStaysOnThePrivateNetwork(t *testing.T) {
for _, choice := range []struct{ path, body string }{
{"/api/cloud/grant", grantBody},
{"/api/cloud/stats", statsBody},
} {
t.Run(choice.path, func(t *testing.T) {
for _, tc := range []struct {
name, host, remote, header, value string
want int
}{
{"private IPv4", "ftw.local:8080", "192.168.1.10:1234", "", "", http.StatusOK},
{"private IPv6", "[fd00::1]:8080", "[fd00::2]:1234", "", "", http.StatusOK},
{"CGNAT", "ftw.local:8080", "100.64.0.10:1234", "", "", http.StatusForbidden},
{"public client", "ftw.local:8080", "203.0.113.9:1234", "", "", http.StatusForbidden},
{"public host", "box.example.com", "192.168.1.10:1234", "", "", http.StatusForbidden},
{"forwarded LAN", "ftw.local:8080", "192.168.1.10:1234", "X-Forwarded-For", "192.168.1.20", http.StatusForbidden},
{"loopback proxy", "localhost:8080", "127.0.0.1:1234", "Forwarded", "for=203.0.113.9", http.StatusForbidden},
{"cross-site origin", "ftw.local:8080", "192.168.1.10:1234", "Origin", "https://other.example", http.StatusForbidden},
{"cross-site fetch", "ftw.local:8080", "192.168.1.10:1234", "Sec-Fetch-Site", "cross-site", http.StatusForbidden},
{"non-JSON body", "ftw.local:8080", "192.168.1.10:1234", "Content-Type", "text/plain", http.StatusUnsupportedMediaType},
} {
t.Run(tc.name, func(t *testing.T) {
cloud := &stubCloud{}
s := New(&Deps{Cloud: cloud})
req := localRequest(http.MethodPost, choice.path, choice.body)
req.Host, req.RemoteAddr = tc.host, tc.remote
req.Header.Set("Content-Type", "application/json")
if tc.header != "" {
req.Header.Set(tc.header, tc.value)
}
w := httptest.NewRecorder()
s.Handler().ServeHTTP(w, req)
if w.Code != tc.want {
t.Fatalf("got %d, want %d: %s", w.Code, tc.want, w.Body.String())
}
if called := len(cloud.granted)+len(cloud.stats) == 1; called != (tc.want == http.StatusOK) {
t.Fatalf("Cloud write = %v with status %d", called, w.Code)
}
})
}
})
}
}

// The audit line names the door, never anything the caller sent.
func TestTheGrantRecordsWhichDoorAsked(t *testing.T) {
cloud := &stubCloud{}
Expand Down Expand Up @@ -213,7 +301,7 @@ func TestWhoMayTurnStatisticsOn(t *testing.T) {
{"owner over a session", sessionRequest(http.MethodPost, "/api/cloud/stats", statsBody), http.StatusOK},
{"viewer over a session", viewerRequest(http.MethodPost, "/api/cloud/stats", statsBody), http.StatusForbidden},
{"the box itself", loopbackRequest(http.MethodPost, "/api/cloud/stats", statsBody), http.StatusOK},
{"a LAN client", localRequest(http.MethodPost, "/api/cloud/stats", statsBody), http.StatusForbidden},
{"a LAN client", localRequest(http.MethodPost, "/api/cloud/stats", statsBody), http.StatusOK},
{"a LAN client with the house password", func() *http.Request {
r := localRequest(http.MethodPost, "/api/cloud/stats", statsBody)
return r.WithContext(withLANSecret(r.Context(), true))
Expand Down
50 changes: 50 additions & 0 deletions web/project-statistics.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
.project-statistics-panel {
min-inline-size: 0;
margin: 0;
padding: 16px 20px 20px;
border: 1px solid var(--line);
border-radius: var(--radius-lg);
background: var(--ink-raised);
color: var(--fg);
}
.project-statistics-panel legend {
padding: 0 6px;
color: var(--fg);
font: 600 1rem var(--sans);
}
.project-statistics-panel .hint {
max-width: 85ch;
margin: 10px 0;
color: var(--fg-dim);
font: inherit;
line-height: 1.5;
}
.project-statistics-panel details {
margin: 12px 0;
}
.project-statistics-panel summary {
width: fit-content;
color: var(--fg);
cursor: pointer;
}
.project-statistics-panel a {
color: var(--accent-e);
}
.project-statistics-panel .app-link-actions {
margin-top: 16px;
}
.project-statistics-panel button {
min-height: 40px;
}
.project-statistics-panel .project-statistics-error {
margin: 12px 0 0;
color: var(--red-e);
}
.project-statistics-saved {
margin: 0;
padding: 12px 16px;
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: var(--ink-raised);
color: var(--fg);
}
Loading
Loading