• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

gameap / gameap / 30137012301

24 Jul 2026 11:40PM UTC coverage: 84.416% (-0.3%) from 84.738%
30137012301

Pull #39

github

et-nik
plugins rcon and query
Pull Request #39: Plugins rcon and query

552 of 896 new or added lines in 18 files covered. (61.61%)

7 existing lines in 3 files now uncovered.

49400 of 58520 relevant lines covered (84.42%)

34214.27 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

71.78
/pkg/plugin/protocol.go
1
package plugin
2

3
import (
4
        "context"
5
        "net"
6
        "net/netip"
7
        "strconv"
8
        "strings"
9
        "time"
10

11
        "github.com/gameap/gameap/pkg/netutil"
12
        "github.com/gameap/gameap/pkg/plugin/sdk/protocol"
13
        "github.com/gameap/gameap/pkg/quercon/query"
14
        "github.com/gameap/gameap/pkg/quercon/rcon"
15
        "github.com/gameap/gameap/pkg/quercon/rcon/players"
16
        "github.com/pkg/errors"
17
)
18

19
const defaultProtocolTimeout = 10 * time.Second
20

21
var (
22
        // ErrDialBlocked is returned when a game-server target fails the dial policy.
23
        ErrDialBlocked = errors.New("plugin net: target address is blocked")
24
        // ErrHostNotResolved is returned when a game-server hostname does not resolve.
25
        ErrHostNotResolved = errors.New("plugin net: hostname did not resolve")
26
)
27

28
// netResolver lets tests stub DNS.
29
type netResolver interface {
30
        LookupNetIP(ctx context.Context, network, host string) ([]netip.Addr, error)
31
}
32

33
// NetDialPolicy governs which game-server addresses the host will dial on behalf
34
// of a plugin-implemented protocol.
35
type NetDialPolicy struct {
36
        BlockPrivateIPs bool
37
        AllowedHosts    []string
38
        MaxTimeout      time.Duration
39
}
40

41
// ProtocolRunner executes plugin-implemented RCON/Query protocols. It owns the
42
// only dial: it opens and guards the connection to the game server, registers it
43
// in the shared ConnRegistry, invokes the plugin's protocol RPCs (which perform
44
// I/O over the gameap-net library using the handle), and tears the connection
45
// down. A plugin can therefore only ever reach the server the host dialed for it.
46
type ProtocolRunner struct {
47
        manager      *Manager
48
        registry     *ConnRegistry
49
        policy       NetDialPolicy
50
        resolver     netResolver
51
        dialer       *net.Dialer
52
        allowedHosts map[string]struct{}
53
}
54

55
// NewProtocolRunner builds a runner. registry must be the same instance given to
56
// the gameap-net host-library factory.
57
func NewProtocolRunner(manager *Manager, registry *ConnRegistry, policy NetDialPolicy) *ProtocolRunner {
6✔
58
        if policy.MaxTimeout <= 0 {
8✔
59
                policy.MaxTimeout = defaultProtocolTimeout
2✔
60
        }
2✔
61

62
        allowed := make(map[string]struct{}, len(policy.AllowedHosts))
6✔
63
        for _, h := range policy.AllowedHosts {
6✔
NEW
64
                if h = strings.ToLower(strings.TrimSpace(h)); h != "" {
×
NEW
65
                        allowed[h] = struct{}{}
×
NEW
66
                }
×
67
        }
68

69
        return &ProtocolRunner{
6✔
70
                manager:      manager,
6✔
71
                registry:     registry,
6✔
72
                policy:       policy,
6✔
73
                resolver:     net.DefaultResolver,
6✔
74
                dialer:       &net.Dialer{Timeout: policy.MaxTimeout},
6✔
75
                allowedHosts: allowed,
6✔
76
        }
6✔
77
}
78

79
// RconClient returns an rcon.Client backed by the given plugin protocol.
80
func (r *ProtocolRunner) RconClient(pluginID, protocolID string, cfg rcon.Config) (rcon.Client, error) {
2✔
81
        return &pluginRconClient{
2✔
82
                runner:     r,
2✔
83
                pluginID:   pluginID,
2✔
84
                protocolID: protocolID,
2✔
85
                cfg:        cfg,
2✔
86
        }, nil
2✔
87
}
2✔
88

89
// Query performs a server query via the given plugin protocol.
90
func (r *ProtocolRunner) Query(
91
        ctx context.Context,
92
        pluginID, protocolID, host string,
93
        port int,
94
) (*query.Result, error) {
1✔
95
        address := net.JoinHostPort(host, strconv.Itoa(port))
1✔
96

1✔
97
        handle, release, err := r.openHandle(ctx, "udp", address, pluginID)
1✔
98
        if err != nil {
1✔
NEW
99
                return nil, err
×
NEW
100
        }
×
101
        defer release()
1✔
102

1✔
103
        plugin, ok := r.manager.GetPlugin(pluginID)
1✔
104
        if !ok || plugin.Protocol == nil {
1✔
NEW
105
                return nil, errors.Wrapf(ErrPluginNotFound, "plugin: %s", pluginID)
×
NEW
106
        }
×
107

108
        callCtx, cancel := r.callContext(ctx)
1✔
109
        defer cancel()
1✔
110

1✔
111
        resp, err := plugin.Protocol.QueryServer(callCtx, &protocol.QueryServerRequest{
1✔
112
                ProtocolId: protocolID,
1✔
113
                ConnHandle: handle,
1✔
114
                Address:    address,
1✔
115
        })
1✔
116
        if err != nil {
1✔
NEW
117
                return nil, err
×
NEW
118
        }
×
119

120
        if resp.Error != nil {
1✔
NEW
121
                return nil, errors.New(*resp.Error)
×
NEW
122
        }
×
123

124
        return queryResultFromProto(resp.Result), nil
1✔
125
}
126

127
// ParsePlayers parses a raw players list via the plugin's ParsePlayers RPC.
128
func (r *ProtocolRunner) ParsePlayers(
129
        ctx context.Context,
130
        pluginID, protocolID, raw string,
131
) ([]players.Player, error) {
1✔
132
        plugin, ok := r.manager.GetPlugin(pluginID)
1✔
133
        if !ok || plugin.Protocol == nil {
1✔
NEW
134
                return nil, errors.Wrapf(ErrPluginNotFound, "plugin: %s", pluginID)
×
NEW
135
        }
×
136

137
        callCtx, cancel := r.callContext(ctx)
1✔
138
        defer cancel()
1✔
139

1✔
140
        resp, err := plugin.Protocol.ParsePlayers(callCtx, &protocol.ParsePlayersRequest{
1✔
141
                ProtocolId: protocolID,
1✔
142
                Raw:        raw,
1✔
143
        })
1✔
144
        if err != nil {
1✔
NEW
145
                return nil, err
×
NEW
146
        }
×
147

148
        if resp.Error != nil {
1✔
NEW
149
                return nil, errors.New(*resp.Error)
×
NEW
150
        }
×
151

152
        out := make([]players.Player, 0, len(resp.Players))
1✔
153
        for _, p := range resp.Players {
2✔
154
                out = append(out, players.Player{
1✔
155
                        ID:     p.Id,
1✔
156
                        Name:   p.Name,
1✔
157
                        Ping:   p.Ping,
1✔
158
                        Score:  p.Score,
1✔
159
                        Addr:   p.Addr,
1✔
160
                        UniqID: p.Uniqid,
1✔
161
                })
1✔
162
        }
1✔
163

164
        return out, nil
1✔
165
}
166

167
// openHandle dials the guarded address, registers the connection for the plugin,
168
// and returns the handle plus a release func that closes and unregisters it.
169
func (r *ProtocolRunner) openHandle(
170
        ctx context.Context,
171
        network, address, pluginID string,
172
) (uint64, func(), error) {
3✔
173
        conn, err := r.dial(ctx, network, address)
3✔
174
        if err != nil {
3✔
NEW
175
                return 0, nil, err
×
NEW
176
        }
×
177

178
        handle, err := r.registry.Register(conn, pluginID, time.Now().Add(r.policy.MaxTimeout))
3✔
179
        if err != nil {
3✔
NEW
180
                _ = conn.Close()
×
NEW
181

×
NEW
182
                return 0, nil, err
×
NEW
183
        }
×
184

185
        return handle, func() { r.registry.Discard(handle) }, nil
6✔
186
}
187

188
func (r *ProtocolRunner) callContext(ctx context.Context) (context.Context, context.CancelFunc) {
6✔
189
        return context.WithTimeout(ctx, r.policy.MaxTimeout)
6✔
190
}
6✔
191

192
func (r *ProtocolRunner) dial(ctx context.Context, network, address string) (net.Conn, error) {
3✔
193
        ip, port, err := r.resolveAndCheck(ctx, address)
3✔
194
        if err != nil {
3✔
NEW
195
                return nil, err
×
NEW
196
        }
×
197

198
        return r.dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
3✔
199
}
200

201
func (r *ProtocolRunner) resolveAndCheck(ctx context.Context, address string) (netip.Addr, string, error) {
3✔
202
        host, port, err := net.SplitHostPort(address)
3✔
203
        if err != nil {
3✔
NEW
204
                return netip.Addr{}, "", errors.Wrap(err, "invalid address")
×
NEW
205
        }
×
206

207
        allowBypass := r.hostAllowed(host)
3✔
208

3✔
209
        if ip, ipErr := netip.ParseAddr(host); ipErr == nil {
6✔
210
                if err := r.checkIP(ip, allowBypass); err != nil {
3✔
NEW
211
                        return netip.Addr{}, "", err
×
NEW
212
                }
×
213

214
                return ip, port, nil
3✔
215
        }
216

NEW
217
        ips, err := r.resolver.LookupNetIP(ctx, "ip", host)
×
NEW
218
        if err != nil {
×
NEW
219
                return netip.Addr{}, "", errors.Wrap(ErrHostNotResolved, err.Error())
×
NEW
220
        }
×
221

NEW
222
        if len(ips) == 0 {
×
NEW
223
                return netip.Addr{}, "", errors.Wrap(ErrHostNotResolved, host)
×
NEW
224
        }
×
225

NEW
226
        for _, ip := range ips {
×
NEW
227
                if err := r.checkIP(ip, allowBypass); err != nil {
×
NEW
228
                        return netip.Addr{}, "", err
×
NEW
229
                }
×
230
        }
231

NEW
232
        return ips[0], port, nil
×
233
}
234

235
func (r *ProtocolRunner) checkIP(ip netip.Addr, allowBypass bool) error {
10✔
236
        if netutil.IsCloudMetadataIP(ip) {
12✔
237
                return errors.Wrapf(ErrDialBlocked, "ip=%s reason=%s", ip, netutil.BlockReasonCloudMetadata)
2✔
238
        }
2✔
239

240
        if !r.policy.BlockPrivateIPs || allowBypass {
14✔
241
                return nil
6✔
242
        }
6✔
243

244
        if reason := netutil.BlockReason(ip); reason != "" {
3✔
245
                return errors.Wrapf(ErrDialBlocked, "ip=%s reason=%s", ip, reason)
1✔
246
        }
1✔
247

248
        return nil
1✔
249
}
250

251
func (r *ProtocolRunner) hostAllowed(host string) bool {
3✔
252
        if len(r.allowedHosts) == 0 {
6✔
253
                return false
3✔
254
        }
3✔
255

NEW
256
        _, ok := r.allowedHosts[strings.ToLower(host)]
×
NEW
257

×
NEW
258
        return ok
×
259
}
260

261
func queryResultFromProto(qr *protocol.QueryResult) *query.Result {
1✔
262
        if qr == nil {
1✔
NEW
263
                return &query.Result{QueryTime: time.Now()}
×
NEW
264
        }
×
265

266
        resultPlayers := make([]query.ResultPlayer, 0, len(qr.Players))
1✔
267
        for _, p := range qr.Players {
2✔
268
                resultPlayers = append(resultPlayers, query.ResultPlayer{Name: p.Name, Score: int(p.Score)})
1✔
269
        }
1✔
270

271
        return &query.Result{
1✔
272
                QueryTime:     time.Now(),
1✔
273
                Online:        qr.Online,
1✔
274
                Name:          qr.Name,
1✔
275
                Map:           qr.Map,
1✔
276
                PlayersNum:    int(qr.PlayersNum),
1✔
277
                MaxPlayersNum: int(qr.MaxPlayersNum),
1✔
278
                Players:       resultPlayers,
1✔
279
        }
1✔
280
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc