diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..01072ca --- /dev/null +++ b/.editorconfig @@ -0,0 +1,8 @@ +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.v] +indent_style = tab diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..f4011a7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,7 @@ +* text=auto eol=lf +*.bat eol=crlf + +**/*.v linguist-language=V +**/*.vv linguist-language=V +**/*.vsh linguist-language=V +**/v.mod linguist-language=V diff --git a/.github/workflows/jekyll-gh-pages.yml b/.github/workflows/jekyll-gh-pages.yml new file mode 100644 index 0000000..0e553c7 --- /dev/null +++ b/.github/workflows/jekyll-gh-pages.yml @@ -0,0 +1,58 @@ +# Sample workflow for building and deploying a Jekyll site to GitHub Pages +name: Deploy Jekyll with GitHub Pages dependencies preinstalled + +on: + # Runs on pushes targeting the default branch + push: + branches: ["master"] + + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages +permissions: + contents: read + pages: write + id-token: write + +# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. +# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + # Build job + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Pages + uses: actions/configure-pages@v4 + - name: Setup V + uses: nocturlab/setup-vlang-action@v1 + with: + id: v + v-version: master + - name: Bulid docs + run: v gendocs.vsh + - name: Build with Jekyll + uses: actions/jekyll-build-pages@v1 + with: + source: ./docs + destination: ./_site + - name: Upload artifact + uses: actions/upload-pages-artifact@v2 + + # Deployment job + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + needs: build + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v3 \ No newline at end of file diff --git a/.github/workflows/lib_tests.yml b/.github/workflows/lib_tests.yml new file mode 100644 index 0000000..37a0691 --- /dev/null +++ b/.github/workflows/lib_tests.yml @@ -0,0 +1,20 @@ +name: Library tests + +on: [push, pull_request] + +jobs: + library_tests: + name: Run library tests + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Setup V + uses: nocturlab/setup-vlang-action@v1 + with: + id: v + v-version: master + + - name: Test library + run: v test src/ \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..51442b3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +# Binaries for programs and plugins +main + +*.exe +*.exe~ +*.so +*.dylib +*.dll + +# Ignore binary output folders +bin/ + +# Ignore common editor/system specific metadata +.DS_Store +.idea/ +.vscode/ +*.iml + +# ENV +.env + +# vweb and database +*.db +/*.js + +/test.v +/test*.v +*.def +/*.c +/*.db +/test*.py +/test.py +/test*.vsh \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..996e35c --- /dev/null +++ b/README.md @@ -0,0 +1,27 @@ +# rcon.v + +RCON client in V + +[Documentation](https://darphome.github.io/rcon.v/rcon.html) + +## Example + +```v +import rcon + +fn main() { + // `rcon.connect_tcp` parameter is string in address:port format + mut client := rcon.connect_tcp('127.0.0.1:25575')! + defer { + client.close() or {} + } + // perform authentication. it may return 'invalid password' error + client.login('mypassw0rd')! + + // execute command. returns string as result + client.execute('say Hello world')! + + players := client.execute('list')! + println('Players on server: ${players}') +} +``` \ No newline at end of file diff --git a/examples/interactive_client.v b/examples/interactive_client.v new file mode 100644 index 0000000..567a42d --- /dev/null +++ b/examples/interactive_client.v @@ -0,0 +1,224 @@ +module main + +import cli +import os +import regex +import rcon +import term + +struct Colorizer { +mut: + pattern regex.RE +} + +fn (mut c Colorizer) colorize(x string) string { + return c.pattern.replace_by_fn(x, fn (_ regex.RE, s string, start int, end int) string { + c := s[end - 1] + f := match c { + `0` { + fn () string { + return term.format_esc('30') + } + } + `1` { + fn () string { + return term.format_esc('34') + } + } + `2` { + fn () string { + return term.format_esc('32') + } + } + `3` { + fn () string { + return term.format_esc('36') + } + } + `4` { + fn () string { + return term.format_esc('31') + } + } + `5` { + fn () string { + return term.format_esc('35') + } + } + `6` { + fn () string { + return term.format_esc('93') + } + } + `7` { + fn () string { + return term.format_esc('90') + } + } + `8` { + fn () string { + return term.format_esc('90') + } + } + `9` { + fn () string { + return term.format_esc('94') + } + } + `a` { + fn () string { + return term.format_esc('392') + } + } + `b` { + fn () string { + return term.format_esc('96') + } + } + `c` { + fn () string { + return term.format_esc('91') + } + } + `d` { + fn () string { + return term.format_esc('95') + } + } + `e` { + fn () string { + return term.format_esc('33') + } + } + `f` { + fn () string { + return term.format_esc('37') + } + } + `r` { + fn () string { + return term.format_esc('0') + } + } + `k` { + fn () string { + return '' + } + } + `l` { + fn () string { + return term.format_esc('1') + } + } + `o` { + fn () string { + return term.format_esc('3') + } + } + `n` { + fn () string { + return term.format_esc('4') + } + } + `m` { + fn () string { + return term.format_esc('9') + } + } + else { + panic('invalid character (${c})') + } + } + return f() + }) +} + +fn new_colorizer() Colorizer { + mut pattern := regex.new() + pattern.compile_opt('(ยง[0-9a-frlonmk])') or { panic(err) } + return Colorizer{ + pattern: pattern + } +} + +@[params] +struct ApplicationParams { + address string + port int + password string + raw bool +} + +fn run_application(params ApplicationParams) ! { + mut client := rcon.connect_tcp('${params.address}:${params.port}')! + defer { + client.close() or {} + } + mut colorizer := new_colorizer() + client.login(params.password)! + for { + print('$ ') + command := os.input('').trim_space() + if command in ['Q', 'q'] { + break + } + if command == '' { + continue + } + r := client.execute(command)! + if params.raw { + println(r) + } else { + println(colorizer.colorize(r)) + } + } +} + +fn main() { + mut app := cli.Command{ + name: 'rcon-client' + description: 'interactive RCON client' + execute: fn (cmd cli.Command) ! { + address := cmd.flags.filter(|c| c.name == 'address')[0].get_string()! + port := cmd.flags.filter(|c| c.name == 'port')[0].get_int()! + password := cmd.flags.filter(|c| c.name == 'password')[0].get_string()! + raw := cmd.flags.filter(|c| c.name == 'raw')[0].get_bool() or { false } + run_application(address: address, port: port, password: password, raw: raw)! + } + flags: [ + cli.Flag{ + flag: .string + name: 'address' + abbrev: 'a' + description: 'the address to use when connecting' + required: false + default_value: ['127.0.0.1'] + }, + cli.Flag{ + flag: .int + name: 'port' + abbrev: 'P' + description: 'the port to use when connecting' + required: false + default_value: ['25575'] + }, + cli.Flag{ + flag: .string + name: 'password' + abbrev: 'p' + description: 'the password to use when authenticating' + required: true + }, + cli.Flag{ + flag: .bool + name: 'raw' + abbrev: 'r' + description: 'whether to print raw responses, if not present, they will automatically colorized' + required: false + default_value: ['false'] + }, + ] + } + app.setup() + app.parse(os.args) +} diff --git a/gendocs.vsh b/gendocs.vsh new file mode 100644 index 0000000..1c68d05 --- /dev/null +++ b/gendocs.vsh @@ -0,0 +1,9 @@ +import os + +println('Generating docs...') +if os.system('v doc -color -f html -o docs/ -readme src/') != 0 { + eprintln('HTML docs generation failed') + exit(1) +} else { + println('HTML docs successfully generated!') +} diff --git a/src/client.v b/src/client.v new file mode 100644 index 0000000..bd1aee0 --- /dev/null +++ b/src/client.v @@ -0,0 +1,192 @@ +module rcon + +import encoding.binary +import io +import net +import strings + +fn recv_text(mut r io.Reader) !TextPacket { + mut buf := []u8{len: 4} + mut n := r.read(mut buf)! + if n != 4 { + return error('expected 4 bytes as packet begin, got ${n}') + } + n = binary.little_endian_u32(buf) + if n > 4105 { + return error('too large packet (>4105): ${n}') + } + mut payload := []u8{len: n} + m := r.read(mut payload)! + if m != n { + return error('expected to receive ${n} bytes, got ${m}') + } + $if trace_rcon ? { + dump(payload) + } + tp := TextPacket.unpack(payload)! + $if trace_rcon ? { + dump(tp) + } + return tp +} + +pub struct TcpClient { +mut: + conn &net.TcpConn + i u32 +} + +fn (mut c TcpClient) send(packet Packet) ! { + c.conn.write(packet.pack())! +} + +fn (mut c TcpClient) recv_text() !TextPacket { + return recv_text(mut c.conn)! +} + +// close closes server connection +pub fn (mut c TcpClient) close() ! { + if c.conn == unsafe { nil } { + return + } + c.conn.close()! +} + +// login authenticates using password +pub fn (mut c TcpClient) login(password string) !&TcpClient { + c.send(TextPacket{ + request_id: c.i + typ: u32(PacketType.login) + data: password + })! + c.i++ + p := c.recv_text()! + if p.request_id == -1 { + return error('invalid password') + } + // successfully logged in + return unsafe { c } +} + +// execute sends an command and returns result +pub fn (mut c TcpClient) execute(command string) !string { + c.send(TextPacket{ + request_id: c.i + typ: u32(PacketType.command) + data: command + })! + mut sb := strings.new_builder(32) + for { + p := c.recv_text()! + sb.write_string(p.data) + if p.data.len < 4096 { + break + } + } + return sb.str() +} + +// command is like execute but returns current instance and accepts an lambda to allow fluent-style usage +pub fn (mut c TcpClient) command(command string, callback fn (result string) !) !&TcpClient { + callback(c.execute(command)!)! + return unsafe { c } +} + +// connect_tcp creates TcpClient and connects to server (as TCP connection) +pub fn connect_tcp(address string) !TcpClient { + return TcpClient{ + conn: net.dial_tcp(address)! + i: 86 + } +} + +// // tcp connects to server using address and call provided lambda with TcpClient +// pub fn tcp(address string, callback fn (mut client TcpClient) !) !TcpClient { +// mut client := connect_tcp(address)! +// callback(mut client)! +// return client +// } + +pub struct UdpClient { +mut: + conn &net.UdpConn + i u32 +} + +fn (mut c UdpClient) send(packet Packet) ! { + c.conn.write(packet.pack())! +} + +fn (mut c UdpClient) recv_text() !TextPacket { + mut buf := []u8{len: 4} + mut n, _ := c.conn.read(mut buf)! + if n != 4 { + return error('expected 4 bytes as packet begin, got ${n}') + } + n = binary.little_endian_u32(buf) + if n > 4105 { + return error('too large packet (>4105): ${n}') + } + mut payload := []u8{len: n} + m, _ := c.conn.read(mut payload)! + if m != n { + return error('expected to receive ${n} bytes, got ${m}') + } + return TextPacket.unpack(payload)! +} + +// close closes server connection +pub fn (mut c UdpClient) close() ! { + if c.conn == unsafe { nil } { + return + } + c.conn.close()! +} + +// login authenticates using password +pub fn (mut c UdpClient) login(password string) !&UdpClient { + c.send(TextPacket{ + request_id: c.i + typ: u32(PacketType.login) + data: password + })! + c.i++ + p := c.recv_text()! + if p.request_id == -1 { + return error('invalid password') + } + // successfully logged in + return unsafe { c } +} + +// execute sends an command and returns result +pub fn (mut c UdpClient) execute(command string) !string { + c.send(TextPacket{ + request_id: c.i + typ: u32(PacketType.command) + data: command + })! + mut sb := strings.new_builder(32) + for { + p := c.recv_text()! + sb.write_string(p.data) + if p.data.len < 4096 { + break + } + } + return sb.str() +} + +// command is like execute but returns current instance and accepts an lambda to allow fluent-style usage +pub fn (mut c UdpClient) command(command string, callback fn (result string) !) !UdpClient { + callback(c.execute(command)!)! + return c +} + +// connect_udp creates UdpClient and connects to server (as UDP connection) +pub fn connect_udp(address string) !UdpClient { + return UdpClient{ + conn: net.dial_udp(address)! + i: 118 + } +} diff --git a/src/packet.v b/src/packet.v new file mode 100644 index 0000000..cb9930d --- /dev/null +++ b/src/packet.v @@ -0,0 +1,120 @@ +module rcon + +import arrays +import encoding.binary + +pub enum PacketType as u32 { + login = 3 + command = 2 + response = 0 +} + +pub struct PacketHeader { +pub: + typ u32 + request_id u32 +} + +pub struct BinaryPacket { + PacketHeader +pub: + data []u8 +} + +pub struct TextPacket { + PacketHeader +pub: + data string +} + +pub type Packet = BinaryPacket | TextPacket + +fn pack_packet(typ u32, request_id u32, data []u8) []u8 { + len := 9 + data.len + mut r := []u8{len: 4 + len} + binary.little_endian_put_u32_at(mut r, u32(len), 0) + binary.little_endian_put_u32_at(mut r, u32(request_id), 4) + binary.little_endian_put_u32_at(mut r, u32(typ), 8) + arrays.copy(mut r[12..], data) + r[len + 3] = 0 + return r +} + +// pack returns packed byte array which can be sent to RCON socket +pub fn (bp BinaryPacket) pack() []u8 { + return pack_packet(bp.typ, bp.request_id, bp.data) +} + +// pack returns packed byte array which can be sent to RCON socket +pub fn (tp TextPacket) pack() []u8 { + data := tp.data.bytes() + idx := data.index(0) + mut r := if idx != -1 { data[..idx] } else { data } + r << 0 + return pack_packet(tp.typ, tp.request_id, r) +} + +@[inline] +fn (bp BinaryPacket) to_text() TextPacket { + return TextPacket{ + typ: bp.typ + request_id: bp.request_id + data: bp.data.bytestr() + } +} + +// promote converts [BinaryPacket](#BinaryPacket) to [TextPacket](#TextPacket), strips `data` before zero, and if `data` does not contains zero, then it returns error +pub fn (bp BinaryPacket) promote() !TextPacket { + i := bp.data.index(0) + if i == -1 { + return error('data is not zero-terminated') + } + return unsafe { + BinaryPacket{ + typ: bp.typ + request_id: bp.request_id + data: bp.data[..i] + }.to_text() + } +} + +// BinaryPacket.unpack converts payload to [BinaryPacket](#BinaryPacket) +pub fn BinaryPacket.unpack(payload []u8) !BinaryPacket { + if payload.len < 9 { + return error('too small packet (<9): ${payload.len}') + } + request_id := binary.little_endian_u32_at(payload, 0) + typ := binary.little_endian_u32_at(payload, 4) + data := payload[8..payload.len - 1] + return BinaryPacket{ + request_id: request_id + typ: typ + data: data + } +} + +// TextPacket.unpack converts payload to [TextPacket](#TextPacket) +pub fn TextPacket.unpack(payload []u8) !TextPacket { + if payload.len < 10 { + return error('too small packet (<10): ${payload.len}') + } + request_id := binary.little_endian_u32_at(payload, 0) + typ := binary.little_endian_u32_at(payload, 4) + data := payload[8..payload.len - 1] + i := data.index(0) + if i == -1 { + return error('data is not zero-terminated') + } + return TextPacket{ + request_id: request_id + typ: typ + data: data[..i].bytestr() + } +} + +fn (p Packet) pack() []u8 { + return match p { + BinaryPacket { p.pack() } + TextPacket { p.pack() } + } +} diff --git a/src/packet_test.v b/src/packet_test.v new file mode 100644 index 0000000..0ea3168 --- /dev/null +++ b/src/packet_test.v @@ -0,0 +1,104 @@ +module rcon + +fn test_build_packet() { + assert pack_packet(u32(PacketType.login), 1234, 'mypassw0rd'.bytes()) == [u8(19), 0, 0, 0, + 210, 4, 0, 0, 3, 0, 0, 0, 109, 121, 112, 97, 115, 115, 119, 48, 114, 100, 0] + assert pack_packet(u32(PacketType.login), 1234, 'mypassw0rd\0'.bytes()) == [ + u8(20), + 0, + 0, + 0, + 210, + 4, + 0, + 0, + 3, + 0, + 0, + 0, + 109, + 121, + 112, + 97, + 115, + 115, + 119, + 48, + 114, + 100, + 0, + 0, + ] + assert pack_packet(0, 0, []) == [u8(9), 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] +} + +fn test_binary_packet() { + assert BinaryPacket{ + typ: u32(PacketType.login) + request_id: 1234 + data: 'mypassw0rd'.bytes() + }.pack() == [u8(19), 0, 0, 0, 210, 4, 0, 0, 3, 0, 0, 0, 109, 121, 112, 97, 115, 115, 119, 48, + 114, 100, 0] + assert BinaryPacket{ + typ: u32(PacketType.login) + request_id: 1234 + data: 'mypassw0rd\0'.bytes() + }.pack() == [u8(20), 0, 0, 0, 210, 4, 0, 0, 3, 0, 0, 0, 109, 121, 112, 97, 115, 115, 119, 48, + 114, 100, 0, 0] + assert BinaryPacket{}.pack() == [u8(9), 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] +} + +fn test_text_packet() { + assert TextPacket{ + typ: u32(PacketType.login) + request_id: 1234 + data: 'mypassw0rd' + }.pack() == [u8(20), 0, 0, 0, 210, 4, 0, 0, 3, 0, 0, 0, 109, 121, 112, 97, 115, 115, 119, 48, + 114, 100, 0, 0] + assert TextPacket{ + typ: u32(PacketType.login) + request_id: 1234 + data: 'mypassw0rd\0' + }.pack() == [u8(20), 0, 0, 0, 210, 4, 0, 0, 3, 0, 0, 0, 109, 121, 112, 97, 115, 115, 119, 48, + 114, 100, 0, 0] + assert TextPacket{}.pack() == [u8(10), 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] +} + +fn test_unpacking_part1() ! { + assert BinaryPacket.unpack([u8(1), 0, 0, 0, 0, 0, 0, 0, 0])! == BinaryPacket{ + request_id: 1 + typ: 0 + data: [] + } + assert BinaryPacket.unpack([u8(2), 0, 0, 0, 0, 0, 0, 0, 0])! == BinaryPacket{ + request_id: 2 + typ: 0 + data: [] + } + assert BinaryPacket.unpack([u8(255), 255, 255, 255, 0, 0, 0, 0, 0])! == BinaryPacket{ + request_id: 4294967295 + typ: 0 + data: [] + } + assert BinaryPacket.unpack([u8(0), 0, 0, 0, 0, 0, 0, 0, 0])! == BinaryPacket{ + request_id: 0 + typ: 0 + data: [] + } +} + +fn test_unpacking_part2() { + BinaryPacket.unpack([]) or { + assert err.msg() == 'too small packet (<9): 0' + return + } + assert false +} + +fn test_unpacking_part3() { + TextPacket.unpack([]) or { + assert err.msg() == 'too small packet (<10): 0' + return + } + assert false +} diff --git a/v.mod b/v.mod new file mode 100644 index 0000000..6446e83 --- /dev/null +++ b/v.mod @@ -0,0 +1,7 @@ +Module { + name: 'rcon.v' + description: 'RCON client in V' + version: '0.1.0' + license: 'MIT' + dependencies: [] +}