├── .ruby-gemset ├── Procfile ├── gateway.gif ├── screenshot.png ├── public └── steve.png ├── c8d31fe7-3ffd-43ba-ae3e-c3a5913c10e9.dat ├── config.ru ├── Gemfile ├── change_dns.sh ├── README.md ├── LICENSE ├── Gemfile.lock ├── wither.rb └── .minecraft.svg /.ruby-gemset: -------------------------------------------------------------------------------- 1 | wither 2 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: bundle exec thin -R config.ru start -p $PORT -e $RACK_ENV 2 | -------------------------------------------------------------------------------- /gateway.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pickaxe-club/wither/HEAD/gateway.gif -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pickaxe-club/wither/HEAD/screenshot.png -------------------------------------------------------------------------------- /public/steve.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pickaxe-club/wither/HEAD/public/steve.png -------------------------------------------------------------------------------- /c8d31fe7-3ffd-43ba-ae3e-c3a5913c10e9.dat: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pickaxe-club/wither/HEAD/c8d31fe7-3ffd-43ba-ae3e-c3a5913c10e9.dat -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'bundler' 3 | 4 | Bundler.require 5 | 6 | $stdout.sync = true 7 | $stderr.sync = true 8 | 9 | require './wither' 10 | run Wither 11 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | ruby '2.7.1' 2 | source 'https://rubygems.org' 3 | 4 | gem 'sinatra', '~> 2.0.8' 5 | gem 'thin', '>= 1.7.2' 6 | gem 'rest-client' 7 | gem 'minecraft-query', require: 'rcon/rcon' 8 | gem 'dnsimple' 9 | gem 'activesupport', '~> 6.0.3' 10 | gem 'droplet_kit', '~> 3.8.0' 11 | gem 'net-ssh' 12 | gem 'platform-api' 13 | -------------------------------------------------------------------------------- /change_dns.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # http://www.kirya.net/articles/running-a-secure-ddns-service-with-bind/ 4 | 5 | host=$1 6 | ipaddr=$2 7 | 8 | echo "Aliasing host $host to ip $ipaddr" 9 | nsupdate -k ./Kpickaxe.+157+50170.private < 1.0, >= 1.0.2) 6 | i18n (>= 0.7, < 2) 7 | minitest (~> 5.1) 8 | tzinfo (~> 1.1) 9 | zeitwerk (~> 2.2, >= 2.2.2) 10 | addressable (2.7.0) 11 | public_suffix (>= 2.0.2, < 5.0) 12 | axiom-types (0.1.1) 13 | descendants_tracker (~> 0.0.4) 14 | ice_nine (~> 0.11.0) 15 | thread_safe (~> 0.3, >= 0.3.1) 16 | coercible (1.0.0) 17 | descendants_tracker (~> 0.0.1) 18 | concurrent-ruby (1.1.6) 19 | daemons (1.3.1) 20 | descendants_tracker (0.0.4) 21 | thread_safe (~> 0.3, >= 0.3.1) 22 | dnsimple (5.0.0) 23 | httparty 24 | domain_name (0.5.20190701) 25 | unf (>= 0.0.5, < 1.0.0) 26 | droplet_kit (3.8.0) 27 | faraday (>= 0.15) 28 | kartograph (~> 0.2.3) 29 | resource_kit (~> 0.1.5) 30 | virtus (~> 1.0.3) 31 | equalizer (0.0.11) 32 | erubis (2.7.0) 33 | eventmachine (1.2.7) 34 | excon (0.78.0) 35 | faraday (1.0.1) 36 | multipart-post (>= 1.2, < 3) 37 | heroics (0.1.1) 38 | erubis (~> 2.0) 39 | excon 40 | moneta 41 | multi_json (>= 1.9.2) 42 | http-accept (1.7.0) 43 | http-cookie (1.0.3) 44 | domain_name (~> 0.5) 45 | httparty (0.18.0) 46 | mime-types (~> 3.0) 47 | multi_xml (>= 0.5.2) 48 | i18n (1.8.3) 49 | concurrent-ruby (~> 1.0) 50 | ice_nine (0.11.2) 51 | kartograph (0.2.7) 52 | mime-types (3.3.1) 53 | mime-types-data (~> 3.2015) 54 | mime-types-data (3.2020.0512) 55 | minecraft-query (1.0.0) 56 | minitest (5.14.1) 57 | moneta (1.0.0) 58 | multi_json (1.15.0) 59 | multi_xml (0.6.0) 60 | multipart-post (2.1.1) 61 | mustermann (1.1.1) 62 | ruby2_keywords (~> 0.0.1) 63 | net-ssh (6.0.2) 64 | netrc (0.11.0) 65 | platform-api (3.2.0) 66 | heroics (~> 0.1.1) 67 | moneta (~> 1.0.0) 68 | rate_throttle_client (~> 0.1.0) 69 | public_suffix (4.0.5) 70 | rack (2.2.3) 71 | rack-protection (2.0.8.1) 72 | rack 73 | rate_throttle_client (0.1.2) 74 | resource_kit (0.1.7) 75 | addressable (>= 2.3.6, < 3.0.0) 76 | rest-client (2.1.0) 77 | http-accept (>= 1.7.0, < 2.0) 78 | http-cookie (>= 1.0.2, < 2.0) 79 | mime-types (>= 1.16, < 4.0) 80 | netrc (~> 0.8) 81 | ruby2_keywords (0.0.2) 82 | sinatra (2.0.8.1) 83 | mustermann (~> 1.0) 84 | rack (~> 2.0) 85 | rack-protection (= 2.0.8.1) 86 | tilt (~> 2.0) 87 | thin (1.7.2) 88 | daemons (~> 1.0, >= 1.0.9) 89 | eventmachine (~> 1.0, >= 1.0.4) 90 | rack (>= 1, < 3) 91 | thread_safe (0.3.6) 92 | tilt (2.0.10) 93 | tzinfo (1.2.7) 94 | thread_safe (~> 0.1) 95 | unf (0.1.4) 96 | unf_ext 97 | unf_ext (0.0.7.7) 98 | virtus (1.0.5) 99 | axiom-types (~> 0.1) 100 | coercible (~> 1.0) 101 | descendants_tracker (~> 0.0, >= 0.0.3) 102 | equalizer (~> 0.0, >= 0.0.9) 103 | zeitwerk (2.3.0) 104 | 105 | PLATFORMS 106 | ruby 107 | 108 | DEPENDENCIES 109 | activesupport (~> 6.0.3) 110 | dnsimple 111 | droplet_kit (~> 3.8.0) 112 | minecraft-query 113 | net-ssh 114 | platform-api 115 | rest-client 116 | sinatra (~> 2.0.8) 117 | thin (>= 1.7.2) 118 | 119 | RUBY VERSION 120 | ruby 2.7.1p83 121 | 122 | BUNDLED WITH 123 | 2.1.4 124 | -------------------------------------------------------------------------------- /wither.rb: -------------------------------------------------------------------------------- 1 | require 'cgi' 2 | require 'open-uri' 3 | require 'active_support/all' 4 | require 'net/http' 5 | 6 | DROPLET_SIZE = 'g-4vcpu-16gb' 7 | 8 | class Say 9 | class << self 10 | def rcon(command) 11 | rcon = RCON::Minecraft.new ENV['RCON_IP'], ENV['RCON_PORT'] || 25575 12 | rcon.auth ENV['RCON_PASSWORD'] 13 | rcon.command(command).strip 14 | end 15 | 16 | def game(user_name, text) 17 | # Replace curly single and double quotes with non-Unicode versions 18 | text.gsub!(/[\u201c\u201d]/, '"') 19 | text.gsub!(/[\u2018\u2019]/, "'") 20 | 21 | data = { text: "<#{user_name.gsub(/\Aslackbot\z/, 'Steve')}> #{CGI.unescapeHTML(text.gsub(/<(\S+)>/, "\\1"))}" } 22 | rcon %|tellraw @a ["",#{data.to_json}]| 23 | end 24 | 25 | def slack(user_name, text) 26 | RestClient.post ENV['SLACK_URL'], { 27 | # username: user_name, text: text.gsub(/\[m\Z/, ""), icon_url: "https://minotar.net/avatar/#{user_name}?date=#{Date.today}" 28 | username: user_name, text: text.gsub(/\[m\Z/, ""), icon_url: "https://minotar.net/helm/#{user_name}?date=#{Date.today}" 29 | }.to_json, content_type: :json, accept: :json 30 | end 31 | end 32 | end 33 | 34 | class Command 35 | def initialize(who, line) 36 | @who = who 37 | @line = line 38 | end 39 | 40 | def run 41 | execute if allowed? 42 | end 43 | 44 | private 45 | def execute 46 | raise NotImplementedError 47 | end 48 | 49 | def allowed? 50 | %w( qrush tyrosinase bensawyer fishtoaster cobyr ravenx99 51 | uncleadam sleeplessbooks ).include? @who 52 | end 53 | 54 | def slack(line) 55 | Say.slack 'MC_wither', line 56 | end 57 | 58 | def set_config_var(key, value) 59 | heroku = PlatformAPI.connect_oauth(ENV['HEROKU_PLATFORM_API_TOKEN']) 60 | heroku.config_var.update('wither', {key => value}) 61 | end 62 | end 63 | 64 | class DnsCommand < Command 65 | FILENAME = 'Kpickaxe.+157+50170' 66 | def execute 67 | puts "executing DNS command: #{@line}" 68 | if @line =~ /^wither dns ([\w-]+) ([\d\.]+)$/ 69 | ensure_keys 70 | if system("sh ./change_dns.sh #{$1}.pickaxe.club #{$2}") 71 | puts "---=== moving pickaxe to #{$1} at #{$2}" 72 | slack "I've moved pickaxe to #{$1}.pickaxe.club, pointing at #{$2}. :pickaxe:" 73 | sleep 2 74 | puts "---=== having slept, setting RCON_IP to #{$2}" 75 | set_config_var('RCON_IP', $2) 76 | puts "---=== [[unreachable code, since set_config_var reboots?]]" 77 | else 78 | slack "Dns update failed." 79 | end 80 | end 81 | end 82 | 83 | private 84 | 85 | # Make sure the keys necessary for the dns update are on the filesystem 86 | def ensure_keys 87 | return if File.exist?(private_file) && File.exist?(key_file) 88 | 89 | File.open(private_file, 'w') { |f| f.write(ENV['DNS_PRIVATE']) } 90 | File.open(key_file, 'w') { |f| f.write(ENV['DNS_KEY']) } 91 | end 92 | 93 | def private_file 94 | './' + FILENAME + '.private' 95 | end 96 | 97 | def key_file 98 | './' + FILENAME + '.key' 99 | end 100 | end 101 | 102 | class SayCommand < Command 103 | def execute 104 | Say.game @who, @line 105 | end 106 | 107 | def allowed? 108 | true 109 | end 110 | end 111 | 112 | class ListCommand < Command 113 | def execute 114 | list = Say.rcon('list') 115 | slack list 116 | Say.game 'MC_wither', list 117 | end 118 | 119 | def allowed? 120 | true 121 | end 122 | end 123 | 124 | class DropletCommand < Command 125 | private 126 | def client 127 | @client ||= DropletKit::Client.new(access_token: ENV['DO_ACCESS_TOKEN']) 128 | end 129 | 130 | def droplet 131 | @droplet ||= client.droplets.all.find { |drop| drop.name == 'pickaxe.club' } 132 | end 133 | end 134 | 135 | class StatusCommand < DropletCommand 136 | def execute 137 | if droplet 138 | public_ip = droplet.public_ip 139 | 140 | Net::SSH.start(public_ip, "minecraft", :password => ENV['DO_SSH_PASSWORD'], :timeout => 10) do |ssh| 141 | output = ssh.exec!("uptime") 142 | slack "Pickaxe.club is online at #{public_ip}. `#{output.strip}`" 143 | end 144 | else 145 | slack "Pickaxe.club is offline!" 146 | end 147 | rescue Errno::ETIMEDOUT 148 | slack "Pickaxe.club is timing out. Maybe offline?" 149 | end 150 | 151 | def allowed? 152 | true 153 | end 154 | end 155 | 156 | class BootCommand < DropletCommand 157 | def execute 158 | if droplet 159 | Say.slack 'MC_wither', 'Pickaxe.club is already running!' 160 | return 161 | end 162 | 163 | unless @line =~ /^[Ww]ither boot (\d{3}[a-z]?)$/ 164 | slack "syntax: wither boot " 165 | return 166 | end 167 | restore_week = $1 168 | 169 | unless restore_file_exists(restore_week) 170 | slack "restore file for week #{restore_week} not found. Check logs." 171 | return 172 | end 173 | 174 | set_config_var 'BOOT_RESTORE_WEEK', restore_week 175 | 176 | droplet = DropletKit::Droplet.new( 177 | name: 'pickaxe.club', 178 | region: 'nyc3', 179 | image: 'ubuntu-16-04-x64', 180 | size: DROPLET_SIZE, 181 | private_networking: true, 182 | user_data: open(ENV['DO_USER_DATA_URL']).read # ROFLMAO 183 | ) 184 | client.droplets.create(droplet) 185 | slack "Pickaxe.club is booting up! (restoring week #{restore_week})" 186 | end 187 | 188 | def restore_file_exists(week) 189 | url = URI(ENV['ARCHIVE_URL'] + "/week#{week}.tar.gz") 190 | http = Net::HTTP.new(url.host, url.port) 191 | http.use_ssl = true 192 | response = http.head(url.path) 193 | puts "HEAD request response: #{response} #{url}" 194 | response.code == "200" 195 | end 196 | end 197 | 198 | class ShutdownCommand < DropletCommand 199 | def execute 200 | if droplet 201 | client.droplets.delete(id: droplet.id) 202 | 203 | slack "Pickaxe.club is shutting down. I hope it was backed up!" 204 | else 205 | slack "Pickaxe.club isn't running." 206 | end 207 | end 208 | end 209 | 210 | class BossCommand < Command 211 | def execute 212 | puts "input to boss command:" 213 | puts @line 214 | slack "you are the boss" 215 | Say.game 'MC_wither', "you are the big boss" 216 | end 217 | end 218 | 219 | 220 | class Wither < Sinatra::Application 221 | COMMANDS = %w(list dns ip boot shutdown status backup generate boss) 222 | 223 | get '/' do 224 | 'Wither!' 225 | end 226 | 227 | get '/restore-week' do 228 | ENV['BOOT_RESTORE_WEEK'] 229 | end 230 | 231 | post '/hook' do 232 | text = params[:text] 233 | user_name = params[:user_name] 234 | 235 | if text == nil || text == '' || user_name == 'slackbot' 236 | return 'nope' 237 | end 238 | 239 | if params[:token] == ENV['SLACK_TOKEN'] 240 | wither, command, * = text.split 241 | 242 | if wither.downcase == "wither" && COMMANDS.include?(command) 243 | puts "recognized wither command: #{command} text: #{text}" 244 | command_class = "#{command}_command".camelize.safe_constantize 245 | command_class.new(user_name, text).run 246 | else 247 | begin 248 | SayCommand.new(user_name, text).run 249 | rescue Exception => e 250 | logger.info "Got error #{e.class}" 251 | logger.info "Port = #{ENV['RCON_PORT'] || 25575}" 252 | logger.info "IP = #{ENV['RCON_IP']}" 253 | logger.info "password = #{ENV['RCON_PASSWORD']}" 254 | raise e 255 | end 256 | end 257 | 258 | status 201 259 | else 260 | status 403 261 | end 262 | 263 | 'ok' 264 | end 265 | 266 | post '/minecraft/hook' do 267 | body = request.body.read 268 | logger.info body 269 | 270 | if body =~ /INFO\]: <(.*)> (.*)/ 271 | Say.slack $1, $2 272 | elsif body =~ %r{Server thread/INFO\]: ([^\d]+)} 273 | line = $1 274 | Say.slack 'MC_wither', line if line !~ /the game/ 275 | end 276 | 277 | 'ok' 278 | end 279 | 280 | post '/cloud/booted/:instance_id' do 281 | logger.info params.inspect 282 | instance_id = params[:instance_id] 283 | Say.slack "wither", "I've finished booting #{instance_id}!" 284 | end 285 | end 286 | -------------------------------------------------------------------------------- /.minecraft.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 20 | 22 | 40 | 46 | 47 | 49 | 50 | 52 | image/svg+xml 53 | 55 | 56 | 57 | 58 | 59 | 63 | 78 | 97 | 115 | 1845 | 1860 | 1861 | 1862 | --------------------------------------------------------------------------------