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
20 changes: 20 additions & 0 deletions .github/scripts/workflows.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,26 @@ test("every Go setup resolves one exact committed patch", () => {
assert.ok(checked >= 3);
});

test("Windows desktop validates the authenticated probe config with its fetched engine", () => {
const desktop = jobs(workflow("ci.yml")).get("desktop");
assert.ok(desktop, "ci.yml has no desktop job");
const desktopSteps = steps(desktop);
const fetchIndex = desktopSteps.findIndex((step) => step.includes("name: Fetch sing-box and wintun"));
assert.notEqual(fetchIndex, -1, "desktop job has no resource fetch step");

const probeIndexes = desktopSteps
.map((step, index) => ({ step, index }))
.filter(({ step }) => /^\s*run:\s*go test \.\/core\/singbox -run '\^TestProbeConfigPassesSingBoxCheck\$' -count=1\s*$/m.test(step));
assert.deepEqual(
probeIndexes.map(({ index }) => index),
[fetchIndex + 1],
"the mandatory probe schema check must run exactly once, immediately after fetching the pinned engine",
);
assert.match(probeIndexes[0].step, /name: Validate authenticated probe config/);
assert.doesNotMatch(probeIndexes[0].step, /^\s*if:/m);
assert.doesNotMatch(probeIndexes[0].step, /continue-on-error:/);
});

test("the Arch attach step names the repository instead of asking git", () => {
// The build step chowns the checkout to `builder` so makepkg can run, and this
// step runs as root: gh's own repository resolution shells out to git, git
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ jobs:
run: cargo fmt --check
- name: Fetch sing-box and wintun
run: powershell -ExecutionPolicy Bypass -File scripts/fetch-resources.ps1
- name: Validate authenticated probe config
run: go test ./core/singbox -run '^TestProbeConfigPassesSingBoxCheck$' -count=1
- name: Build core sidecar
run: go build -o ui-desktop/src-tauri/binaries/tenebra-core-x86_64-pc-windows-msvc.exe ./cmd/tenebra-core
- name: Lint the Rust backend
Expand Down
27 changes: 26 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,30 @@ All notable changes to Tenebra are documented here. The format follows

## [Unreleased]

## [0.6.1] - 2026-09-16

### Fixed

- The on-connect, multi-destination node validation now reserves a free block
of loopback ports and authenticates its temporary SOCKS listeners, so an
unrelated local process cannot be mistaken for Tenebra's own probe.
- Detected probe startup, bind-race and early process-exit failures are reported
as local validation errors instead of marking every server unreachable.
Captured probe output is bounded and scrubbed before it reaches diagnostics.
- Separately, the server list's direct TCP status checks now distinguish runs
that are in progress, complete or failed, offer an explicit retry, and keep
an earlier RTT visibly stale without counting it as a current reachable
result.

### Verification scope

Automated tests cover the on-connect validation's probe ownership, lifecycle
and port races, authenticated readiness and log bounds, plus the server list's
direct TCP states. The precise trigger of the original unavailable-server
report on the affected machine has not been causally reproduced. Native
installation, service, ordinary-user UI and tunnel checks of the exact signed
candidate remain required before publication.

## [0.6.0] - 2026-09-13

### Changed
Expand Down Expand Up @@ -1516,7 +1540,8 @@ Initial tagged release.
first run. Updates delivered in-app are minisign-verified against the bundled
key; only the initial download is unsigned.

[Unreleased]: https://github.com/Divaaaan/tenebra/compare/v0.6.0...HEAD
[Unreleased]: https://github.com/Divaaaan/tenebra/compare/v0.6.1...HEAD
[0.6.1]: https://github.com/Divaaaan/tenebra/compare/v0.6.0...v0.6.1
[0.6.0]: https://github.com/Divaaaan/tenebra/compare/v0.5.11...v0.6.0
[0.5.11]: https://github.com/Divaaaan/tenebra/compare/v0.5.10...v0.5.11
[0.5.10]: https://github.com/Divaaaan/tenebra/compare/v0.5.5...v0.5.10
Expand Down
94 changes: 94 additions & 0 deletions adapters/internal/processlog/writer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// Package processlog adapts process stdout and stderr to line-oriented log
// callbacks without taking ownership of the process pipes. os/exec can then
// bound pipe-copy completion with Cmd.WaitDelay while callers still receive an
// unterminated final line before process completion is published.
package processlog

import (
"bytes"
"sync"
)

const maxPendingBytes = 1 << 20

// Writer buffers a partial line across Write calls and emits complete lines.
// It is safe for concurrent Write and Flush calls, although each process stream
// normally has its own Writer.
type Writer struct {
mu sync.Mutex
pending []byte
lineHasData bool
emit func(string)
}

// New creates a line writer that calls emit once for each completed line.
func New(emit func(string)) *Writer {
return &Writer{emit: emit}
}

// Write implements io.Writer. A pathological line is emitted in bounded chunks
// so a child process cannot grow the pending buffer without limit.
func (w *Writer) Write(p []byte) (int, error) {
w.mu.Lock()
defer w.mu.Unlock()

written := len(p)
for len(p) > 0 {
newline := bytes.IndexByte(p, '\n')
if newline >= 0 {
w.appendBounded(p[:newline])
if len(w.pending) > 0 || !w.lineHasData {
w.emitPending()
}
w.lineHasData = false
p = p[newline+1:]
continue
}
w.appendBounded(p)
break
}
return written, nil
}

func (w *Writer) appendBounded(p []byte) {
if len(p) > 0 {
w.lineHasData = true
}
for len(p) > 0 {
room := maxPendingBytes - len(w.pending)
if room > len(p) {
room = len(p)
}
w.pending = append(w.pending, p[:room]...)
p = p[room:]
if len(w.pending) == maxPendingBytes {
w.emitPending()
}
}
}

// Flush emits an unterminated final line. Call it only after Cmd.Wait returns,
// when os/exec's writer-copy goroutines have completed or WaitDelay closed their
// pipes.
func (w *Writer) Flush() {
w.mu.Lock()
defer w.mu.Unlock()
if len(w.pending) > 0 {
w.emitPending()
}
w.lineHasData = false
}

func (w *Writer) emitPending() {
w.emitBytes(w.pending)
w.pending = w.pending[:0]
}

func (w *Writer) emitBytes(line []byte) {
if len(line) > 0 && line[len(line)-1] == '\r' {
line = line[:len(line)-1]
}
if w.emit != nil {
w.emit(string(line))
}
}
56 changes: 56 additions & 0 deletions adapters/internal/processlog/writer_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package processlog

import (
"bytes"
"reflect"
"testing"
)

func TestWriterEmitsCompleteLinesAndFlushesTail(t *testing.T) {
var got []string
w := New(func(line string) { got = append(got, line) })

for _, chunk := range []string{"first\r", "\nsec", "ond\nthird"} {
if n, err := w.Write([]byte(chunk)); err != nil || n != len(chunk) {
t.Fatalf("Write(%q) = (%d, %v), want (%d, nil)", chunk, n, err, len(chunk))
}
}
if want := []string{"first", "second"}; !reflect.DeepEqual(got, want) {
t.Fatalf("before Flush lines = %#v, want %#v", got, want)
}

w.Flush()
w.Flush()
if want := []string{"first", "second", "third"}; !reflect.DeepEqual(got, want) {
t.Fatalf("after Flush lines = %#v, want %#v", got, want)
}
}

func TestWriterBoundsAPathologicalLine(t *testing.T) {
var lengths []int
w := New(func(line string) { lengths = append(lengths, len(line)) })
payload := append(bytes.Repeat([]byte{'x'}, maxPendingBytes+7), '\n')

if _, err := w.Write(payload); err != nil {
t.Fatalf("Write: %v", err)
}
if want := []int{maxPendingBytes, 7}; !reflect.DeepEqual(lengths, want) {
t.Fatalf("emitted lengths = %v, want bounded chunks %v", lengths, want)
}
}

func TestWriterDoesNotInventEmptyLineAfterExactBoundary(t *testing.T) {
var lengths []int
w := New(func(line string) { lengths = append(lengths, len(line)) })

if _, err := w.Write(bytes.Repeat([]byte{'x'}, maxPendingBytes)); err != nil {
t.Fatalf("write boundary-sized line: %v", err)
}
if _, err := w.Write([]byte{'\n'}); err != nil {
t.Fatalf("write terminating newline: %v", err)
}

if want := []int{maxPendingBytes}; !reflect.DeepEqual(lengths, want) {
t.Fatalf("emitted lengths = %v, want %v", lengths, want)
}
}
45 changes: 15 additions & 30 deletions adapters/linux/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@
package linux

import (
"bufio"
"bytes"
"context"
"encoding/json"
Expand All @@ -42,6 +41,8 @@ import (
"strings"
"sync"
"time"

"github.com/Divaaaan/tenebra/adapters/internal/processlog"
)

// defaultClashPort matches singbox.TunOptions' default external controller port,
Expand All @@ -59,6 +60,10 @@ const logRingSize = 200
// is slow or not yet listening.
const statsTimeout = 2 * time.Second

// processOutputWaitDelay bounds Cmd.Wait's drain of stdout/stderr after the
// supervised process exits but a descendant keeps inherited handles open.
const processOutputWaitDelay = 500 * time.Millisecond

// maxConnectionsBody bounds the read of the /connections document. It has to be
// generous because the body is parsed as one JSON value: the totals live at the
// head of the object, but json.Unmarshal still has to walk the whole connection
Expand Down Expand Up @@ -184,19 +189,11 @@ func (r *Runner) Start(ctx context.Context, configJSON []byte) error {

runCtx, cancel := context.WithCancel(ctx)
cmd := exec.CommandContext(runCtx, bin, "run", "-c", cfgPath)

stdout, err := cmd.StdoutPipe()
if err != nil {
cancel()
os.Remove(cfgPath)
return fmt.Errorf("linux: stdout pipe: %w", err)
}
stderr, err := cmd.StderrPipe()
if err != nil {
cancel()
os.Remove(cfgPath)
return fmt.Errorf("linux: stderr pipe: %w", err)
}
stdoutLog := processlog.New(r.ring.add)
stderrLog := processlog.New(r.ring.add)
cmd.Stdout = stdoutLog
cmd.Stderr = stderrLog
cmd.WaitDelay = processOutputWaitDelay

if err := cmd.Start(); err != nil {
cancel()
Expand All @@ -211,15 +208,13 @@ func (r *Runner) Start(ctx context.Context, configJSON []byte) error {
r.cfgPath = cfgPath
r.clashSecret = secret

// Drain both streams into the ring buffer; the goroutines end when the pipes
// close on process exit.
go r.scan(stdout)
go r.scan(stderr)

// One watcher owns Wait. It publishes the exit on done, closes it, and clears
// the running state so the Runner can be started again.
// the running state so the Runner can be started again. Cmd.Wait owns and
// bounds stdout/stderr copying through WaitDelay.
go func() {
werr := cmd.Wait()
stdoutLog.Flush()
stderrLog.Flush()
cancel()
os.Remove(cfgPath)

Expand Down Expand Up @@ -493,16 +488,6 @@ func (r *Runner) Logs() []string {
return ring.snapshot()
}

// scan copies a process stream line by line into the ring buffer.
func (r *Runner) scan(rc io.ReadCloser) {
defer rc.Close()
sc := bufio.NewScanner(rc)
sc.Buffer(make([]byte, 0, 64*1024), 1<<20)
for sc.Scan() {
r.ring.add(sc.Text())
}
}

// singboxVersionTimeout bounds the `sing-box version` call. The binary answers
// instantly or not at all; a longer budget would only lengthen the wait when the
// file on disk is not actually an executable.
Expand Down
Loading
Loading