tor: skip onion cleanup before service creation
What changed, and why it matters
This commit fixes a bug in how LND's Tor controller shuts down. Previously, during startup cleanup, the controller would try to delete an onion service that might never have been created, which could hide the real reason why creating the onion service failed. The fix only deletes the onion service if it was actually created, and makes shutdown more robust by always closing the Tor control connection and reporting both cleanup errors clearly.
Review and merge. The change is defensive and improves diagnostics and cleanup reliability. No immediate security patch urgency, but it removes a failure-mode that could complicate incident response.
Security signals we found
Error-handling improvement that prevents masking of original ADD_ONION failures
Ensures control connection is closed even when DEL_ONION fails
Prevents stopped flag from being consumed when no connection exists
Preserves diagnostic state (activeServiceID) when both cleanup paths fail
Evidence from the diff
The patch modifies tor/controller.go’s Stop() method. Previously it unconditionally called DelOnion(c.activeServiceID) and then checked if c.conn was nil. The new logic: (1) returns an error immediately if no connection exists, without consuming the stopped flag; (2) only issues DEL_ONION when activeServiceID is non-empty; (3) always closes the control connection; (4) uses errors.Join to preserve both DEL_ONION and connection close errors; (5) clears activeServiceID if either cleanup step succeeds, since Tor drops ephemeral services when the control connection closes. Tests are added to cover no-connection, no-active-service, DEL_ONION error, and combined error cases.
Changed components
tor/controller.gotor/controller_test.goInspect captured patch +152 / −10
diff --git a/tor/controller.go b/tor/controller.go
index 6facef8..63790a1 100644
--- a/tor/controller.go
+++ b/tor/controller.go
@@ -171,26 +171,35 @@ func (c *Controller) Start() error {
// Stop closes the connection between the controller and the Tor server.
func (c *Controller) Stop() error {
+ if c.conn == nil {
+ return fmt.Errorf("no connection available to the tor server")
+ }
+
if !atomic.CompareAndSwapInt32(&c.stopped, 0, 1) {
return nil
}
log.Info("Stopping tor controller")
- // Remove the onion service.
- if err := c.DelOnion(c.activeServiceID); err != nil {
- log.Errorf("DEL_ONION got error: %v", err)
- return err
- }
+ var delOnionErr error
- // Reset service ID.
- c.activeServiceID = ""
+ // Remove the onion service if one was created successfully.
+ if c.activeServiceID != "" {
+ if err := c.DelOnion(c.activeServiceID); err != nil {
+ log.Errorf("DEL_ONION got error: %v", err)
+ delOnionErr = err
+ }
+ }
- if c.conn == nil {
- return fmt.Errorf("no connection available to the tor server")
+ closeErr := c.conn.Close()
+ if delOnionErr == nil || closeErr == nil {
+ // Reset service ID. If DEL_ONION failed but the control
+ // connection closed successfully, the ephemeral service is
+ // removed by Tor along with the connection.
+ c.activeServiceID = ""
}
- return c.conn.Close()
+ return errors.Join(delOnionErr, closeErr)
}
// Reconnect makes a new socket connection between the tor controller and
diff --git a/tor/controller_test.go b/tor/controller_test.go
index 279b4f3..3937bbb 100644
--- a/tor/controller_test.go
+++ b/tor/controller_test.go
@@ -1,12 +1,16 @@
package tor
import (
+ "bufio"
+ "errors"
"fmt"
+ "io"
"net"
"net/textproto"
"os"
"path/filepath"
"strconv"
+ "strings"
"sync"
"testing"
"time"
@@ -118,6 +122,26 @@ func (tp *testProxy) cleanUp() {
}
}
+// closeErrorConn is an in-memory control connection that returns a configured
+// error from Close.
+type closeErrorConn struct {
+ responses *strings.Reader
+ commands strings.Builder
+ closeErr error
+}
+
+func (c *closeErrorConn) Read(p []byte) (int, error) {
+ return c.responses.Read(p)
+}
+
+func (c *closeErrorConn) Write(p []byte) (int, error) {
+ return c.commands.Write(p)
+}
+
+func (c *closeErrorConn) Close() error {
+ return c.closeErr
+}
+
// createTestProxy creates a proxy server to listen on a random address,
// creates a server and a client connection, and initializes a testProxy using
// these params.
@@ -302,6 +326,115 @@ func TestReconnectTCMustBeRunning(t *testing.T) {
require.Equal(t, errTCStopped, c.Reconnect())
}
+// TestStopWithoutConnectionDoesNotMarkStopped checks that Stop doesn't consume
+// the one-way stopped flag if no control connection is available.
+func TestStopWithoutConnectionDoesNotMarkStopped(t *testing.T) {
+ c := &Controller{}
+
+ err := c.Stop()
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "no connection available")
+ require.Zero(t, c.stopped)
+}
+
+// TestStopWithoutActiveService closes the control connection without issuing a
+// DEL_ONION command if ADD_ONION never completed successfully.
+func TestStopWithoutActiveService(t *testing.T) {
+ proxy := createTestProxy(t)
+ t.Cleanup(proxy.cleanUp)
+
+ c := &Controller{
+ conn: proxy.clientConn,
+ }
+
+ require.NoError(t, c.Stop())
+
+ assertControlConnClosed(t, proxy.serverConn)
+}
+
+// TestStopClosesConnectionOnDelOnionError checks that Stop closes the control
+// connection even if Tor rejects the DEL_ONION command.
+func TestStopClosesConnectionOnDelOnionError(t *testing.T) {
+ proxy := createTestProxy(t)
+ t.Cleanup(proxy.cleanUp)
+
+ const serviceID = "fakeID"
+ c := &Controller{
+ conn: proxy.clientConn,
+ activeServiceID: serviceID,
+ }
+
+ serverErr := make(chan error, 1)
+ go func() {
+ reader := bufio.NewReader(proxy.serverConn)
+
+ line, err := reader.ReadString('\n')
+ if err != nil {
+ serverErr <- err
+
+ return
+ }
+
+ _, writeErr := proxy.serverConn.Write([]byte(
+ "512 Bad arguments\r\n",
+ ))
+
+ expectedCmd := fmt.Sprintf("DEL_ONION %s\r\n", serviceID)
+ if line != expectedCmd {
+ serverErr <- fmt.Errorf("expected %q, got %q",
+ expectedCmd, line)
+
+ return
+ }
+
+ serverErr <- writeErr
+ }()
+
+ err := c.Stop()
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "invalid arguments")
+ require.NoError(t, <-serverErr)
+ require.Empty(t, c.activeServiceID)
+
+ assertControlConnClosed(t, proxy.serverConn)
+}
+
+// TestStopReturnsDelOnionAndCloseErrors checks that Stop preserves both
+// cleanup failures and keeps the active service ID for diagnostics.
+func TestStopReturnsDelOnionAndCloseErrors(t *testing.T) {
+ const serviceID = "fakeID"
+
+ closeErr := errors.New("close failed")
+ conn := &closeErrorConn{
+ responses: strings.NewReader("512 Bad arguments\r\n"),
+ closeErr: closeErr,
+ }
+ c := &Controller{
+ conn: textproto.NewConn(conn),
+ activeServiceID: serviceID,
+ }
+
+ err := c.Stop()
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "invalid arguments")
+ require.ErrorIs(t, err, closeErr)
+ require.Equal(t, serviceID, c.activeServiceID)
+ require.Equal(t, "DEL_ONION fakeID\r\n", conn.commands.String())
+}
+
+// assertControlConnClosed asserts that the control connection was closed by
+// the peer instead of timing out while waiting for data.
+func assertControlConnClosed(t *testing.T, conn net.Conn) {
+ t.Helper()
+
+ buf := make([]byte, 1)
+ require.NoError(t, conn.SetReadDeadline(
+ time.Now().Add(50*time.Millisecond),
+ ))
+ _, err := conn.Read(buf)
+ require.ErrorIs(t, err, io.EOF)
+}
+
// TestReconnectSucceed tests a reconnection will succeed when the tor
// controller is up and running.
func TestReconnectSucceed(t *testing.T) {
Why this scored 22/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.