ruby: add a gem that wraps the library

This commit is contained in:
James Tucker
2023-03-01 17:38:45 -08:00
parent 0f378c01bd
commit 775ee41f1d
13 changed files with 498 additions and 0 deletions

36
.github/workflows/ruby.yml vendored Normal file
View File

@@ -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

7
.gitignore vendored
View File

@@ -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

12
ruby/Gemfile Normal file
View File

@@ -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"

26
ruby/Gemfile.lock Normal file
View File

@@ -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

47
ruby/README.md Normal file
View File

@@ -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).

53
ruby/Rakefile Normal file
View File

@@ -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

19
ruby/bin/echo_server Executable file
View File

@@ -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

View File

@@ -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

195
ruby/lib/tailscale.rb Normal file
View File

@@ -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

View File

@@ -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

39
ruby/tailscale.gemspec Normal file
View File

@@ -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

View File

@@ -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

6
ruby/test/test_helper.rb Normal file
View File

@@ -0,0 +1,6 @@
# Copyright (c) Tailscale Inc & AUTHORS
# SPDX-License-Identifier: BSD-3-Clause
# frozen_string_literal: true
require "tailscale"
require "minitest/autorun"