diff --git a/tor/cmd_info.go b/tor/cmd_info.go new file mode 100644 index 000000000..ea6452e6c --- /dev/null +++ b/tor/cmd_info.go @@ -0,0 +1,72 @@ +package tor + +import ( + "errors" + "fmt" +) + +var ( + // ErrServiceNotCreated is used when we want to query info on an onion + // service while it's not been created yet. + ErrServiceNotCreated = errors.New("onion service hasn't been created") + + // ErrServiceIDUnmatch is used when the serviceID the controller has + // doesn't match the serviceID the Tor daemon has. + ErrServiceIDUnmatch = errors.New("onion serviceIDs not match") + + // ErrNoServiceFound is used when the Tor daemon replies no active + // onion services found for the current control connection while we + // expect one. + ErrNoServiceFound = errors.New("no active service found") +) + +// CheckOnionService checks that the onion service created by the controller +// is active. It queries the Tor daemon using the endpoint "onions/current" to +// get the current onion service and checks that service ID matches the +// activeServiceID. +func (c *Controller) CheckOnionService() error { + // Check that we have a hidden service created. + if c.activeServiceID == "" { + return ErrServiceNotCreated + } + + // Fetch the onion services that live in current control connection. + cmd := "GETINFO onions/current" + code, reply, err := c.sendCommand(cmd) + + // Exit early if we got an error or Tor daemon didn't respond success. + // TODO(yy): unify the usage of err and code so we could rely on a + // single source to change our state. + if err != nil || code != success { + log.Debugf("query service:%v got err:%v, reply:%v", + c.activeServiceID, err, reply) + + return fmt.Errorf("%w: %v", err, reply) + } + + // Parse the reply, which should have the following format, + // onions/current=serviceID + // After parsing, we get a map as, + // [onion/current: serviceID] + // + // NOTE: our current tor controller does NOT support multiple onion + // services to be created at the same time, thus we expect the reply to + // only contain one serviceID. If multiple serviceIDs are returned, we + // would expected the reply to have the following format, + // onions/current=serviceID1, serviceID2, serviceID3,... + // Thus a new parser is need to parse that reply. + resp := parseTorReply(reply) + serviceID, ok := resp["onions/current"] + if !ok { + return ErrNoServiceFound + } + + // Check that our active service is indeed the service acknowledged by + // Tor daemon. + if c.activeServiceID != serviceID { + return fmt.Errorf("%w: controller has: %v, Tor daemon has: %v", + ErrServiceIDUnmatch, c.activeServiceID, serviceID) + } + + return nil +} diff --git a/tor/cmd_info_test.go b/tor/cmd_info_test.go new file mode 100644 index 000000000..81e9d051d --- /dev/null +++ b/tor/cmd_info_test.go @@ -0,0 +1,87 @@ +package tor + +import ( + "errors" + "io" + "syscall" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCheckOnionServiceFailOnServiceNotCreated(t *testing.T) { + t.Parallel() + + // Create a dummy tor controller. + c := &Controller{} + + // Check that CheckOnionService returns an error when the service + // hasn't been created. + require.Equal(t, ErrServiceNotCreated, c.CheckOnionService()) +} + +func TestCheckOnionServiceSucceed(t *testing.T) { + t.Parallel() + + // Create mock server and client connection. + proxy := createTestProxy(t) + defer proxy.cleanUp() + server := proxy.serverConn + + // Assign a fake service ID to the controller. + c := &Controller{conn: proxy.clientConn, activeServiceID: "fakeID"} + + // Test a successful response. + serverResp := "250-onions/current=fakeID\n250 OK\n" + + // Let the server mocks a given response. + _, err := server.Write([]byte(serverResp)) + require.NoError(t, err, "server failed to write") + + // For a successful response, we expect no error. + require.NoError(t, c.CheckOnionService()) +} + +func TestCheckOnionServiceFailOnServiceIDNotMatch(t *testing.T) { + t.Parallel() + + // Create mock server and client connection. + proxy := createTestProxy(t) + defer proxy.cleanUp() + server := proxy.serverConn + + // Assign a fake service ID to the controller. + c := &Controller{conn: proxy.clientConn, activeServiceID: "fakeID"} + + // Mock a response with a different serviceID. + serverResp := "250-onions/current=unmatchedID\n250 OK\n" + + // Let the server mocks a given response. + _, err := server.Write([]byte(serverResp)) + require.NoError(t, err, "server failed to write") + + // Check the error returned from GetServiceInfo is expected. + require.ErrorIs(t, c.CheckOnionService(), ErrServiceIDUnmatch) +} + +func TestCheckOnionServiceFailOnClosedConnection(t *testing.T) { + t.Parallel() + + // Create mock server and client connection. + proxy := createTestProxy(t) + defer proxy.cleanUp() + server := proxy.serverConn + + // Assign a fake service ID to the controller. + c := &Controller{conn: proxy.clientConn, activeServiceID: "fakeID"} + + // Close the connection from the server side. + require.NoError(t, server.Close(), "server failed to close conn") + + // Check the error returned from GetServiceInfo is expected. + err := c.CheckOnionService() + eof := errors.Is(err, io.EOF) + reset := errors.Is(err, syscall.ECONNRESET) + require.Truef(t, eof || reset, + "must of EOF or RESET error, instead got: %v", err) +}