diff --git a/README.md b/README.md index 22ee6fd..d0bd4e1 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,36 @@ Verify it bit (alpha can no longer reach bravo's discovery port): docker exec disruptoor-test-alpha nc -vz 172.30.0.11 30303 ``` +Or isolate a single container from **everything else** without enumerating the +counterparties — the complement of `target` is computed at apply time, so the +same request keeps working when the enclave topology changes: + +```bash +curl -X PUT http://localhost:7700/v1/state \ + -H 'Content-Type: application/json' \ + -d '{ + "isolations": [ + { + "name": "blackout-alpha", + "target": {"id": "alpha"}, + "scope": ["cl_p2p", "el_p2p", "include_control"] + } + ] + }' +``` + +An isolation is semantically a symmetric two-group partition of `target` vs +the rest of the enclave. `scope` defaults to `[cl_p2p, el_p2p]`; add +`include_control` to also cut RPC/engine/metrics/VC↔CL traffic (a full +blackout). The target must not match every container — there'd be nothing +left to isolate from. + +A target matching multiple containers is isolated **as a group**: traffic +among its members keeps flowing (useful for "island" scenarios, e.g. cutting +all beacon nodes off from their EL/VC stacks while they still gossip with +each other). To black out several containers individually, declare one +isolation per container. + Heal everything: ```bash diff --git a/disruptoor.md b/disruptoor.md index 4514bb4..578dc1b 100644 --- a/disruptoor.md +++ b/disruptoor.md @@ -127,6 +127,13 @@ Declarative, versioned, idempotent. "scope": ["cl_p2p", "el_p2p"] } ], + "isolations": [ + { + "name": "blackout-node-5", + "target": { "node-index": "5" }, + "scope": ["cl_p2p", "el_p2p", "include_control"] + } + ], "shaping": [ { "name": "dial-up-node", @@ -146,6 +153,7 @@ Declarative, versioned, idempotent. - **Auth by network.** Controller binds only to the enclave network. No tokens. Opt-in `expose: true` to publish on the host. - **Stable selectors.** The standalone API accepts label selectors. Higher-level package integrations can translate participant names into these selectors. - **Validation.** Reject configs at PUT time: same node in two groups, unknown participants, contradictory shaping rules. +- **Isolations.** `isolations` cuts a target selector off from every other container in the enclave; the counterparty group is the complement of the target, computed at apply time. This covers the "isolate one node" scenario that partitions cannot express (groups must be disjoint and there is no negation selector). ## Configuration block in ethereum-package diff --git a/examples/disruption.yaml b/examples/disruption.yaml index 0b4bb1b..9dc612f 100644 --- a/examples/disruption.yaml +++ b/examples/disruption.yaml @@ -15,6 +15,19 @@ partitions: # scope omitted → defaults to [cl_p2p, el_p2p] # symmetric omitted → defaults to true +# Isolations cut a target off from every other container in the enclave. +# The counterparty group is computed at apply time as the complement of +# target, so it never needs to be enumerated (and stays correct when the +# enclave topology changes). Commented out here because it would overlap +# with the alpha-vs-bravo partition above in the 2-container smoke harness. +# +# isolations: +# - name: blackout-alpha +# target: { id: alpha } +# # scope omitted → defaults to [cl_p2p, el_p2p]; add include_control +# # to also cut RPC/engine/metrics/VC-CL traffic: +# # scope: [cl_p2p, el_p2p, include_control] + shaping: - name: jitter-everything target: all diff --git a/internal/api/api.go b/internal/api/api.go index 4c34432..9697162 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -286,6 +286,16 @@ func cloneState(s state.State) state.State { } } } + if len(s.Isolations) > 0 { + out.Isolations = make([]state.Isolation, len(s.Isolations)) + for i, iso := range s.Isolations { + out.Isolations[i] = state.Isolation{ + Name: iso.Name, + Target: cloneSelectorPtr(iso.Target), + Scope: cloneStrings(iso.Scope), + } + } + } if len(s.Shaping) > 0 { out.Shaping = make([]state.Shaping, len(s.Shaping)) for i, sh := range s.Shaping { @@ -398,6 +408,12 @@ func (s *service) applyLocked(ctx context.Context, desired state.State) error { _ = s.clearLocked(ctx) return fmt.Errorf("resolve partitions: %w", err) } + resolvedIsolations, err := s.resolveIsolations(ctx, desired.Isolations) + if err != nil { + _ = s.clearLocked(ctx) + return fmt.Errorf("resolve isolations: %w", err) + } + resolvedPartitions = append(resolvedPartitions, resolvedIsolations...) resolvedShaping, err := s.resolveShaping(ctx, desired.Shaping) if err != nil { _ = s.clearLocked(ctx) @@ -465,6 +481,40 @@ func (s *service) resolvePartitions(ctx context.Context, ps []state.Partition) ( return out, nil } +// resolveIsolations expands each isolation into a two-group partition: +// the target selector vs the complement of its match set. Reusing +// ResolvedPartition means the conntrack and iptables backends need no +// isolation-specific code paths. +func (s *service) resolveIsolations(ctx context.Context, isos []state.Isolation) ([]backend.ResolvedPartition, error) { + defaultScope := []string{"cl_p2p", "el_p2p"} + out := make([]backend.ResolvedPartition, 0, len(isos)) + for _, iso := range isos { + // Validate guarantees iso.Target is non-nil, non-empty, and not "all". + target, err := s.cfg.Discovery.Resolve(ctx, *iso.Target) + if err != nil { + return nil, fmt.Errorf("isolation %q: %w", iso.Name, err) + } + if len(target) == 0 { + return nil, fmt.Errorf("isolation %q: target matched no containers", iso.Name) + } + everyone, err := s.cfg.Discovery.Resolve(ctx, state.Selector{All: true}) + if err != nil { + return nil, fmt.Errorf("isolation %q: resolve enclave containers: %w", iso.Name, err) + } + rest := subtractContainers(everyone, target) + if len(rest) == 0 { + return nil, fmt.Errorf("isolation %q: target matches every container in the enclave; nothing to isolate from", iso.Name) + } + out = append(out, backend.ResolvedPartition{ + Name: iso.Name, + Groups: [][]discovery.Container{target, rest}, + Scope: iso.EffectiveScope(defaultScope), + Symmetric: true, + }) + } + return out, nil +} + func (s *service) resolveShaping(ctx context.Context, sh []state.Shaping) ([]backend.ResolvedShaping, error) { out := make([]backend.ResolvedShaping, 0, len(sh)) for _, r := range sh { @@ -486,6 +536,23 @@ func (s *service) resolveShaping(ctx context.Context, sh []state.Shaping) ([]bac return out, nil } +// subtractContainers returns the containers in from that are not in remove, +// keyed by container ID. Order of from is preserved. +func subtractContainers(from, remove []discovery.Container) []discovery.Container { + removeIDs := make(map[string]struct{}, len(remove)) + for _, c := range remove { + removeIDs[c.ID] = struct{}{} + } + out := make([]discovery.Container, 0, len(from)) + for _, c := range from { + if _, drop := removeIDs[c.ID]; drop { + continue + } + out = append(out, c) + } + return out +} + // statusRecorder wraps http.ResponseWriter to capture the status code that // downstream handlers wrote, so the request-log middleware can include it. // WriteHeader is the only hook needed because handlers that never call it diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 746e605..0f0ae9b 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -108,6 +108,124 @@ func TestStatePutApplyErrorUsesStableResponse(t *testing.T) { require.NotContains(t, string(respBody), "selector matched no containers") } +func TestApplyIsolationResolvesComplement(t *testing.T) { + ipt := &recordingIptables{} + svc := newTestServiceWithDiscovery(inventoryDiscovery{"alpha", "bravo", "charlie"}) + svc.cfg.Iptables = ipt + + require.NoError(t, svc.Apply(context.Background(), state.State{ + Isolations: []state.Isolation{{ + Name: "blackout-alpha", + Target: &state.Selector{Match: map[string][]string{"id": {"alpha"}}}, + Scope: []string{state.ScopeCLP2P, state.ScopeELP2P, state.ScopeControl}, + }}, + })) + + require.Len(t, ipt.partitions, 1) + part := ipt.partitions[0] + require.Equal(t, "blackout-alpha", part.Name) + require.Len(t, part.Groups, 2) + require.Equal(t, []string{"alpha"}, containerNames(part.Groups[0])) + require.Equal(t, []string{"bravo", "charlie"}, containerNames(part.Groups[1])) + require.Equal(t, []string{state.ScopeCLP2P, state.ScopeELP2P, state.ScopeControl}, part.Scope) + require.True(t, part.Symmetric) +} + +// A target matching multiple containers is isolated as a group: its members +// end up in the same partition group, so traffic among them is unaffected. +// This is load-bearing API semantics — callers wanting per-container +// blackouts declare one isolation each. +func TestApplyIsolationKeepsMultiMatchTargetAsOneGroup(t *testing.T) { + ipt := &recordingIptables{} + svc := newTestServiceWithDiscovery(inventoryDiscovery{"alpha", "bravo", "charlie", "delta"}) + svc.cfg.Iptables = ipt + + require.NoError(t, svc.Apply(context.Background(), state.State{ + Isolations: []state.Isolation{{ + Name: "island", + Target: &state.Selector{Match: map[string][]string{"id": {"alpha", "bravo"}}}, + }}, + })) + + require.Len(t, ipt.partitions, 1) + part := ipt.partitions[0] + require.Len(t, part.Groups, 2) + require.Equal(t, []string{"alpha", "bravo"}, containerNames(part.Groups[0]), + "matched containers must share one group so intra-target traffic keeps flowing") + require.Equal(t, []string{"charlie", "delta"}, containerNames(part.Groups[1])) +} + +func TestApplyAppendsIsolationsAfterPartitions(t *testing.T) { + ipt := &recordingIptables{} + svc := newTestServiceWithDiscovery(inventoryDiscovery{"alpha", "bravo", "charlie"}) + svc.cfg.Iptables = ipt + + require.NoError(t, svc.Apply(context.Background(), state.State{ + Partitions: []state.Partition{{ + Name: "split", + Groups: []state.Selector{ + {Match: map[string][]string{"id": {"alpha"}}}, + {Match: map[string][]string{"id": {"bravo"}}}, + }, + }}, + Isolations: []state.Isolation{{ + Name: "blackout-charlie", + Target: &state.Selector{Match: map[string][]string{"id": {"charlie"}}}, + }}, + })) + + require.Len(t, ipt.partitions, 2) + require.Equal(t, "split", ipt.partitions[0].Name) + require.Equal(t, "blackout-charlie", ipt.partitions[1].Name) + // Isolation with no scope inherits the partition default. + require.Equal(t, []string{state.ScopeCLP2P, state.ScopeELP2P}, ipt.partitions[1].Scope) +} + +func TestApplyIsolationTargetMatchingNothingFails(t *testing.T) { + svc := newTestServiceWithDiscovery(inventoryDiscovery{"alpha", "bravo"}) + + err := svc.Apply(context.Background(), state.State{ + Isolations: []state.Isolation{{ + Name: "ghost", + Target: &state.Selector{Match: map[string][]string{"id": {"missing"}}}, + }}, + }) + + require.ErrorContains(t, err, "target matched no containers") +} + +func TestApplyIsolationTargetMatchingEverythingFails(t *testing.T) { + svc := newTestServiceWithDiscovery(inventoryDiscovery{"alpha", "bravo"}) + + err := svc.Apply(context.Background(), state.State{ + Isolations: []state.Isolation{{ + Name: "everyone", + Target: &state.Selector{Match: map[string][]string{"id": {"alpha", "bravo"}}}, + }}, + }) + + require.ErrorContains(t, err, "nothing to isolate from") +} + +func TestGetStateDeepCopiesIsolations(t *testing.T) { + svc := newTestServiceWithDiscovery(inventoryDiscovery{"alpha", "bravo"}) + require.NoError(t, svc.Apply(context.Background(), state.State{ + Isolations: []state.Isolation{{ + Name: "blackout", + Target: &state.Selector{Match: map[string][]string{"id": {"alpha"}}}, + Scope: []string{state.ScopeCLP2P}, + }}, + })) + + got := svc.GetState() + got.Isolations[0].Target.Match["id"][0] = "mutated" + got.Isolations[0].Scope[0] = state.ScopeControl + + current := svc.GetState() + require.Equal(t, "alpha", current.Isolations[0].Target.Match["id"][0]) + require.Equal(t, state.ScopeCLP2P, current.Isolations[0].Scope[0]) +} + func TestApplyClearsPreviousStateBeforeConntrackFlush(t *testing.T) { ops := &opLog{} svc := newTestServiceWithOps(ops) @@ -225,6 +343,66 @@ func (fakeDiscovery) ResolveGroups(_ context.Context, sels []state.Selector) ([] return out, nil } +// inventoryDiscovery resolves selectors against a fixed container inventory: +// the All selector matches everything, and Match selectors are honoured for +// the "id" key only (values name containers directly). +type inventoryDiscovery []string + +func (inventoryDiscovery) Start(context.Context) error { return nil } +func (inventoryDiscovery) Stop() error { return nil } +func (inventoryDiscovery) EnclaveID() string { return "test" } +func (d inventoryDiscovery) Resolve(_ context.Context, sel state.Selector) ([]discovery.Container, error) { + out := make([]discovery.Container, 0, len(d)) + for _, name := range d { + if sel.All || containsValue(sel.Match["id"], name) { + out = append(out, discovery.Container{ID: name, Name: name}) + } + } + return out, nil +} + +func (d inventoryDiscovery) ResolveGroups(ctx context.Context, sels []state.Selector) ([][]discovery.Container, error) { + out := make([][]discovery.Container, len(sels)) + for i, sel := range sels { + matched, err := d.Resolve(ctx, sel) + if err != nil { + return nil, err + } + out[i] = matched + } + return out, nil +} + +func containsValue(values []string, target string) bool { + for _, v := range values { + if v == target { + return true + } + } + return false +} + +func containerNames(cs []discovery.Container) []string { + out := make([]string, 0, len(cs)) + for _, c := range cs { + out = append(out, c.Name) + } + return out +} + +// recordingIptables captures the resolved partitions passed to Apply. +type recordingIptables struct { + partitions []backend.ResolvedPartition +} + +func (*recordingIptables) Start(context.Context) error { return nil } +func (*recordingIptables) Stop() error { return nil } +func (r *recordingIptables) Apply(_ context.Context, ps []backend.ResolvedPartition) error { + r.partitions = ps + return nil +} +func (*recordingIptables) Clear(context.Context) error { return nil } + type emptyGroupDiscovery struct { fakeDiscovery } diff --git a/internal/state/state.go b/internal/state/state.go index bf0b3da..737b1dd 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -33,6 +33,7 @@ const ( // State is the full desired disruption state for an enclave. type State struct { Partitions []Partition `json:"partitions,omitempty"` + Isolations []Isolation `json:"isolations,omitempty"` Shaping []Shaping `json:"shaping,omitempty"` } @@ -46,6 +47,23 @@ type Partition struct { Symmetric *bool `json:"symmetric,omitempty"` } +// Isolation cuts the containers matched by Target off from the rest of the +// enclave. The counterparty set ("everyone else") is computed at apply time +// as the complement of Target, so callers don't enumerate it — a Partition +// cannot express this because its groups must be disjoint and there is no +// negation selector. Semantically an isolation is a symmetric two-group +// partition: Target vs the rest of the enclave. +// +// When Target matches more than one container, the matched set is isolated +// AS A GROUP: traffic among its members is unaffected. To black out several +// containers individually (no traffic between them either), declare one +// isolation per container. +type Isolation struct { + Name string `json:"name"` + Target *Selector `json:"target,omitempty"` + Scope []string `json:"scope,omitempty"` +} + // Shaping describes per-target link degradation: delay, jitter, loss, // bandwidth. v0 only supports Target (single selector, blanket egress // shaping); Between is parsed for forward compatibility but rejected by @@ -90,10 +108,19 @@ func (p Partition) EffectiveScope(defaultScope []string) []string { return defaultScope } +// EffectiveScope returns the scope list for this isolation, applying the +// default if unset. +func (iso Isolation) EffectiveScope(defaultScope []string) []string { + if len(iso.Scope) > 0 { + return iso.Scope + } + return defaultScope +} + // Validate runs structural checks on a State. Returns the first error found // or nil. Does not check against live Docker state — that happens at apply. func (s State) Validate() error { - names := make(map[string]struct{}, len(s.Partitions)+len(s.Shaping)) + names := make(map[string]struct{}, len(s.Partitions)+len(s.Isolations)+len(s.Shaping)) for i, p := range s.Partitions { if err := p.validate(); err != nil { return fmt.Errorf("partitions[%d] (%q): %w", i, p.Name, err) @@ -103,6 +130,15 @@ func (s State) Validate() error { } names[p.Name] = struct{}{} } + for i, iso := range s.Isolations { + if err := iso.validate(); err != nil { + return fmt.Errorf("isolations[%d] (%q): %w", i, iso.Name, err) + } + if _, dup := names[iso.Name]; dup { + return fmt.Errorf("isolations[%d]: duplicate name %q", i, iso.Name) + } + names[iso.Name] = struct{}{} + } for i, sh := range s.Shaping { if err := sh.validate(); err != nil { return fmt.Errorf("shaping[%d] (%q): %w", i, sh.Name, err) @@ -184,6 +220,27 @@ func (p Partition) validate() error { return nil } +func (iso Isolation) validate() error { + if iso.Name == "" { + return errors.New("name required") + } + if iso.Target == nil { + return errors.New("target required") + } + if iso.Target.All { + return errors.New(`target cannot be "all": isolating every container leaves nothing to isolate from`) + } + if len(iso.Target.Match) == 0 { + return errors.New("target: empty selector") + } + for _, sc := range iso.Scope { + if sc != ScopeCLP2P && sc != ScopeELP2P && sc != ScopeControl { + return fmt.Errorf("unknown scope %q", sc) + } + } + return nil +} + func (sh Shaping) validate() error { if sh.Name == "" { return errors.New("name required") diff --git a/internal/state/state_test.go b/internal/state/state_test.go index d6a35af..d5ea100 100644 --- a/internal/state/state_test.go +++ b/internal/state/state_test.go @@ -135,6 +135,67 @@ func TestStateValidate(t *testing.T) { }, wantErr: "duplicate name", }, + { + name: "valid isolation", + state: State{Isolations: []Isolation{{ + Name: "blackout", + Target: &Selector{Match: map[string][]string{"node-index": {"1"}}}, + Scope: []string{ScopeCLP2P, ScopeELP2P, ScopeControl}, + }}}, + }, + { + name: "isolation without name", + state: State{Isolations: []Isolation{{ + Target: &Selector{Match: map[string][]string{"id": {"alpha"}}}, + }}}, + wantErr: "name required", + }, + { + name: "isolation without target", + state: State{Isolations: []Isolation{{ + Name: "bad", + }}}, + wantErr: "target required", + }, + { + name: "isolation of all rejected", + state: State{Isolations: []Isolation{{ + Name: "bad", + Target: &Selector{All: true}, + }}}, + wantErr: `target cannot be "all"`, + }, + { + name: "isolation empty target selector", + state: State{Isolations: []Isolation{{ + Name: "bad", + Target: &Selector{}, + }}}, + wantErr: "empty selector", + }, + { + name: "isolation unknown scope", + state: State{Isolations: []Isolation{{ + Name: "bad", + Target: &Selector{Match: map[string][]string{"id": {"alpha"}}}, + Scope: []string{"made_up"}, + }}}, + wantErr: "unknown scope", + }, + { + name: "isolation name colliding with partition", + state: State{ + Partitions: []Partition{{ + Name: "x", + Groups: []Selector{{All: true}, {All: true}}, + }}, + Isolations: []Isolation{{ + Name: "x", + Target: &Selector{Match: map[string][]string{"id": {"alpha"}}}, + }}, + }, + wantErr: "duplicate name", + }, { name: "valid shaping", state: State{Shaping: []Shaping{{ @@ -260,4 +321,28 @@ func TestPartitionDefaults(t *testing.T) { assert.Equal(t, override, p2.EffectiveScope(def)) } +func TestIsolationDefaults(t *testing.T) { + def := []string{ScopeCLP2P, ScopeELP2P} + iso := Isolation{Name: "x"} + assert.Equal(t, def, iso.EffectiveScope(def)) + + override := []string{ScopeCLP2P, ScopeELP2P, ScopeControl} + iso2 := Isolation{Name: "y", Scope: override} + assert.Equal(t, override, iso2.EffectiveScope(def)) +} + +func TestIsolationJSONRoundTrip(t *testing.T) { + in := `{"isolations":[{"name":"blackout","target":{"node-index":[1]},"scope":["cl_p2p","el_p2p","include_control"]}]}` + var s State + require.NoError(t, json.Unmarshal([]byte(in), &s)) + require.Len(t, s.Isolations, 1) + require.NotNil(t, s.Isolations[0].Target) + assert.Equal(t, map[string][]string{"node-index": {"1"}}, s.Isolations[0].Target.Match) + require.NoError(t, s.Validate()) + + out, err := json.Marshal(s) + require.NoError(t, err) + assert.JSONEq(t, `{"isolations":[{"name":"blackout","target":{"node-index":["1"]},"scope":["cl_p2p","el_p2p","include_control"]}]}`, string(out)) +} + func boolPtr(v bool) *bool { return &v } diff --git a/internal/webui/handlers/index.go b/internal/webui/handlers/index.go index feaf212..3315e30 100644 --- a/internal/webui/handlers/index.go +++ b/internal/webui/handlers/index.go @@ -11,8 +11,10 @@ import ( type IndexPage struct { EnclaveID string PartitionsCount int + IsolationsCount int ShapingCount int Partitions []state.Partition + Isolations []state.Isolation Shaping []state.Shaping RecentEvents []api.Event // up to 5, newest first } @@ -23,8 +25,10 @@ func (h *Handler) Index(w http.ResponseWriter, r *http.Request) { page := &IndexPage{ EnclaveID: h.discovery.EnclaveID(), PartitionsCount: len(cur.Partitions), + IsolationsCount: len(cur.Isolations), ShapingCount: len(cur.Shaping), Partitions: cur.Partitions, + Isolations: cur.Isolations, Shaping: cur.Shaping, } if h.events != nil { diff --git a/internal/webui/handlers/partitions.go b/internal/webui/handlers/partitions.go index 30e6e59..0561dd5 100644 --- a/internal/webui/handlers/partitions.go +++ b/internal/webui/handlers/partitions.go @@ -9,6 +9,7 @@ import ( // PartitionsPage is the payload for /partitions. type PartitionsPage struct { Partitions []PartitionView + Isolations []IsolationView } // PartitionView is a UI-friendly projection of state.Partition. It computes @@ -20,19 +21,39 @@ type PartitionView struct { Symmetric bool } +// IsolationView is a UI-friendly projection of state.Isolation with the +// effective scope pre-computed. +type IsolationView struct { + Name string + Target state.Selector + Scope []string +} + // Partitions renders the partitions page at /partitions. func (h *Handler) Partitions(w http.ResponseWriter, r *http.Request) { cur := h.state.GetState() + defaultScope := []string{state.ScopeCLP2P, state.ScopeELP2P} views := make([]PartitionView, 0, len(cur.Partitions)) for _, p := range cur.Partitions { views = append(views, PartitionView{ Name: p.Name, Groups: p.Groups, - Scope: p.EffectiveScope([]string{state.ScopeCLP2P, state.ScopeELP2P}), + Scope: p.EffectiveScope(defaultScope), Symmetric: p.IsSymmetric(), }) } + isolations := make([]IsolationView, 0, len(cur.Isolations)) + for _, iso := range cur.Isolations { + view := IsolationView{ + Name: iso.Name, + Scope: iso.EffectiveScope(defaultScope), + } + if iso.Target != nil { + view.Target = *iso.Target + } + isolations = append(isolations, view) + } data := h.engine.InitPageData(r, "partitions", "/partitions", "Partitions") - data.Data = &PartitionsPage{Partitions: views} + data.Data = &PartitionsPage{Partitions: views, Isolations: isolations} h.engine.Render(w, r, []string{"partitions/partitions.html"}, data) } diff --git a/internal/webui/static/js/disruptoor.js b/internal/webui/static/js/disruptoor.js index 1d5522d..ef98bd3 100644 --- a/internal/webui/static/js/disruptoor.js +++ b/internal/webui/static/js/disruptoor.js @@ -443,6 +443,86 @@ }); } + // ---- Isolation modal --------------------------------------------------- + + function initIsolationModal() { + const modal = document.getElementById("addIsolationModal"); + const form = document.getElementById("isolation-add-form"); + const targetSlot = document.getElementById("isolation-target"); + const warning = document.getElementById("isolation-discovery-warning"); + if (!modal || !form || !targetSlot) return; + + let targetBuilder = null; + + modal.addEventListener("show.bs.modal", () => { + form.reset(); + targetSlot.innerHTML = ""; + // reset() unchecks the default-checked scope boxes; re-tick them. + for (const v of ["cl_p2p", "el_p2p"]) { + const cb = form.querySelector('input[name="scope"][value="' + v + '"]'); + if (cb) cb.checked = true; + } + targetBuilder = newBuilder({ title: "Target", removable: false }); + if (targetBuilder) { + // "All containers" is not a valid isolation target — the + // complement would be empty. Drop the mode option entirely. + const modeSel = targetBuilder.card.querySelector('[data-role="mode"]'); + const allOpt = modeSel ? modeSel.querySelector('option[value="all"]') : null; + if (allOpt) allOpt.remove(); + targetSlot.appendChild(targetBuilder.card); + } + + if (warning) { + warning.classList.add("d-none"); + warning.textContent = ""; + fetchDiscovery() + .then((d) => { + if (d.containers.length === 0) { + warning.textContent = + "Heads up: discovery returned 0 containers. The 'Specific containers' picker will be empty."; + warning.classList.remove("d-none"); + } + }) + .catch((err) => { + warning.textContent = "Discovery failed: " + err.message; + warning.classList.remove("d-none"); + }); + } + }); + + form.addEventListener("submit", async (ev) => { + ev.preventDefault(); + const name = form.querySelector('[name="name"]').value.trim(); + if (!name) { + flash("danger", "Isolation name required."); + return; + } + if (!targetBuilder) { + flash("danger", "Target missing."); + return; + } + const tr = targetBuilder.serialize(); + if (!tr.ok) { + flash("danger", "Target: " + tr.error); + return; + } + const scope = Array.from(form.querySelectorAll('input[name="scope"]:checked')).map((cb) => cb.value); + const iso = { name: name, target: tr.value }; + if (scope.length > 0) iso.scope = scope; + try { + const cur = await fetchState(); + const state = cur.state; + state.isolations = state.isolations || []; + state.isolations.push(iso); + await applyState(state, cur.etag); + flash("success", 'Added isolation "' + name + '".'); + reloadPage(); + } catch (err) { + flash("danger", "Add failed: " + err.message); + } + }); + } + // ---- Shaping modal ----------------------------------------------------- function initShapingModal() { @@ -523,7 +603,7 @@ document.querySelectorAll('[data-action="clear-all"]').forEach(function (btn) { btn.addEventListener("click", async function (ev) { ev.preventDefault(); - if (!confirm("Clear all active partitions and shaping rules?")) { + if (!confirm("Clear all active partitions, isolations, and shaping rules?")) { return; } try { @@ -549,6 +629,8 @@ const state = cur.state; if (kind === "partition") { state.partitions = (state.partitions || []).filter(p => p.name !== name); + } else if (kind === "isolation") { + state.isolations = (state.isolations || []).filter(i => i.name !== name); } else if (kind === "shaping") { state.shaping = (state.shaping || []).filter(s => s.name !== name); } @@ -583,6 +665,7 @@ } initPartitionModal(); + initIsolationModal(); initShapingModal(); }); diff --git a/internal/webui/templates/index/index.html b/internal/webui/templates/index/index.html index 2f3c3e7..41925be 100644 --- a/internal/webui/templates/index/index.html +++ b/internal/webui/templates/index/index.html @@ -2,7 +2,7 @@
| Name | Scope |
|---|---|
| {{ .Name }} | ++ {{ range .Scope }}{{ . }}{{ end }} + {{ if not .Scope }}default{{ end }} + | +
No active isolations.
+ {{ end }} +| Name | +Target | +Scope | +Actions | +
|---|---|---|---|
| {{ $iso.Name }} | ++ {{ if $iso.Target.All }} + all + {{ else }} + {{ range $k := sortedKeys $iso.Target.Match }} + + {{ $k }}={{ joinStrings "," (index $iso.Target.Match $k) }} + + {{ end }} + {{ end }} + | ++ {{ range .Scope }}{{ . }}{{ end }} + | ++ + | +
No isolations configured. An isolation cuts its target off from every other container in the enclave — the counterparty group is computed automatically.
+ {{ end }} +