From 19e69366177799f7ccff75b6f4a1850ff0ca8d09 Mon Sep 17 00:00:00 2001 From: Eric Wong Date: Wed, 2 Dec 2015 11:11:06 +0000 Subject: dtas-mpd-emu: beginning of the MPD emulation proxy Get basic commands and output buffering (in-memory) working. The original mpd buffers large responses into memory, too, so there probably isn't much concern for DoS-ing with slow clients like a typical web server has. --- bin/dtas-mpd-emu | 11 +++++ lib/dtas/mpd_emu_client.rb | 108 +++++++++++++++++++++++++++++++++++++++++++++ lib/dtas/server_loop.rb | 55 +++++++++++++++++++++++ test/test_mpd_emu.rb | 73 ++++++++++++++++++++++++++++++ 4 files changed, 247 insertions(+) create mode 100755 bin/dtas-mpd-emu create mode 100644 lib/dtas/mpd_emu_client.rb create mode 100644 lib/dtas/server_loop.rb create mode 100644 test/test_mpd_emu.rb diff --git a/bin/dtas-mpd-emu b/bin/dtas-mpd-emu new file mode 100755 index 0000000..d95d545 --- /dev/null +++ b/bin/dtas-mpd-emu @@ -0,0 +1,11 @@ +#!/usr/bin/env ruby +# Copyright (C) 2015 all contributors +# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt) + +# MPD protocol emulation proxy in front of dtas-player +require 'socket' +require 'dtas/mpd_emu_client' +require 'dtas/server_loop' +l = TCPServer.new('127.0.0.1', 2100) +svc = DTAS::ServerLoop.new([l], DTAS::MpdEmuClient) +svc.run_forever diff --git a/lib/dtas/mpd_emu_client.rb b/lib/dtas/mpd_emu_client.rb new file mode 100644 index 0000000..01258d1 --- /dev/null +++ b/lib/dtas/mpd_emu_client.rb @@ -0,0 +1,108 @@ +# Copyright (C) 2015 all contributors +# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt) + +# emulate the MPD protocol +require_relative '../dtas' +require 'shellwords' + +class DTAS::MpdEmuClient # :nodoc: + attr_reader :to_io + + # protocol version we support + SERVER = 'MPD 0.13.0' + MAX_RBUF = 8192 + ACK = { + ERROR_NOT_LIST: 1, + ERROR_ARG: 2, + ERROR_PASSWORD: 3, + ERROR_PERMISSION: 4, + ERROR_UNKNOWN: 5, + ERROR_NO_EXIST: 50, + ERROR_PLAYLIST_MAX: 51, + ERROR_SYSTEM: 52, + ERROR_PLAYLIST_LOAD: 53, + ERROR_UPDATE_ALREADY: 54, + ERROR_PLAYER_SYNC: 55, + ERROR_EXIST: 56, + } + + def initialize(io) + @to_io = io + @rbuf = ''.b + @wbuf = nil + @cmd_listnum = 0 + out("OK #{SERVER}\n") + end + + def dispatch_loop(rbuf) + while rbuf.sub!(/\A([^\r\n]+)\r?\n/n, '') + rv = dispatch(Shellwords.split($1)) + next if rv == true + return rv + end + rbuf.size >= MAX_RBUF ? nil : :wait_readable + end + + def dispatch(argv) + cmd = argv.shift or return err(:ERROR_UNKNOWN) + cmd = "mpdcmd_#{cmd}" + respond_to?(cmd) ? __send__(cmd, argv) : err(:ERROR_UNKNOWN) + end + + def err(sym) + "[#{ACK[sym]}@#@cmd_listnum {}" + end + + def mpdcmd_ping(argv) + out("OK\n") + end + + # returns true on complete, :wait_writable when blocked, or nil on error + def out(buf) + buf = buf.b + if @wbuf + @wbuf << buf + :wait_writable + else + tot = buf.size + case rv = @to_io.write_nonblock(buf, exception: false) + when Integer + return true if rv == tot + buf.slice!(0, rv).clear + tot -= rv + when :wait_writable + @wbuf = buf + return rv + end while tot > 0 + true # all done + end + rescue + nil # signal EOF up the chain + end + + def dispatch_rd(buf) + case rv = @to_io.read_nonblock(MAX_RBUF, buf, exception: false) + when String then dispatch_loop(@rbuf << rv) + when :wait_readable, nil then rv + end + rescue + nil + end + + def dispatch_wr + tot = @wbuf.size + case rv = @to_io.write_nonblock(@wbuf, exception: false) + when Integer + @wbuf.slice!(0, rv).clear + tot -= rv + return :wait_readable if tot == 0 + when :wait_writable then return rv + end while true + rescue + nil + end + + def hash + @to_io.fileno + end +end diff --git a/lib/dtas/server_loop.rb b/lib/dtas/server_loop.rb new file mode 100644 index 0000000..f0936aa --- /dev/null +++ b/lib/dtas/server_loop.rb @@ -0,0 +1,55 @@ +# Copyright (C) 2015 all contributors +# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt) + +require_relative '../dtas' + +# Used for mpd emulation currently, but dtas-player might use this eventually +class DTAS::ServerLoop + def initialize(listeners, client_class) + @rd = {} + @wr = {} + @rbuf = '' + @client_class = client_class + listeners.each { |l| @rd[l] = true } + end + + def run_forever + begin + r = IO.select(@rd.keys, @wr.keys) or next + r[0].each { |rd| do_read(rd) } + r[1].each { |wr| do_write(wr) } + end while true + end + + def do_write(wr) + case wr.dispatch_wr + when :wait_readable + @wr.delete(wr) + @rd[wr] = true + when nil + @wr.delete(wr) + wr.to_io.close + # when :wait_writable # do nothing + end + end + + def do_read(rd) + case rd + when @client_class + case rd.dispatch_rd(@rbuf) + when :wait_writable + @rd.delete(rd) + @wr[rd] = true + when nil + @rd.delete(rd) + rd.to_io.close + # when :wait_readable : do nothing + end + else + case io = rd.accept_nonblock(exception: false) + when :wait_readable then break + when IO then @rd[@client_class.new(io)] = true + end while true + end + end +end diff --git a/test/test_mpd_emu.rb b/test/test_mpd_emu.rb new file mode 100644 index 0000000..9c73ae9 --- /dev/null +++ b/test/test_mpd_emu.rb @@ -0,0 +1,73 @@ +# Copyright (C) 2013-2015 all contributors +# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt) +require_relative 'helper' +require 'socket' +require 'dtas/mpd_emu_client' +require 'dtas/server_loop' + +class TestMlib < Testcase + class Quit + attr_reader :to_io + + def initialize + @to_io, @w = IO.pipe + end + + def quit! + @w.close + end + + def accept_nonblock(*args) + Thread.exit + end + end + + def setup + @host = '127.0.0.1' + @l = TCPServer.new(@host, 0) + @port = @l.addr[1] + @quit = Quit.new + @klass = Class.new(DTAS::MpdEmuClient) + @svc = DTAS::ServerLoop.new([@l, @quit], @klass) + @th = Thread.new { @svc.run_forever } + @c = TCPSocket.new(@host, @port) + assert_match %r{\AOK.*MPD.*\n\z}, @c.gets + end + + def teardown + @quit.quit! + @th.join + @quit.to_io.close + @l.close + @c.close unless @c.closed? + end + + def test_ping + @c.write "ping\n" + assert_equal "OK\n", @c.gets + assert_nil IO.select([@c], nil, nil, 0) + end + + # to ensure output buffering works: + module BigOutput + WAKE = IO.pipe + NR = 20000 + OMG = ('OMG! ' * 99) << "OMG!\n" + def mpdcmd_big_output(*_) + rv = true + NR.times { rv = out(OMG) } + # tell the tester we're done writing to our buffer: + WAKE[1].write(rv == :wait_writable ? '.' : 'F') + rv + end + end + + def test_big_output + @klass.__send__(:include, BigOutput) + @c.write "big_output\n" + assert_equal '.', BigOutput::WAKE[0].read(1), 'server blocked on write' + BigOutput::NR.times do + assert_equal BigOutput::OMG, @c.gets + end + end +end -- cgit v1.2.3-24-ge0c7