Skip to content
Open
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
4 changes: 4 additions & 0 deletions app.go
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,10 @@ func (a *App) startCore(ctx context.Context) error {

docker := runtime.NewDockerProvider()
docker.SetIsolationSetting(func() string { return a.cfg.Config().Isolation })
docker.SetFenceGated(func(slug string) bool {
svc, ok := cat.Get(slug)
return ok && svc.Docker.FenceGated
})
// A failed write is retried on every check, so it is reported once per failure
// rather than every five minutes. Docker's own evidence still counts meanwhile.
var memoryFailing atomic.Bool
Expand Down
2 changes: 1 addition & 1 deletion app_proxies_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ func TestProxiesStateNeverCarriesSecretsAndSaysWhatCannotRun(t *testing.T) {
t.Fatalf("honeygain can run behind a proxy, got %q", refusals["honeygain"])
}
if !strings.Contains(refusals["mysterium"], "cannot") {
t.Fatalf("mysterium needs the host's network and cannot, got %q", refusals["mysterium"])
t.Fatalf("mysterium runs only behind the LAN fence and cannot, got %q", refusals["mysterium"])
}
if state.TunnelImage != "" || !strings.Contains(state.TunnelImageError, "CASHPILOT_TUNNEL_IMAGE") {
t.Fatalf("a refused tunnel image is said: %+v", state)
Expand Down
8 changes: 7 additions & 1 deletion catalog-overlay/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,16 @@ the entry on its own, which is how a Desktop-only service would be carried.

| Delta | Where | Why |
| --- | --- | --- |
| 15 image digest pins | `image-pins.yml` | Desktop's digest-pin rule; the web catalog ships floating references. |
| 14 image digest pins | `image-pins.yml` | Desktop's digest-pin rule; the web catalog ships floating references. |
| `native:` block on mysterium | `append/bandwidth/mysterium.yml` | Desktop can run a service as a supervised native process with no container runtime. The web app deploys through Docker on a worker and has no such runtime. |
| `native:` schema documentation | `append/_schema.yml` | Documents the field above. |

## Fields Desktop reads differently

| Field | What Desktop does | Why |
| --- | --- | --- |
| `docker.fence_gated` | A deploy is refused unless the earner joins the fenced network (`internal/runtime/lanfence.go`), and the compose export refuses the service. Turning isolation off stops the node before the fence's drops go, and Start refuses it until isolation is on again. The container keeps the restart policy every earner gets, `unless-stopped`. | The web worker sets restart policy `no` and starts the node only after it has read the fence back, at boot and after a crash. Desktop has no such starter yet; with `no` the node would stay down after every reboot until someone redeployed it. The gap that leaves: after a reboot, Docker starts the node before the app writes the fence again. In that window the fenced network has no masquerade, so the node has no internet route, but nothing drops what it sends to this machine itself. The node's own `--firewall.protected.networks` still blackholes private ranges inside its namespace. |

## Files Desktop reads beside the services

| File | Why |
Expand Down
13 changes: 7 additions & 6 deletions catalog-overlay/append/bandwidth/mysterium.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@
# Native (no-Docker) execution: run the official Mysterium headless node binary as a
# supervised child process. Pinned to release 1.38.5; sha256 is the SHA-256 of each
# published release artifact. There is no linux/arm64 tarball upstream (only .deb), so
# arm64 Linux hosts fall back to the docker: image above.
# arm64 Linux hosts fall back to the docker: image above, which deploys only behind
# the LAN fence (docker.fence_gated).
#
# The launch args deliberately do NOT mirror docker.command any more. The container
# binds its UI and Tequila API to 0.0.0.0 because it runs inside a container with
# host networking on a worker box; a native process binds them to 127.0.0.1 because
# it runs directly on the user's own machine, where 0.0.0.0 would publish an
# unauthenticated node admin API to the local network. Port 4449 and the accepted
# The launch args deliberately do NOT mirror docker.command. The container binds its
# UI to 0.0.0.0 inside its own network namespace, which reaches the host only where
# docker.ui_port publishes it; a native process binds it to 127.0.0.1 because it runs
# directly on the user's own machine, where 0.0.0.0 would publish an unauthenticated
# node admin API to the local network. Port 4449 and the accepted
# terms are unchanged, so the earnings collector at
# http://localhost:4449/tequilapi still works.
#
Expand Down
3 changes: 0 additions & 3 deletions catalog-overlay/image-pins.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,6 @@ pins:
iproyal:
image: "iproyal/pawns-cli@sha256:b8bd21d755a7bd33cdde59da31f9b39d13e67fc67d00cb5f895d1c606eb5a95d"
why: "Upstream ships no tag at all on this entry."
mysterium:
image: "mysteriumnetwork/myst@sha256:1b68b8336112567a2b4596eccebd53cb704bfa37630dd3e2aef63d8a0eca2b2a"
why: "Upstream ships no tag at all on this entry."
packetstream:
image: "packetstream/psclient@sha256:e5015592031fb6f3876582117f47995ecec8157a639e9fc3fcfe4af28b2c497a"
why: "Upstream ships no tag at all on this entry."
Expand Down
132 changes: 132 additions & 0 deletions e2e/fence_gated_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
package e2e

import (
"strings"
"testing"

"github.com/GeiserX/CashPilot-Desktop/internal/runtime"
)

// gatedProvider is a Docker provider whose isolation setting the test changes, with
// the catalog's fence_gated services wired in the way app.go does. Mysterium is
// deployed behind the fence and running.
func gatedProvider(t *testing.T, e *env, setting *string) *runtime.DockerProvider {
t.Helper()
provider := runtime.NewDockerProvider()
provider.SetIsolationSetting(func() string { return *setting })
provider.SetFenceGated(func(slug string) bool {
svc, ok := e.Catalog.Get(slug)
return ok && svc.Docker.FenceGated
})
svc, _ := e.Catalog.Get("mysterium")
if _, err := provider.Deploy(e.ctx(), runtime.DeploySpec{Slug: "mysterium", Service: svc}, nil); err != nil {
t.Fatalf("deploy mysterium: %v", err)
}
return provider
}

func state(e *env, name string) string {
c, _ := e.Docker.Container(name)
return c.State
}

// A node Docker is about to restart is stopped too: left alone, Docker would bring
// it back after the drops are gone, past the app's Start guard.
func TestTurningIsolationOffStopsARestartingMysterium(t *testing.T) {
e := newEnv(t)
setting := "auto"
provider := gatedProvider(t, e, &setting)
e.Docker.SetState("cashpilot-mysterium", "restarting")
setting = "off"
provider.ReconcileFence(e.ctx())
if got := state(e, "cashpilot-mysterium"); got != "exited" {
t.Fatalf("state = %q, want the restarting node stopped", got)
}
}

// A node that cannot be stopped keeps the fence: the drops stay, and the status
// says why.
func TestTheFenceStaysWhenMysteriumCannotBeStopped(t *testing.T) {
e := newEnv(t)
setting := "auto"
provider := gatedProvider(t, e, &setting)
e.Docker.FailStop("cashpilot-mysterium")
setting = "off"
provider.ReconcileFence(e.ctx())
st := provider.FenceStatus()
if st.State != "failed" || !strings.Contains(st.Error, "LAN fence was kept") {
t.Fatalf("status = %+v, want the fence kept and the failure named", st)
}
}

// With the fence network already gone, a node that cannot be stopped is still
// named in the status, so the dashboard does not show a clean "n/a" while it runs.
func TestAStuckMysteriumIsReportedWhenTheFenceNetworkIsGone(t *testing.T) {
e := newEnv(t)
setting := "auto"
provider := gatedProvider(t, e, &setting)
e.Docker.FailStop("cashpilot-mysterium")
e.Docker.RemoveNetwork(runtime.FenceNetwork)
setting = "off"
provider.ReconcileFence(e.ctx())
st := provider.FenceStatus()
if !strings.Contains(st.Error, "could not stop cashpilot-mysterium") {
t.Fatalf("status = %+v, want the failed stop named", st)
}
}

// A container whose inspect fails cannot be judged, so Start and Restart refuse it.
func TestStartFailsClosedWhenTheContainerCannotBeInspected(t *testing.T) {
e := newEnv(t)
setting := "auto"
provider := gatedProvider(t, e, &setting)
if err := provider.Start(e.ctx(), "mysterium"); err != nil {
t.Fatalf("Start behind the fence: %v", err)
}
e.Docker.SetState("cashpilot-mysterium", "exited")
e.Docker.FailInspect("cashpilot-mysterium")
for name, err := range map[string]error{
"Start": provider.Start(e.ctx(), "mysterium"),
"Restart": provider.Restart(e.ctx(), "mysterium", 5),
} {
if err == nil || !strings.Contains(err.Error(), "could not check") {
t.Errorf("%s: err = %v, want a refusal", name, err)
}
}
if got := state(e, "cashpilot-mysterium"); got == "running" {
t.Fatal("a container that could not be inspected was started")
}
}

// A Mysterium made before the fence-gated label existed is found by its catalog
// entry: stopped when isolation goes off, and not started again.
func TestAnUnlabelledMysteriumIsGatedByItsCatalogEntry(t *testing.T) {
e := newEnv(t)
setting := "auto"
provider := gatedProvider(t, e, &setting)
e.Docker.SetLabel("cashpilot-mysterium", runtime.LabelFenceGated, "")
setting = "off"
provider.ReconcileFence(e.ctx())
if got := state(e, "cashpilot-mysterium"); got != "exited" {
t.Fatalf("state = %q, want the unlabelled node stopped", got)
}
if err := provider.Start(e.ctx(), "mysterium"); err == nil || !strings.Contains(err.Error(), "only behind the LAN fence") {
t.Fatalf("Start: err = %v, want a refusal", err)
}
}

// Behind a working fence, a node that is not on the fenced network alone is still
// refused: a container made on the host's network, or attached elsewhere too.
func TestStartRefusesAMysteriumOffTheFencedNetwork(t *testing.T) {
e := newEnv(t)
setting := "auto"
provider := gatedProvider(t, e, &setting)
e.Docker.SetState("cashpilot-mysterium", "exited")
e.Docker.AttachNetwork("cashpilot-mysterium", "bridge")
if err := provider.Start(e.ctx(), "mysterium"); err == nil || !strings.Contains(err.Error(), "also attached to bridge") {
t.Fatalf("Start: err = %v, want a refusal naming the other network", err)
}
if got := state(e, "cashpilot-mysterium"); got == "running" {
t.Fatal("a node attached outside the fence was started")
}
}
119 changes: 118 additions & 1 deletion e2e/harness/docker.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"log"
"net/http"
"net/http/httptest"
goruntime "runtime"
"sort"
"strings"
"sync"
Expand Down Expand Up @@ -46,6 +47,19 @@ type Container struct {
Entrypoint []string
StopTimeout *int
Mounts []Mount
// Runtime, NetworkMode and PortBindings are the HostConfig fields of the same
// names, as the deploy sent them.
Runtime string
NetworkMode string
PortBindings map[string][]PortBinding
// ExtraNetworks are networks a test attached it to beside its network mode.
ExtraNetworks []string
}

// PortBinding is one host binding of a published port.
type PortBinding struct {
HostIP string `json:"HostIp"`
HostPort string `json:"HostPort"`
}

// VolumeNames lists the names of the container's named volumes, in the order the
Expand Down Expand Up @@ -81,6 +95,8 @@ type Docker struct {
// networks are the user-defined networks, by name. The app creates one, its
// LAN fence network, and runs a one-shot helper container to write the fence.
networks map[string]map[string]any
// failInspect and failStop name containers whose inspect or stop fails.
failInspect, failStop map[string]bool
}

// NewDocker starts the fake daemon on a loopback TCP port.
Expand Down Expand Up @@ -283,6 +299,14 @@ func (d *Docker) handle(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"Version": "28.0.0-fake", "ApiVersion": "1.51", "Os": "linux", "Arch": "amd64",
})
case path == "/info":
// What a real daemon reports and the deploy asks for: its CPU (this
// machine's, as the fallback assumed before /info was answered), its OS
// and its OCI runtimes.
writeJSON(w, http.StatusOK, map[string]any{
"Architecture": goruntime.GOARCH, "OSType": "linux",
"Runtimes": map[string]any{"runc": map[string]string{"path": "runc"}},
})
case path == "/images/create":
d.pull(w, r)
case strings.HasPrefix(path, "/images/") && strings.HasSuffix(path, "/json"):
Expand Down Expand Up @@ -356,7 +380,10 @@ func (d *Docker) create(w http.ResponseWriter, r *http.Request) {
StopTimeout *int `json:"StopTimeout"`
Labels map[string]string `json:"Labels"`
HostConfig struct {
Mounts []struct {
Runtime string `json:"Runtime"`
NetworkMode string `json:"NetworkMode"`
PortBindings map[string][]PortBinding `json:"PortBindings"`
Mounts []struct {
Type string `json:"Type"`
Source string `json:"Source"`
Target string `json:"Target"`
Expand Down Expand Up @@ -388,6 +415,10 @@ func (d *Docker) create(w http.ResponseWriter, r *http.Request) {
Cmd: body.Cmd,
Entrypoint: body.Entrypoint,
StopTimeout: body.StopTimeout,

Runtime: body.HostConfig.Runtime,
NetworkMode: body.HostConfig.NetworkMode,
PortBindings: body.HostConfig.PortBindings,
}
for _, m := range body.HostConfig.Mounts {
if m.Type == "volume" && m.Source != "" {
Expand Down Expand Up @@ -435,6 +466,14 @@ func (d *Docker) list(w http.ResponseWriter) {
func (d *Docker) container(w http.ResponseWriter, r *http.Request, rest string) {
id, action, _ := strings.Cut(rest, "/")

d.mu.Lock()
failed := (action == "json" && d.failing(d.failInspect, id)) || (action == "stop" && d.failing(d.failStop, id))
d.mu.Unlock()
if failed {
writeJSON(w, http.StatusInternalServerError, map[string]string{"message": "fake daemon: " + action + " failed for " + id})
return
}

switch {
case action == "json":
d.inspect(w, id)
Expand Down Expand Up @@ -493,9 +532,87 @@ func (d *Docker) inspect(w http.ResponseWriter, id string) {
"State": map[string]any{"Status": c.State, "Running": c.State == "running"},
"Config": map[string]any{"Image": c.Image, "Labels": c.Labels, "Env": c.Env, "Cmd": c.Cmd},
"Mounts": mounts,
"HostConfig": map[string]any{
"NetworkMode": c.NetworkMode, "Runtime": c.Runtime, "PortBindings": c.PortBindings,
},
"NetworkSettings": map[string]any{"Networks": c.networks()},
})
}

// networks is what inspect reports the container attached to: its network mode's
// network, plus any a test attached it to.
func (c *Container) networks() map[string]any {
out := map[string]any{}
if c.NetworkMode != "" {
out[c.NetworkMode] = map[string]any{}
}
for _, n := range c.ExtraNetworks {
out[n] = map[string]any{}
}
return out
}

// SetState sets a container's state as Docker would report it ("restarting").
func (d *Docker) SetState(name, state string) {
d.mu.Lock()
defer d.mu.Unlock()
if c := d.find(name); c != nil {
c.State = state
}
}

// SetLabel sets (or, with value "", removes) one label of a container, the way a
// container made by an older version lacks a label a newer one sets.
func (d *Docker) SetLabel(name, key, value string) {
d.mu.Lock()
defer d.mu.Unlock()
if c := d.find(name); c != nil {
if value == "" {
delete(c.Labels, key)
} else {
c.Labels[key] = value
}
}
}

// AttachNetwork reports the container attached to one more network.
func (d *Docker) AttachNetwork(name, network string) {
d.mu.Lock()
defer d.mu.Unlock()
if c := d.find(name); c != nil {
c.ExtraNetworks = append(c.ExtraNetworks, network)
}
}

// RemoveNetwork deletes a network behind the app's back, the way a user's
// `docker network rm` or a daemon reset would.
func (d *Docker) RemoveNetwork(name string) {
d.mu.Lock()
defer d.mu.Unlock()
delete(d.networks, name)
}

// FailInspect makes inspecting this container fail with a server error.
func (d *Docker) FailInspect(name string) { d.setFail(&d.failInspect, name) }

// FailStop makes stopping this container fail with a server error.
func (d *Docker) FailStop(name string) { d.setFail(&d.failStop, name) }

func (d *Docker) setFail(set *map[string]bool, name string) {
d.mu.Lock()
defer d.mu.Unlock()
if *set == nil {
*set = map[string]bool{}
}
(*set)[name] = true
}

// failing reports a container whose name or id is in set. Called with d.mu held.
func (d *Docker) failing(set map[string]bool, id string) bool {
c := d.find(id)
return set[id] || (c != nil && (set[c.Name] || set[c.ID]))
}

func (d *Docker) setState(w http.ResponseWriter, id, state string) {
d.mu.Lock()
defer d.mu.Unlock()
Expand Down
Loading
Loading