From 775ee41f1dbc7c55286e4cb5d43225312d8ba03e Mon Sep 17 00:00:00 2001 From: James Tucker Date: Wed, 1 Mar 2023 17:38:45 -0800 Subject: [PATCH] ruby: add a gem that wraps the library --- .github/workflows/ruby.yml | 36 +++++ .gitignore | 7 + ruby/Gemfile | 12 ++ ruby/Gemfile.lock | 26 ++++ ruby/README.md | 47 +++++++ ruby/Rakefile | 53 +++++++ ruby/bin/echo_server | 19 +++ ruby/ext/libtailscale/extconf.rb | 12 ++ ruby/lib/tailscale.rb | 195 ++++++++++++++++++++++++++ ruby/lib/tailscale/version.rb | 8 ++ ruby/tailscale.gemspec | 39 ++++++ ruby/test/tailscale/test_tailscale.rb | 38 +++++ ruby/test/test_helper.rb | 6 + 13 files changed, 498 insertions(+) create mode 100644 .github/workflows/ruby.yml create mode 100644 ruby/Gemfile create mode 100644 ruby/Gemfile.lock create mode 100644 ruby/README.md create mode 100644 ruby/Rakefile create mode 100755 ruby/bin/echo_server create mode 100644 ruby/ext/libtailscale/extconf.rb create mode 100644 ruby/lib/tailscale.rb create mode 100644 ruby/lib/tailscale/version.rb create mode 100644 ruby/tailscale.gemspec create mode 100644 ruby/test/tailscale/test_tailscale.rb create mode 100644 ruby/test/test_helper.rb diff --git a/.github/workflows/ruby.yml b/.github/workflows/ruby.yml new file mode 100644 index 0000000..c9532a1 --- /dev/null +++ b/.github/workflows/ruby.yml @@ -0,0 +1,36 @@ +name: Ruby + +on: + push: + branches: + - main + + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + name: Ruby ${{ matrix.ruby }} + strategy: + matrix: + ruby: + - '3.2.0' + + steps: + - uses: actions/checkout@v3 + + - name: Set up Go + uses: actions/setup-go@v3 + with: + go-version: "1.20" + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + bundler-cache: true + working-directory: ruby + - name: Run build & test + run: | + cd ruby + bundle exec rake build test \ No newline at end of file diff --git a/.gitignore b/.gitignore index f71a0a2..234dd96 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,9 @@ +libtailscale.so libtailscale.a libtailscale.h +/ruby/tmp/ +/ruby/doc/ +/ruby/ext/libtailscale/*.go +/ruby/ext/libtailscale/go.mod +/ruby/ext/libtailscale/go.sum +/ruby/LICENSE \ No newline at end of file diff --git a/ruby/Gemfile b/ruby/Gemfile new file mode 100644 index 0000000..5cedead --- /dev/null +++ b/ruby/Gemfile @@ -0,0 +1,12 @@ +# Copyright (c) Tailscale Inc & AUTHORS +# SPDX-License-Identifier: BSD-3-Clause +# frozen_string_literal: true + +source "https://rubygems.org" + +# Specify your gem's dependencies in tailscale-ruby.gemspec +gemspec + +gem "rake", "~> 13.0" +gem "rake-compiler", "~> 1.2.1" +gem "minitest", "~> 5.0" \ No newline at end of file diff --git a/ruby/Gemfile.lock b/ruby/Gemfile.lock new file mode 100644 index 0000000..76d7c54 --- /dev/null +++ b/ruby/Gemfile.lock @@ -0,0 +1,26 @@ +PATH + remote: . + specs: + tailscale (0.1.0) + ffi (~> 1.15.5) + +GEM + remote: https://rubygems.org/ + specs: + ffi (1.15.5) + minitest (5.16.3) + rake (13.0.6) + rake-compiler (1.2.1) + rake + +PLATFORMS + x86_64-linux-gnu + +DEPENDENCIES + minitest (~> 5.0) + rake (~> 13.0) + rake-compiler (~> 1.2.1) + tailscale! + +BUNDLED WITH + 2.4.1 diff --git a/ruby/README.md b/ruby/README.md new file mode 100644 index 0000000..9c2ba30 --- /dev/null +++ b/ruby/README.md @@ -0,0 +1,47 @@ +# tailscale + +The tailscale gem provides an embedded network interface that can be used to +listen for and dial connections to other [Tailscale](https://tailscale.com) +nodes. + +## Installation + +Source installations will require a recent Go compiler in $PATH in order to build. + +Install the gem and add to the application's Gemfile by executing: + + $ bundle add tailscale + +If bundler is not being used to manage dependencies, install the gem by executing: + + $ gem install ailscale + +## Usage + +The node will need to be authorized in order to function. Set an auth key with +`set_auth_key`, or watch the libtailscale log stream and respond to the printed +authorization URL. You can also set the `$TS_AUTHKEY` environment variable. + +```ruby +require 'tailscale' +t = Tailscale.new +t.up +l = t.listen "tcp", ":1999" +while c = l.accept + c.write "hello world" + c.close +end +``` + +## Development + +After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. + +To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org). + +## Contributing + +Pull requests are welcome on GitHub at https://github.com/tailscale/libtailscale + +Please file any issues about this code or the hosted service on +[the issue tracker](https://github.com/tailscale/tailscale/issues). diff --git a/ruby/Rakefile b/ruby/Rakefile new file mode 100644 index 0000000..27540e7 --- /dev/null +++ b/ruby/Rakefile @@ -0,0 +1,53 @@ +# Copyright (c) Tailscale Inc & AUTHORS +# SPDX-License-Identifier: BSD-3-Clause +# frozen_string_literal: true + +require "bundler/gem_tasks" +require "rake/testtask" +require "rake/extensiontask" + +go_sources = %w[tailscale.go go.mod go.sum] +go_sources.map do |f| + to = "ext/libtailscale/#{f}" + from = "../#{f}" + file to => from do + cp from, to + end + task copy: to + task :clobber do + rm_f to + end +end +file "LICENSE" => "../LICENSE" do + cp "../LICENSE", "LICENSE" +end +task :clobber do + rm_f "LICENSE" +end +task copy: "LICENSE" +task build: :copy + +# XXX: Rake::ExtensionTask seems to ignore prerequisites. +# Rake::ExtensionTask.new "libtailscale" do |ext| +# ext.source_pattern = "*.{go,mod,sum}" +# end +# task "compile:libtailscale" => :copy +libname = "lib/libtailscale.#{RbConfig::CONFIG['DLEXT']}" +task libname => :copy do |t| + sh "go build -buildmode=c-shared -o #{t.name} github.com/tailscale/libtailscale" +end +desc "Build the C extension using local sources" +task compile: libname +task :clobber do + rm_f libname + rm_f libname.sub(/\.#{RbConfig::CONFIG['DLEXT']}$/, ".h") +end + +Rake::TestTask.new(:test) do |t| + t.libs << "test" + t.libs << "lib" + t.test_files = FileList["test/**/test_*.rb"] +end +task test: :compile + +task default: :test \ No newline at end of file diff --git a/ruby/bin/echo_server b/ruby/bin/echo_server new file mode 100755 index 0000000..6d553fc --- /dev/null +++ b/ruby/bin/echo_server @@ -0,0 +1,19 @@ +#!/usr/bin/env ruby +# Copyright (c) Tailscale Inc & AUTHORS +# SPDX-License-Identifier: BSD-3-Clause +# frozen_string_literal: true + +require "bundler/setup" +require "tailscale" + +t = Tailscale::new +t.start + +s = t.listen "tcp", ":1997" +while c = s.accept + while got = c.readpartial(2046) + print got + c.write got + end + c.close +end diff --git a/ruby/ext/libtailscale/extconf.rb b/ruby/ext/libtailscale/extconf.rb new file mode 100644 index 0000000..e0279fa --- /dev/null +++ b/ruby/ext/libtailscale/extconf.rb @@ -0,0 +1,12 @@ +# Copyright (c) Tailscale Inc & AUTHORS +# SPDX-License-Identifier: BSD-3-Clause +# frozen_string_literal: true +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 "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 diff --git a/ruby/lib/tailscale.rb b/ruby/lib/tailscale.rb new file mode 100644 index 0000000..78eb32d --- /dev/null +++ b/ruby/lib/tailscale.rb @@ -0,0 +1,195 @@ +# Copyright (c) Tailscale Inc & AUTHORS +# SPDX-License-Identifier: BSD-3-Clause +# frozen_string_literal: true + +require 'tailscale/version' +require 'ffi' +require 'rbconfig' + +# 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 :TsnetListenerClose, [:int], :int + attach_function :TsnetAccept, [:int, :pointer], :int, blocking: true + attach_function :TsnetErrmsg, [:int, :pointer, :size_t], :int + 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 + 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::TsnetListenerClose(@listener) + end + 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, 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 + 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 + + # 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 + + # Check if the tailscale server is open. + def assert_open + raise ClosedError if @t <= 0 + end +end diff --git a/ruby/lib/tailscale/version.rb b/ruby/lib/tailscale/version.rb new file mode 100644 index 0000000..bb56321 --- /dev/null +++ b/ruby/lib/tailscale/version.rb @@ -0,0 +1,8 @@ +# Copyright (c) Tailscale Inc & AUTHORS +# SPDX-License-Identifier: BSD-3-Clause + +# frozen_string_literal: true + +class Tailscale + VERSION = "0.1.0" +end diff --git a/ruby/tailscale.gemspec b/ruby/tailscale.gemspec new file mode 100644 index 0000000..70567cb --- /dev/null +++ b/ruby/tailscale.gemspec @@ -0,0 +1,39 @@ +# Copyright (c) Tailscale Inc & AUTHORS +# SPDX-License-Identifier: BSD-3-Clause +# frozen_string_literal: true + +require_relative "lib/tailscale/version" + +Gem::Specification.new do |spec| + spec.name = "tailscale" + spec.version = Tailscale::VERSION + spec.authors = ["Tailscale Inc & AUTHORS"] + spec.email = ["support@tailscale.com"] + + spec.summary = "Tailscale in-process connections for Ruby" + spec.description = "Tailscale in-process connections for Ruby" + spec.homepage = "https://www.tailscale.com" + spec.license = "BSD-3-Clause" + spec.required_ruby_version = ">= 2.6.0" + + spec.metadata["allowed_push_host"] = "https://rubygems.org" + + spec.metadata["homepage_uri"] = spec.homepage + spec.metadata["source_code_uri"] = "https://github.com/tailscale/libtailscale/tree/main/ruby" + spec.metadata["bug_tracker_uri"] = "https://github.com/tailscale/tailscale/issues" + + spec.files = Dir.chdir(__dir__) do + `git ls-files -z`.split("\x0").reject do |f| + (f == __FILE__) || f.match(%r{\A(?:(?:bin|test|spec|features)/|\.(?:git|circleci)|appveyor)}) + end + end + spec.files += ["LICENSE"] + spec.files += Dir["ext/libtailscale/*.{mod,sum,go}"] + + spec.bindir = "exe" + spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } + spec.require_paths = ["lib"] + spec.extensions = ["ext/libtailscale/extconf.rb"] + + spec.add_dependency "ffi", "~> 1.15.5" +end diff --git a/ruby/test/tailscale/test_tailscale.rb b/ruby/test/tailscale/test_tailscale.rb new file mode 100644 index 0000000..8e1b8a0 --- /dev/null +++ b/ruby/test/tailscale/test_tailscale.rb @@ -0,0 +1,38 @@ +# Copyright (c) Tailscale Inc & AUTHORS +# SPDX-License-Identifier: BSD-3-Clause +# frozen_string_literal: true +require 'test_helper' + +class TestTailscale < Minitest::Test + + 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 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 newts + t = Tailscale::new + unless ENV['VERBOSE'] + logfd = IO.sysopen("/dev/null", "w+") + t.set_log_fd logfd + end + t + end +end \ No newline at end of file diff --git a/ruby/test/test_helper.rb b/ruby/test/test_helper.rb new file mode 100644 index 0000000..3c00081 --- /dev/null +++ b/ruby/test/test_helper.rb @@ -0,0 +1,6 @@ +# Copyright (c) Tailscale Inc & AUTHORS +# SPDX-License-Identifier: BSD-3-Clause +# frozen_string_literal: true + +require "tailscale" +require "minitest/autorun"