File Coverage

blib/lib/Net/RCON/Minecraft.pm
Criterion Covered Total %
statement 90 90 100.0
branch 40 40 100.0
condition 6 6 100.0
subroutine 16 16 100.0
pod 4 4 100.0
total 156 156 100.0


line stmt bran cond sub pod time code
1             # Minecraft implementation of the RCON protocol.
2              
3             package Net::RCON::Minecraft;
4              
5 11     11   879292 use 5.010;
  11         82  
6              
7 11     11   5264 use Mouse;
  11         315520  
  11         41  
8 11     11   4302 use Mouse::Util::TypeConstraints;
  11         25  
  11         64  
9 11     11   6584 use Net::RCON::Minecraft::Response;
  11         992  
  11         470  
10 11     11   3193 use IO::Socket::IP;
  11         176877  
  11         79  
11 11     11   8324 use IO::Select;
  11         8163  
  11         467  
12 11     11   69 use Carp;
  11         21  
  11         577  
13              
14 11     11   79 no warnings 'uninitialized';
  11         29  
  11         795  
15              
16             our $VERSION = '0.03';
17              
18             use constant {
19             # Packet types
20 11         14567 AUTH => 3, # Minecraft RCON login packet type
21             AUTH_RESPONSE => 2, # Server auth response
22             AUTH_FAIL => -1, # Auth failure (password invalid)
23             COMMAND => 2, # Command packet type
24             RESPONSE_VALUE => 0, # Server response
25 11     11   79 };
  11         19  
26              
27             class_type 'IP' => { class => 'IO::Socket::IP' };
28              
29             has host => ( is => 'ro', default => 'localhost', isa => 'Str' );
30             has port => ( is => 'ro', default => 25575, isa => 'Int' );
31             has password => ( is => 'ro', default => '', isa => 'Str' );
32             has timeout => ( is => 'rw', default => 30, isa => 'Num' );
33             has _select => ( is => 'ro', default => sub { IO::Select->new } );
34             has _socket => ( is => 'rw', default => undef, isa => 'Maybe[IP]' );
35             has _next_id => ( is => 'rw', default => 1, isa => 'Int' );
36              
37             after _next_id => sub { $_[0]->{_next_id} = ($_[0]->{_next_id} + 1) % 2**31 };
38              
39             sub connect {
40 33     33 1 4150 my ($s) = @_;
41              
42 33 100       76 return 1 if $s->connected;
43              
44 32 100       156 croak 'Password required' unless length $s->password;
45              
46 30 100       135 $s->_socket(IO::Socket::IP->new(
47             PeerAddr => $s->host,
48             PeerPort => $s->port,
49             Proto => 'tcp',
50 1         12 )) or croak "Connection to @{[$s->host]}:@{[$s->port]} failed: $!";
  1         33  
51              
52 29         474 $s->_socket->autoflush(1);
53 29         170 $s->_select->remove($s->_select->handles);
54 29         299 $s->_select->add($s->_socket);
55              
56 29         181 my $id = $s->_next_id(1);
57 29         197 $s->_send_encode(AUTH, $id, $s->password);
58 29         77 my ($size,$res_id,$type,$payload) = $s->_read_decode;
59              
60             # Force a reconnect if we're about to error out
61 28 100 100     165 $s->disconnect unless $type == AUTH_RESPONSE and $id == $res_id;
62              
63 28 100       86 croak "RCON authentication failed" if $res_id == AUTH_FAIL;
64 27 100       96 croak "Expected AUTH_RESPONSE(2), got $type" if $type != AUTH_RESPONSE;
65 24 100       64 croak "Expected ID $id, got $res_id" if $id != $res_id;
66 23 100       59 croak "Non-blank payload <$payload>" if length $payload;
67              
68 22         44 return 1;
69             }
70              
71             sub disconnect {
72 13     13 1 341 my $s = shift;
73 13 100 100     67 $s->_socket->shutdown(2) if $s->_socket and $s->_socket->connected;
74 13         120 1;
75             }
76              
77 37 100   37 1 199 sub connected { $_[0]->_socket and $_[0]->_socket->connected }
78              
79             sub command {
80 16     16 1 534 my ($s, $command, $mode) = @_;
81              
82 16 100       64 croak 'Command required' unless length $command;
83 15         44 $s->connect;
84              
85 15         44 my $id = $s->_next_id;
86 15         204 my $nonce = 16 + int rand(2**15 - 16); # Extra insurance
87 15         49 $s->_send_encode(COMMAND, $id, $command);
88 15         46 $s->_send_encode($nonce, $id, 'nonce');
89              
90 15         29 my $raw = '';
91 15         27 while (1) {
92 31         62 my ($size,$res_id,$type,$payload) = $s->_read_decode;
93 31 100       81 if ($id != $res_id) {
94 1         4 $s->disconnect;
95 1         14 croak sprintf(
96             'Desync. Expected %d (0x%04x), got %d (0x%04x). Disconnected.',
97             $id, $id, $res_id, $res_id
98             );
99             }
100 30 100       68 croak "size:$size id:$id got type $type, not RESPONSE_VALUE(0)"
101             if $type != RESPONSE_VALUE;
102 29 100       113 last if $payload eq sprintf 'Unknown request %x', $nonce;
103 16         39 $raw .= $payload;
104             }
105              
106 13         35 $raw =~ s!\r\n!\n!g; # \R would be nice, but requires 5.010
107              
108 13         292 Net::RCON::Minecraft::Response->new(raw => $raw, id => $id);
109             }
110              
111             sub DESTROY { $_[0]->disconnect }
112              
113             #
114             # Private methods -- Not for external use
115             #
116              
117             # Grab a complete response from the Minecraft server and decode it,
118             # returning $id, $type, and $payload
119             sub _read_decode {
120 65     65   2593 my ($s) = @_;
121              
122 65         134 local $_ = $s->_read_with_timeout(4);
123              
124 62         150 my $size = unpack 'V';
125              
126 62 100       145 croak 'Packet too short. Size field = ' . $size . ' (10 is smallest)'
127             if $size < 10;
128              
129 61         112 $_ = $s->_read_with_timeout($size);
130              
131 60 100       315 croak 'Server response missing terminator' unless s/\0\0$//;
132              
133 59         294 $size, unpack 'V!V(A*)';
134             }
135              
136             # Read with timeout. Implemented with select. Guarantees either $len bytes are
137             # read, or we croak() trying. Returns the bytes read.
138             sub _read_with_timeout {
139 126     126   205 my ($s, $len) = @_;
140              
141 126         213 my $ret = '';
142              
143 126         248 while ($len > length $ret) {
144 127 100       426 if ($s->_select->can_read($s->timeout)) {
145 124         611 my $buf = '';
146 124         342 my $read = $s->_socket->sysread($buf, $len - length $ret);
147 124 100       1521 croak "Socket read error: $!" if not defined $read;
148 123         328 $ret .= $buf;
149             } else {
150 3         21 $s->disconnect;
151 3         41 croak "Server timeout. Got " .length($ret)."/".$len." bytes";
152             }
153             }
154              
155 122         250 $ret;
156             }
157              
158             # Form and send a packet of the specified $type, $req_id and $payload
159             sub _send_encode {
160 61     61   226 my ($s, $type, $id, $payload) = @_;
161 61 100       166 $payload = "" unless defined $payload;
162 61         220 my $data = pack('V!V' => $id, $type) . $payload . "\0\0";
163 61         105 eval { $s->_socket->send(pack(V => length $data) . $data) };
  61         266  
164 61 100       5122 croak "Socket write failed: $@" if $@;
165              
166             }
167              
168             __PACKAGE__->meta->make_immutable();