From 87e6827cf1bc89134a2b814300fb71e6614da2ee Mon Sep 17 00:00:00 2001 From: James Tucker Date: Fri, 27 Feb 2026 12:10:16 -0800 Subject: [PATCH] ruby: add TsnetAccept export, complete Ruby API bindings, and expand test coverage Move tailscale_accept from a C recvmsg implementation to a Go export (TsnetAccept) that uses syscall.Recvmsg and ParseUnixRights, giving proper error reporting through recErr. Add missing Ruby FFI bindings and wrapper methods for TsnetGetIps, TsnetGetRemoteAddr, and TsnetAccept. Fix Listener#accept to use TsnetAccept with IO.select, and Listener#close to use IO.for_fd. Guard TsnetClose against never-started servers to avoid a panic in tsnet.Server.Close when internal state is nil. Add substantially more test coverage. --- ruby/lib/tailscale.rb | 34 ++- ruby/test/tailscale/test_tailscale.rb | 322 ++++++++++++++++++++++++-- tailscale.c | 23 +- tailscale.go | 52 ++++- 4 files changed, 388 insertions(+), 43 deletions(-) diff --git a/ruby/lib/tailscale.rb b/ruby/lib/tailscale.rb index eab3aa1..d17c43c 100644 --- a/ruby/lib/tailscale.rb +++ b/ruby/lib/tailscale.rb @@ -39,8 +39,9 @@ module Libtailscale attach_function :TsnetSetLogFD, [:int, :int], :int attach_function :TsnetDial, [:int, :string, :string, :pointer], :int, blocking: true attach_function :TsnetListen, [:int, :string, :string, :pointer], :int - attach_function :close, [:int], :int - attach_function :tailscale_accept, [:int, :pointer], :int, blocking: true + attach_function :TsnetAccept, [:int, :pointer], :int, blocking: true + attach_function :TsnetGetIps, [:int, :pointer, :size_t], :int + attach_function :TsnetGetRemoteAddr, [:int, :int, :pointer, :size_t], :int attach_function :TsnetErrmsg, [:int, :pointer, :size_t], :int attach_function :TsnetLoopback, [:int, :pointer, :size_t, :pointer, :pointer], :int end @@ -81,20 +82,33 @@ def initialize(ts, listener) @listener = listener end + # Get the remote address of an accepted connection. +conn+ is the +IO+ + # object returned by +accept+. Returns the remote IP address as a string. + def get_remote_addr(conn) + @ts.assert_open + buf = FFI::MemoryPointer.new(:char, 1024) + Error.check(@ts, Libtailscale::TsnetGetRemoteAddr(@listener, conn.fileno, buf, buf.size)) + buf.read_string + end + # Accept a new connection. This method blocks until a new connection is # received. An +IO+ object is returned which can be used to read and # write. def accept @ts.assert_open + lio = IO.for_fd(@listener) + until IO.select([lio]).first.any? + @ts.assert_open + end conn = FFI::MemoryPointer.new(:int) - Error.check(@ts, Libtailscale::tailscale_accept(@listener, conn)) + Error.check(@ts, Libtailscale::TsnetAccept(@listener, conn)) IO::new(conn.read_int) end # Close the listener. def close @ts.assert_open - Error.check(@ts, Libtailscale::close(@listener)) + IO.for_fd(@listener).close end end @@ -228,9 +242,17 @@ def set_log_fd(log_fd) Error.check(self, Libtailscale::TsnetSetLogFD(@t, log_fd)) end + # Get the IP addresses of this Tailscale node as an array of strings. + # The node must be started before calling this method. + def get_ips + assert_open + buf = FFI::MemoryPointer.new(:char, 1024) + Error.check(self, Libtailscale::TsnetGetIps(@t, buf, buf.size)) + buf.read_string.split(",") + end + # Dial a network address. +network+ is one of "tcp" or "udp". +addr+ is the - # remote address to connect to. This method blocks until the connection is - # established. + # remote address to connect to. This method blocks until the connection is established. def dial(network, addr) assert_open conn = FFI::MemoryPointer.new(:int) diff --git a/ruby/test/tailscale/test_tailscale.rb b/ruby/test/tailscale/test_tailscale.rb index 4109b72..7908d6c 100644 --- a/ruby/test/tailscale/test_tailscale.rb +++ b/ruby/test/tailscale/test_tailscale.rb @@ -4,8 +4,12 @@ require "test_helper" require "fileutils" require "tmpdir" +require "timeout" -class TestTailscale < Minitest::Test +# TestTailscaleConfig tests configuration methods that don't require networking. +# Servers are never started so never need Close (tsnet panics on Close for +# unstarted servers). +class TestTailscaleConfig < Minitest::Test def setup super @tmpdir = Dir.mktmpdir @@ -20,31 +24,319 @@ def test_that_it_has_a_version_number refute_nil(::Tailscale::VERSION) end - def test_listen_sorta_works - ts = newts + def test_new_returns_valid_server + ts = Tailscale.new + ts.set_hostname("test-new") + end + + def test_set_dir + Tailscale.new.set_dir(@tmpdir) + end + + def test_set_hostname + Tailscale.new.set_hostname("my-ruby-host") + end + + def test_set_auth_key + Tailscale.new.set_auth_key("tskey-auth-fake-key") + end + + def test_set_ephemeral_true + Tailscale.new.set_ephemeral(true) + end + + def test_set_ephemeral_false + Tailscale.new.set_ephemeral(false) + end + + def test_set_control_url + Tailscale.new.set_control_url($testcontrol_url) + end + + def test_set_log_fd_file + logfile = File.join(@tmpdir, "test.log") + fd = IO.sysopen(logfile, "w+") + Tailscale.new.set_log_fd(fd) + end + + def test_set_log_fd_discard + Tailscale.new.set_log_fd(-1) + end + + def test_closed_error_on_set_dir + assert_raises(Tailscale::ClosedError) { new_closed_ts.set_dir("/tmp") } + end + + def test_closed_error_on_set_hostname + assert_raises(Tailscale::ClosedError) { new_closed_ts.set_hostname("fail") } + end + + def test_closed_error_on_set_auth_key + assert_raises(Tailscale::ClosedError) { new_closed_ts.set_auth_key("fail") } + end + + def test_closed_error_on_set_control_url + assert_raises(Tailscale::ClosedError) { new_closed_ts.set_control_url("fail") } + end + + def test_closed_error_on_set_ephemeral + assert_raises(Tailscale::ClosedError) { new_closed_ts.set_ephemeral(true) } + end + + def test_closed_error_on_set_log_fd + assert_raises(Tailscale::ClosedError) { new_closed_ts.set_log_fd(-1) } + end + + def test_closed_error_on_dial + assert_raises(Tailscale::ClosedError) { new_closed_ts.dial("tcp", "127.0.0.1:80") } + end + + def test_closed_error_on_listen + assert_raises(Tailscale::ClosedError) { new_closed_ts.listen("tcp", ":1234") } + end + + def test_closed_error_on_get_ips + assert_raises(Tailscale::ClosedError) { new_closed_ts.get_ips } + end + + def test_closed_error_on_loopback + assert_raises(Tailscale::ClosedError) { new_closed_ts.loopback } + end + + def test_error_class_attributes + err = Tailscale::Error.new("test error", 42) + assert_equal "test error", err.message + assert_equal 42, err.code + end + + def test_closed_error_message + err = Tailscale::ClosedError.new + assert_match(/closed/, err.message) + end + + def test_errmsg_on_fresh_server + msg = Tailscale.new.errmsg + assert_kind_of String, msg + end + + private + + # Simulate a closed server by setting the handle to -1 so assert_open + # raises without needing to actually start and close a server. + def new_closed_ts + ts = Tailscale.new + ts.instance_variable_set(:@t, -1) + ts + end +end + +# TestTailscaleNetwork tests operations that require running servers. +# Two shared servers (s1, s2) are brought up once, matching the approach +# used by the Go C integration test (tsnetctest): s1 listens, s2 dials. +class TestTailscaleNetwork < Minitest::Test + @@mu = Mutex.new + @@s1 = nil + @@s2 = nil + @@s1_ip = nil + @@s2_ip = nil + @@tmpdir = nil + + def self.ensure_servers + @@mu.synchronize do + return if @@s1 + @@tmpdir = Dir.mktmpdir + + @@s1 = make_server(File.join(@@tmpdir, "s1")) + @@s2 = make_server(File.join(@@tmpdir, "s2")) + + wait_running(@@s1) + wait_running(@@s2) + + @@s1_ip = @@s1.local_api.status["Self"]["TailscaleIPs"][0] + @@s2_ip = @@s2.local_api.status["Self"]["TailscaleIPs"][0] + end + end + + def self.make_server(dir) + FileUtils.mkdir_p(dir) + ts = Tailscale.new + unless ENV["VERBOSE"] + logfd = IO.sysopen("/dev/null", "w+") + ts.set_log_fd(logfd) + end + ts.set_ephemeral(true) + ts.set_dir(dir) + ts.set_control_url($testcontrol_url) ts.up - s = ts.listen("tcp", ":1999") + ts + end + + def self.wait_running(ts) + deadline = Time.now + 30 + loop do + break if ts.local_api.status["BackendState"] == "Running" + raise "timed out waiting for BackendState Running" if Time.now > deadline + sleep 0.05 + end + end + + Minitest.after_run do + @@mu.synchronize do + [@@s1, @@s2].compact.each { |s| s.close rescue nil } + @@s1 = @@s2 = nil + FileUtils.remove_entry_secure(@@tmpdir) if @@tmpdir + end + end + + def setup + super + self.class.ensure_servers + end + + def s1; @@s1; end + def s2; @@s2; end + def s1_ip; @@s1_ip; end + def s2_ip; @@s2_ip; end + + def test_start_async + tmpdir = Dir.mktmpdir + t = newts(tmpdir) + t.start + sleep 0.5 + t.close + FileUtils.remove_entry_secure(tmpdir) + end + + def test_up_and_close + tmpdir = Dir.mktmpdir + t = newts(tmpdir) + t.up + self.class.wait_running(t) + assert_equal "Running", t.local_api.status["BackendState"] + t.close + assert_raises(Tailscale::ClosedError) { t.set_hostname("fail") } + FileUtils.remove_entry_secure(tmpdir) + end + + def test_set_hostname_visible_in_status + tmpdir = Dir.mktmpdir + t = newts(tmpdir) + t.set_hostname("my-ruby-host") + t.up + self.class.wait_running(t) + assert_match(/my-ruby-host/, t.local_api.status["Self"]["HostName"]) + t.close + FileUtils.remove_entry_secure(tmpdir) + end + + def test_get_ips + ips = s1.get_ips + assert_kind_of Array, ips + refute_empty ips + assert ips.any? { |i| i.start_with?("100.") }, + "expected a 100.x.y.z tailscale IPv4, got: #{ips}" + end + + def test_loopback + addr, proxy_cred, local_cred = s1.loopback + assert_match(/:\d+$/, addr) + assert_equal 32, proxy_cred.length + assert_equal 32, local_cred.length + end + + def test_local_api_client + client = s1.local_api_client + assert_kind_of Tailscale::LocalAPIClient, client + refute_nil client.address + refute_nil client.credential + response = client.get("/localapi/v0/status") + assert_equal "200", response.code + end + + def test_local_api_status + status = s1.local_api.status + assert_kind_of Hash, status + assert_equal "Running", status["BackendState"] + assert_kind_of Hash, status["Self"] + refute_empty status["Self"]["TailscaleIPs"] + end + + def test_listen_and_close + s = s1.listen("tcp", ":1999") s.close - ts.close end - def test_dial_sorta_works - ts = newts - ts.up - c = ts.dial("udp", "100.100.100.100:53") + def test_dial_udp + c = s2.dial("udp", "100.100.100.100:53") c.close - ts.close end - def newts - t = Tailscale::new + def test_listen_accept_dial_data_transfer + Timeout.timeout(30) do + ln = s1.listen("tcp", "#{s1_ip}:8081") + c = s2.dial("tcp", "#{s1_ip}:8081") + c.sync = true + ss = ln.accept + ss.sync = true + c.syswrite "hello" + assert_equal "hello", ss.sysread(5) + ss.syswrite "world" + assert_equal "world", c.sysread(5) + ss.close + c.close + ln.close + end + end + + def test_listen_accept_dial_large_data + Timeout.timeout(30) do + ln = s1.listen("tcp", "#{s1_ip}:8082") + c = s2.dial("tcp", "#{s1_ip}:8082") + c.sync = true + ss = ln.accept + ss.sync = true + + payload = "A" * 8192 + c.syswrite(payload) + received = "".b + while received.length < payload.length + chunk = ss.sysread([payload.length - received.length, 65536].min) + received << chunk + end + assert_equal payload.length, received.length + assert_equal payload, received + + ss.close + c.close + ln.close + end + end + + def test_get_remote_addr + Timeout.timeout(30) do + ln = s1.listen("tcp", "#{s1_ip}:8083") + c = s2.dial("tcp", "#{s1_ip}:8083") + ss = ln.accept + remote_addr = ln.get_remote_addr(ss) + refute_nil remote_addr + refute_empty remote_addr + assert_match(/\d+\.\d+\.\d+\.\d+|\[.+\]/, remote_addr) + ss.close + c.close + ln.close + end + end + + private + + def newts(dir) + t = Tailscale.new unless ENV["VERBOSE"] logfd = IO.sysopen("/dev/null", "w+") t.set_log_fd(logfd) end - - t.set_ephemeral(1) - t.set_dir(@tmpdir) + t.set_ephemeral(true) + t.set_dir(dir) t.set_control_url($testcontrol_url) t end diff --git a/tailscale.c b/tailscale.c index 8cbbb22..be18085 100644 --- a/tailscale.c +++ b/tailscale.c @@ -22,6 +22,7 @@ extern int TsnetSetLogFD(int sd, int fd); extern int TsnetGetIps(int sd, char *buf, size_t buflen); extern int TsnetGetRemoteAddr(int listener, int conn, char *buf, size_t buflen); extern int TsnetListen(int sd, char* net, char* addr, int* listenerOut); +extern int TsnetAccept(int ld, int* connOut); extern int TsnetLoopback(int sd, char* addrOut, size_t addrLen, char* proxyOut, char* localOut); extern int TsnetEnableFunnelToLocalhostPlaintextHttp1(int sd, int localhostPort); @@ -50,27 +51,7 @@ int tailscale_listen(tailscale sd, const char* network, const char* addr, tailsc } int tailscale_accept(tailscale_listener ld, tailscale_conn* conn_out) { - struct msghdr msg = {0}; - - char mbuf[256]; - struct iovec io = { .iov_base = mbuf, .iov_len = sizeof(mbuf) }; - msg.msg_iov = &io; - msg.msg_iovlen = 1; - - char cbuf[256]; - msg.msg_control = cbuf; - msg.msg_controllen = sizeof(cbuf); - - if (recvmsg(ld, &msg, 0) == -1) { - return -1; - } - - struct cmsghdr* cmsg = CMSG_FIRSTHDR(&msg); - unsigned char* data = CMSG_DATA(cmsg); - - int fd = *(int*)data; - *conn_out = fd; - return 0; + return TsnetAccept(ld, (int*)conn_out); } int tailscale_getremoteaddr(tailscale_listener l, tailscale_conn conn, char* buf, size_t buflen) { diff --git a/tailscale.go b/tailscale.go index 605506d..2238212 100644 --- a/tailscale.go +++ b/tailscale.go @@ -20,6 +20,7 @@ import ( "syscall" "unsafe" + "golang.org/x/sys/unix" "tailscale.com/hostinfo" "tailscale.com/ipn" "tailscale.com/tsnet" @@ -38,6 +39,7 @@ var servers struct { type server struct { s *tsnet.Server lastErr string + started bool } func getServer(sd C.int) *server { @@ -106,7 +108,11 @@ func TsnetStart(sd C.int) C.int { if s == nil { return C.EBADF } - return s.recErr(s.s.Start()) + err := s.s.Start() + if err == nil { + s.started = true + } + return s.recErr(err) } //export TsnetUp @@ -116,6 +122,9 @@ func TsnetUp(sd C.int) C.int { return C.EBADF } _, err := s.s.Up(context.Background()) // cancellation is via TsnetClose + if err == nil { + s.started = true + } return s.recErr(err) } @@ -134,6 +143,10 @@ func TsnetClose(sd C.int) C.int { // TODO: cancel Up // TODO: close related listeners / conns. + if !s.started { + // Server was never started, nothing to close. + return 0 + } if err := s.s.Close(); err != nil { s.s.Logf("tailscale_close: failed with %v", err) return -1 @@ -209,6 +222,7 @@ func TsnetListen(sd C.int, network, addr *C.char, listenerOut *C.int) C.int { if err != nil { return s.recErr(err) } + s.started = true // The tailscale_listener we return to C is one side of a socketpair(2). // We do this so we can proactively call ln.Accept in a goroutine and @@ -292,6 +306,41 @@ func TsnetListen(sd C.int, network, addr *C.char, listenerOut *C.int) C.int { return 0 } +//export TsnetAccept +func TsnetAccept(listenerFd C.int, connOut *C.int) C.int { + listeners.mu.Lock() + ln := listeners.m[listenerFd] + listeners.mu.Unlock() + + if ln == nil { + return C.EBADF + } + + buf := make([]byte, unix.CmsgLen(int(unsafe.Sizeof((C.int)(0))))) + _, oobn, _, _, err := syscall.Recvmsg(int(listenerFd), nil, buf, 0) + if err != nil { + return ln.s.recErr(err) + } + + scms, err := syscall.ParseSocketControlMessage(buf[:oobn]) + if err != nil { + return ln.s.recErr(err) + } + if len(scms) != 1 { + return ln.s.recErr(fmt.Errorf("libtailscale: got %d control messages, want 1", len(scms))) + } + fds, err := syscall.ParseUnixRights(&scms[0]) + if err != nil { + return ln.s.recErr(err) + } + if len(fds) != 1 { + return ln.s.recErr(fmt.Errorf("libtailscale: got %d FDs, want 1", len(fds))) + } + *connOut = (C.int)(fds[0]) + + return 0 +} + func newConn(s *server, netConn net.Conn, connOut *C.int) error { fds, err := syscall.Socketpair(syscall.AF_LOCAL, syscall.SOCK_STREAM, 0) if err != nil { @@ -400,6 +449,7 @@ func TsnetDial(sd C.int, network, addr *C.char, connOut *C.int) C.int { if err != nil { return s.recErr(err) } + s.started = true if err := newConn(s, netConn, connOut); err != nil { return s.recErr(err) }