diff --git a/.gitignore b/.gitignore index 3812672..49fb623 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ libtailscale.h libtailscale.tar* libtailscale_*.a libtailscale_*.h +libtailscale.bundle /tstestcontrol/libtstestcontrol.a /tstestcontrol/libtstestcontrol.h diff --git a/ruby/Gemfile b/ruby/Gemfile index 5cedead..97e7505 100644 --- a/ruby/Gemfile +++ b/ruby/Gemfile @@ -9,4 +9,5 @@ gemspec gem "rake", "~> 13.0" gem "rake-compiler", "~> 1.2.1" -gem "minitest", "~> 5.0" \ No newline at end of file +gem "minitest", "~> 5.0" +gem "base64", "~>0.3" diff --git a/ruby/Gemfile.lock b/ruby/Gemfile.lock index 76d7c54..6b17382 100644 --- a/ruby/Gemfile.lock +++ b/ruby/Gemfile.lock @@ -2,21 +2,25 @@ PATH remote: . specs: tailscale (0.1.0) + base64 (~> 0.3) ffi (~> 1.15.5) GEM remote: https://rubygems.org/ specs: + base64 (0.3.0) ffi (1.15.5) - minitest (5.16.3) - rake (13.0.6) - rake-compiler (1.2.1) + minitest (5.27.0) + rake (13.3.1) + rake-compiler (1.2.9) rake PLATFORMS + arm64-darwin-25 x86_64-linux-gnu DEPENDENCIES + base64 (~> 0.3) minitest (~> 5.0) rake (~> 13.0) rake-compiler (~> 1.2.1) diff --git a/ruby/ext/libtailscale/extconf.rb b/ruby/ext/libtailscale/extconf.rb index e0279fa..1cf0882 100644 --- a/ruby/ext/libtailscale/extconf.rb +++ b/ruby/ext/libtailscale/extconf.rb @@ -1,12 +1,13 @@ # Copyright (c) Tailscale Inc & AUTHORS # SPDX-License-Identifier: BSD-3-Clause # frozen_string_literal: true -require 'rbconfig' +require "rbconfig" + open("Makefile", "w") do |f| - f.puts "libtailscale.#{RbConfig::CONFIG['DLEXT']}:" - f.puts "\tgo build -C #{File.expand_path(__dir__)} -buildmode=c-shared -o #{Dir.pwd}/$@ ." + f.puts("libtailscale.#{RbConfig::CONFIG["DLEXT"]}:") + f.puts("\tgo build -C #{File.expand_path(__dir__)} -buildmode=c-shared -o \"#{Dir.pwd}/$@\" .") - f.puts "install: libtailscale.#{RbConfig::CONFIG['DLEXT']}" - f.puts "\tmkdir -p #{RbConfig::CONFIG['sitelibdir']}" - f.puts "\tcp libtailscale.#{RbConfig::CONFIG['DLEXT']} #{RbConfig::CONFIG['sitelibdir']}/" -end \ No newline at end of file + f.puts("install: libtailscale.#{RbConfig::CONFIG["DLEXT"]}") + f.puts("\tmkdir -p \"#{RbConfig::CONFIG["sitelibdir"]}\"") + f.puts("\tcp libtailscale.#{RbConfig::CONFIG["DLEXT"]} \"#{RbConfig::CONFIG["sitelibdir"]}/\"") +end diff --git a/ruby/lib/tailscale.rb b/ruby/lib/tailscale.rb index c9f9fce..eab3aa1 100644 --- a/ruby/lib/tailscale.rb +++ b/ruby/lib/tailscale.rb @@ -2,283 +2,288 @@ # SPDX-License-Identifier: BSD-3-Clause # frozen_string_literal: true -require 'tailscale/version' -require 'ffi' -require 'rbconfig' +require "tailscale/version" +require "ffi" +require "rbconfig" +require "net/http" +require "json" +require "base64" # Tailscale provides an embedded tailscale network interface for ruby programs. -class Tailscale - - # Libtailscale is a FFI wrapper around the libtailscale C library. - module Libtailscale - extend FFI::Library - - # In development or in precompiled gems the library is in the lib - # directory, and when installed by rubygems it's in the ruby site lib - # directory. - [__dir__, RbConfig::CONFIG['sitelibdir']].find do |dir| - lib = File.expand_path("libtailscale.#{RbConfig::CONFIG["DLEXT"]}", dir) - if File.exist?(lib) - ffi_lib lib - true - end - end - - attach_function :TsnetNewServer, [], :int - attach_function :TsnetStart, [:int], :int - attach_function :TsnetUp, [:int], :int, blocking: true - attach_function :TsnetClose, [:int], :int - attach_function :TsnetSetDir, [:int, :string], :int - attach_function :TsnetSetHostname, [:int, :string], :int - attach_function :TsnetSetAuthKey, [:int, :string], :int - attach_function :TsnetSetControlURL, [:int, :string], :int - attach_function :TsnetSetEphemeral, [:int, :int], :int - 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 :TsnetErrmsg, [:int, :pointer, :size_t], :int - attach_function :TsnetLoopback, [:int, :pointer, :size_t, :pointer, :pointer], :int +class Tailscale + + # Libtailscale is a FFI wrapper around the libtailscale C library. + module Libtailscale + extend FFI::Library + + # In development or in precompiled gems the library is in the lib + # directory, and when installed by rubygems it's in the ruby site lib + # directory. + [__dir__, RbConfig::CONFIG["sitelibdir"]].find do |dir| + lib = File.expand_path("libtailscale.#{RbConfig::CONFIG["DLEXT"]}", dir) + if File.exist?(lib) + ffi_lib lib + true + end end - class ClosedError < StandardError - def initialize - super "tailscale error: the server is closed" - end - end - - class Error < StandardError - attr_reader :code - - def initialize(msg, code = -1) - @code = code - super msg - end - - def self.check(ts, code) - return if code == 0 - - if code == -1 - msg = ts.errmsg - else - msg = "tailscale error: code: #{code}" - end - raise Error.new(msg, code) - end + attach_function :TsnetNewServer, [], :int + attach_function :TsnetStart, [:int], :int + attach_function :TsnetUp, [:int], :int, blocking: true + attach_function :TsnetClose, [:int], :int + attach_function :TsnetSetDir, [:int, :string], :int + attach_function :TsnetSetHostname, [:int, :string], :int + attach_function :TsnetSetAuthKey, [:int, :string], :int + attach_function :TsnetSetControlURL, [:int, :string], :int + attach_function :TsnetSetEphemeral, [:int, :int], :int + 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 :TsnetErrmsg, [:int, :pointer, :size_t], :int + attach_function :TsnetLoopback, [:int, :pointer, :size_t, :pointer, :pointer], :int + end + + class ClosedError < StandardError + def initialize + super("tailscale error: the server is closed") end + end - # A listening socket on the tailscale network. - class Listener - # Create a new listener, user code should not call this directly, - # instead use +Tailscale#listen+. - def initialize(ts, listener) - @ts = ts - @listener = listener - end - - # Accept a new connection. This method blocks until a new connection is - # recieved. An +IO+ object is returned which can be used to read and - # write. - def accept - @ts.assert_open - conn = FFI::MemoryPointer.new(:int) - 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) - end - end + class Error < StandardError + attr_reader :code - # LocalAPIClient provides a Net::HTTP-alike API that can be used to make - # authenticated requests to the local tailscale API. For higher level use, - # +LocalAPI+ may be more convenient. - class LocalAPIClient - # address is the host:port address of the nodes LocalAPI server. - attr_reader :address - # credential is the basic-auth password used to authenticate requests. - attr_reader :credential - - def initialize(addr, cred) - @address = addr - @credential = cred - @basic = Base64.strict_encode64(":#{cred}") - host, _, port = addr.rpartition(":") - @http = Net::HTTP.new(host, port) - end - - def head(path, initheader = nil, &block) - request Net::HTTP::Head.new(path, initheader), &block - end - - def get(path, initheader = nil, &block) - request Net::HTTP::Get.new(path, initheader), &block - end - - def post(path, body = nil, initheader = nil, &block) - request Net::HTTP::Post.new(path, initheader), body, &block - end - - def put(path, body = nil, initheader = nil, &block) - request Net::HTTP::Put.new(path, initheader), body, &block - end - - def patch(path, body = nil, initheader = nil, &block) - request Net::HTTP::Patch.new(path, initheader), body, &block - end - - def delete(path, initheader = nil, &block) - request Net::HTTP::Delete.new(path, initheader), &block - end - - def request(req, body = nil, &block) - req["Host"] = @address - req["Authorization"] = "Basic #{@basic}" - req["Sec-Tailscale"] = "localapi" - @http.request(req, body, &block) - end + def initialize(msg, code = -1) + @code = code + super(msg) end - # LocalAPI provides a convenient interface for interacting with a LocalAPI given a - # LocalAPIClient to make requests with. - class LocalAPI - - def initialize(client) - @client = client - end + def self.check(ts, code) + return if code == 0 - # status returns the status of the local tailscale node. - def status - @client.get("/localapi/v0/status") do |r| - return JSON.parse(r.body) - end - end + if code == -1 + msg = ts.errmsg + else + msg = "tailscale error: code: #{code}" + end + raise Error.new(msg, code) end - - # Create a new tailscale server. - # - # The server is not started, and no network traffic will occur until start - # is called or network operations are used (such as dial or listen). - def initialize - @t = Libtailscale::TsnetNewServer() - raise Error.new("tailscale error: failed to initialize", @t) if @t < 0 - end - - # Start the tailscale server asynchronously. - def start - Error.check self, Libtailscale::TsnetStart(@t) + end + + # A listening socket on the tailscale network. + class Listener + # Create a new listener, user code should not call this directly, + # instead use +Tailscale#listen+. + def initialize(ts, listener) + @ts = ts + @listener = listener end - # Bring the tailscale server up and wait for it to be usable. This method - # blocks until the node is fully authorized. - def up - Error.check self, Libtailscale::TsnetUp(@t) + # 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 + conn = FFI::MemoryPointer.new(:int) + Error.check(@ts, Libtailscale::tailscale_accept(@listener, conn)) + IO::new(conn.read_int) end - # Close the tailscale server. + # Close the listener. def close - Error.check self, Libtailscale::TsnetClose(@t) - @t = -1 + @ts.assert_open + Error.check(@ts, Libtailscale::close(@listener)) end - - # Set the directory to store tailscale state in. - def set_dir(dir) - assert_open - Error.check self, Libtailscale::TsnetSetDir(@t, dir) + end + + # LocalAPIClient provides a Net::HTTP-alike API that can be used to make + # authenticated requests to the local tailscale API. For higher level use, + # +LocalAPI+ may be more convenient. + class LocalAPIClient + # address is the host:port address of the nodes LocalAPI server. + attr_reader :address + # credential is the basic-auth password used to authenticate requests. + attr_reader :credential + + def initialize(addr, cred) + @address = addr + @credential = cred + @basic = Base64.strict_encode64(":#{cred}") + host, _, port = addr.rpartition(":") + @http = Net::HTTP.new(host, port) end - # Set the hostname to use for the tailscale node. - def set_hostname(hostname) - assert_open - Error.check self, Libtailscale::TsnetSetHostname(@t, hostname) + def head(path, initheader = nil, &block) + request(Net::HTTP::Head.new(path, initheader), &block) end - # Set the auth key to use for the tailscale node. - def set_auth_key(auth_key) - assert_open - Error.check self, Libtailscale::TsnetSetAuthKey(@t, auth_key) + def get(path, initheader = nil, &block) + request(Net::HTTP::Get.new(path, initheader), &block) end - # Set the control URL the node will connect to. - def set_control_url(control_url) - assert_open - Error.check self, Libtailscale::TsnetSetControlURL(@t, control_url) + def post(path, body = nil, initheader = nil, &block) + request(Net::HTTP::Post.new(path, initheader), body, &block) end - # Set whether the node is ephemeral or not. - def set_ephemeral(ephemeral) - assert_open - Error.check self, Libtailscale::TsnetSetEphemeral(@t, ephemeral ? 1 : 0) + def put(path, body = nil, initheader = nil, &block) + request(Net::HTTP::Put.new(path, initheader), body, &block) end - # Set the file descriptor to use for logging. The file descriptor must be - # open for writing. e.g. use `IO.sysopen("/dev/null", "w")` to disable - # logging. - def set_log_fd(log_fd) - assert_open - Error.check self, Libtailscale::TsnetSetLogFD(@t, log_fd) + def patch(path, body = nil, initheader = nil, &block) + request(Net::HTTP::Patch.new(path, initheader), body, &block) end - # Dial a network address. +network+ is one of "tcp" or "udp". +addr+ is the - # remote address to connect to, and +local_addr+ is the local address to - # bind to. This method blocks until the connection is established. - def dial(network, addr, local_addr) - assert_open - conn = FFI::MemoryPointer.new(:int) - Error.check self, Libtailscale::TsnetDial(@t, network, addr, conn) - IO::new conn.read_int + def delete(path, initheader = nil, &block) + request(Net::HTTP::Delete.new(path, initheader), &block) end - # Listen on a network address. +network+ is one of "tcp" or "udp". +addr+ is - # the local address to bind to. - def listen(network, addr) - assert_open - listener = FFI::MemoryPointer.new(:int) - Error.check self, Libtailscale::TsnetListen(@t, network, addr, listener) - Listener.new self, listener.read_int + def request(req, body = nil, &block) + req["Host"] = @address + req["Authorization"] = "Basic #{@basic}" + req["Sec-Tailscale"] = "localapi" + @http.request(req, body, &block) end + end - # Start a listener on a loopback address, and returns the address - # and credentials for using it as LocalAPI or a proxy. - def loopback - assert_open - addrbuf = FFI::MemoryPointer.new(:char, 1024) - proxycredbuf = FFI::MemoryPointer.new(:char, 33) - localcredbuf = FFI::MemoryPointer.new(:char, 33) - Error.check self, Libtailscale::TsnetLoopback(@t, addrbuf, addrbuf.size, proxycredbuf, localcredbuf) - [addrbuf.read_string, proxycredbuf.read_string, localcredbuf.read_string] - end + # LocalAPI provides a convenient interface for interacting with a LocalAPI given a + # LocalAPIClient to make requests with. + class LocalAPI - # Start the local API and return a LocalAPIClient for interacting with it. - def local_api_client - addr, _, cred = loopback - LocalAPIClient.new(addr, cred) + def initialize(client) + @client = client end - # Start the local API and return a LocalAPI for interacting with it. - def local_api - LocalAPI.new(local_api_client) + # status returns the status of the local tailscale node. + def status + @client.get("/localapi/v0/status") do |r| + return JSON.parse(r.body) + end end - # Get the last detailed error message from the tailscale server. This method - # is typically not needed by user code, as the library will raise an - # +Error+ with the error message. - def errmsg - buf = FFI::MemoryPointer.new(:char, 1024) - r = Libtailscale::TsnetErrmsg(@t, buf, buf.size) - if r != 0 - return "tailscale internal error: failed to get error message" - end - buf.read_string + end + + # Create a new tailscale server. + # + # The server is not started, and no network traffic will occur until start + # is called or network operations are used (such as dial or listen). + def initialize + @t = Libtailscale::TsnetNewServer() + raise Error.new("tailscale error: failed to initialize", @t) if @t < 0 + end + + # Start the tailscale server asynchronously. + def start + Error.check(self, Libtailscale::TsnetStart(@t)) + end + + # Bring the tailscale server up and wait for it to be usable. This method + # blocks until the node is fully authorized. + def up + Error.check(self, Libtailscale::TsnetUp(@t)) + end + + # Close the tailscale server. + def close + Error.check(self, Libtailscale::TsnetClose(@t)) + @t = -1 + end + + # Set the directory to store tailscale state in. + def set_dir(dir) + assert_open + Error.check(self, Libtailscale::TsnetSetDir(@t, dir)) + end + + # Set the hostname to use for the tailscale node. + def set_hostname(hostname) + assert_open + Error.check(self, Libtailscale::TsnetSetHostname(@t, hostname)) + end + + # Set the auth key to use for the tailscale node. + def set_auth_key(auth_key) + assert_open + Error.check(self, Libtailscale::TsnetSetAuthKey(@t, auth_key)) + end + + # Set the control URL the node will connect to. + def set_control_url(control_url) + assert_open + Error.check(self, Libtailscale::TsnetSetControlURL(@t, control_url)) + end + + # Set whether the node is ephemeral or not. + def set_ephemeral(ephemeral) + assert_open + Error.check(self, Libtailscale::TsnetSetEphemeral(@t, ephemeral ? 1 : 0)) + end + + # Set the file descriptor to use for logging. The file descriptor must be + # open for writing. e.g. use `IO.sysopen("/dev/null", "w")` to disable + # logging. + def set_log_fd(log_fd) + assert_open + Error.check(self, Libtailscale::TsnetSetLogFD(@t, log_fd)) + 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. + def dial(network, addr) + assert_open + conn = FFI::MemoryPointer.new(:int) + Error.check(self, Libtailscale::TsnetDial(@t, network, addr, conn)) + IO::new(conn.read_int) + end + + # Listen on a network address. +network+ is one of "tcp" or "udp". +addr+ is + # the local address to bind to. + def listen(network, addr) + assert_open + listener = FFI::MemoryPointer.new(:int) + Error.check(self, Libtailscale::TsnetListen(@t, network, addr, listener)) + Listener.new(self, listener.read_int) + end + + # Start a listener on a loopback address, and returns the address + # and credentials for using it as LocalAPI or a proxy. + def loopback + assert_open + addrbuf = FFI::MemoryPointer.new(:char, 1024) + proxycredbuf = FFI::MemoryPointer.new(:char, 33) + localcredbuf = FFI::MemoryPointer.new(:char, 33) + Error.check(self, Libtailscale::TsnetLoopback(@t, addrbuf, addrbuf.size, proxycredbuf, localcredbuf)) + [addrbuf.read_string, proxycredbuf.read_string, localcredbuf.read_string] + end + + # Start the local API and return a LocalAPIClient for interacting with it. + def local_api_client + addr, _, cred = loopback + LocalAPIClient.new(addr, cred) + end + + # Start the local API and return a LocalAPI for interacting with it. + def local_api + LocalAPI.new(local_api_client) + end + + # Get the last detailed error message from the tailscale server. This method + # is typically not needed by user code, as the library will raise an + # +Error+ with the error message. + def errmsg + buf = FFI::MemoryPointer.new(:char, 1024) + r = Libtailscale::TsnetErrmsg(@t, buf, buf.size) + if r != 0 + return "tailscale internal error: failed to get error message" end - # Check if the tailscale server is open. - def assert_open - raise ClosedError if @t <= 0 - end + buf.read_string + end + + # Check if the tailscale server is open. + def assert_open + raise ClosedError if @t <= 0 + end end diff --git a/ruby/tailscale.gemspec b/ruby/tailscale.gemspec index 5223967..f3b48df 100644 --- a/ruby/tailscale.gemspec +++ b/ruby/tailscale.gemspec @@ -34,4 +34,5 @@ Gem::Specification.new do |spec| spec.extensions = ["ext/libtailscale/extconf.rb"] spec.add_dependency "ffi", "~> 1.15.5" + spec.add_dependency "base64", "~> 0.3" end diff --git a/ruby/test/tailscale/test_tailscale.rb b/ruby/test/tailscale/test_tailscale.rb index 8e1b8a0..4109b72 100644 --- a/ruby/test/tailscale/test_tailscale.rb +++ b/ruby/test/tailscale/test_tailscale.rb @@ -1,38 +1,51 @@ # Copyright (c) Tailscale Inc & AUTHORS # SPDX-License-Identifier: BSD-3-Clause # frozen_string_literal: true -require 'test_helper' +require "test_helper" +require "fileutils" +require "tmpdir" class TestTailscale < Minitest::Test + def setup + super + @tmpdir = Dir.mktmpdir + end - def test_that_it_has_a_version_number - refute_nil ::Tailscale::VERSION - end - - def test_listen_sorta_works - # TODO: make a more useful test when we can make a client to connect with. - ts = newts - ts.start - s = ts.listen "tcp", ":1999" - s.close - ts.close - end + def teardown + super + FileUtils.remove_entry_secure(@tmpdir) + end - def test_dial_sorta_works - # TODO: make a more useful test when we can make a server to connect to. - ts = newts - ts.start - c = ts.dial "udp", "100.100.100.100:53", "" - c.close - ts.close - end + def test_that_it_has_a_version_number + refute_nil(::Tailscale::VERSION) + end - def newts - t = Tailscale::new - unless ENV['VERBOSE'] - logfd = IO.sysopen("/dev/null", "w+") - t.set_log_fd logfd - end - t + def test_listen_sorta_works + ts = newts + ts.up + s = ts.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") + c.close + ts.close + end + + def newts + t = Tailscale::new + unless ENV["VERBOSE"] + logfd = IO.sysopen("/dev/null", "w+") + t.set_log_fd(logfd) end -end \ No newline at end of file + + t.set_ephemeral(1) + t.set_dir(@tmpdir) + t.set_control_url($testcontrol_url) + t + end +end diff --git a/ruby/test/test_helper.rb b/ruby/test/test_helper.rb index 3c00081..c18fed4 100644 --- a/ruby/test/test_helper.rb +++ b/ruby/test/test_helper.rb @@ -3,4 +3,49 @@ # frozen_string_literal: true require "tailscale" -require "minitest/autorun" + +# start a single global testcontrol instance which is far from a best practice, +# but this avoids some issues with signal propagation between ruby, threads, and +# the go runtime. +# +# grumpy. https://github.com/golang/go/issues/40467 +unless system(*"go install tailscale.com/cmd/testcontrol") + raise "failed to go install testcontrol" +end +gobin = `go env GOBIN`.strip +if gobin == "" + gobin = `go env GOPATH`.strip + "/bin" +end + +path = gobin + "/testcontrol" +if !File.executable? path + raise "#{path} is not an executable" +end +pid = Process.spawn(path) +at_exit do + begin + Process.kill(0, pid) + rescue Errno::ESRCH + raise "testcontrol exited prematurely" + end + Process.kill(:TERM, pid) + Process.wait(pid) +end +$testcontrol_url = "http://127.0.0.1:9911" +require "net/http" +attempts = 0 +until (Net::HTTP::get(URI.parse($testcontrol_url + "/key")) rescue nil) + sleep 0.001 + attempts+=1 + begin + Process.kill(0, pid) + rescue Errno::ESRCH + raise "testcontrol exited prematurely" + end + if attempts == 10000 + raise "timed out waiting for testcontrol http to start" + end +end + +require "minitest" +Minitest.autorun diff --git a/tstestcontrol/Makefile b/tstestcontrol/Makefile index d0acb98..7336c4a 100644 --- a/tstestcontrol/Makefile +++ b/tstestcontrol/Makefile @@ -1,10 +1,12 @@ # Copyright (c) Tailscale Inc & AUTHORS # SPDX-License-Identifier: BSD-3-Clause -all: - go build -buildmode=c-archive -o libtstestcontrol.a +.PHONY: all clean -clean: - rm libtstestcontrol.a - rm libtstestcontrol.h +all: libtstestcontrol.a + +libtstestcontrol.a: + go build -buildmode=c-archive -o $@ +clean: + rm -f libtstestcontrol.a