generated from VLADIMIR/template
up
This commit is contained in:
@@ -52,7 +52,9 @@ func (s *server) GetDevices(ctx context.Context, req *proto.GetDevicesReq) (*pro
|
||||
if err != nil {
|
||||
return nil, deviceErrorStatus(err)
|
||||
}
|
||||
wifi, stale := s.wifiService.Get(ctx)
|
||||
return &proto.GetDevicesRsp{
|
||||
Wifi: mapWiFi(wifi, stale),
|
||||
Devices: mapVenueDevices(devices),
|
||||
ServerTime: timestamppb.New(now),
|
||||
}, nil
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"evening_detective_server/internal/modules/cudy"
|
||||
"evening_detective_server/internal/services/device_presence_service"
|
||||
proto "evening_detective_server/proto"
|
||||
|
||||
@@ -51,3 +52,29 @@ func mapVenueDevices(devices []device_presence_service.Device) []*proto.VenueDev
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func mapWiFi(snapshot cudy.Snapshot, stale bool) *proto.VenueWiFi {
|
||||
result := &proto.VenueWiFi{
|
||||
Enabled: snapshot.Enabled,
|
||||
Stale: stale,
|
||||
HasError: snapshot.HasError,
|
||||
Clients: make([]*proto.WiFiClient, 0, len(snapshot.Clients)),
|
||||
}
|
||||
if !snapshot.CollectedAt.IsZero() {
|
||||
result.CollectedAt = timestamppb.New(snapshot.CollectedAt)
|
||||
}
|
||||
for _, client := range snapshot.Clients {
|
||||
var signal *int32
|
||||
if client.Signal != nil {
|
||||
value := int32(*client.Signal)
|
||||
signal = &value
|
||||
}
|
||||
result.Clients = append(result.Clients, &proto.WiFiClient{
|
||||
IP: client.IP,
|
||||
MAC: client.MAC,
|
||||
Band: client.Band,
|
||||
Signal: signal,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -2,15 +2,21 @@ package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"evening_detective_server/internal/modules/cudy"
|
||||
"evening_detective_server/internal/modules/processor_jwt"
|
||||
"evening_detective_server/internal/services/device_presence_service"
|
||||
"evening_detective_server/internal/services/game_service"
|
||||
"evening_detective_server/internal/services/screen_service"
|
||||
"evening_detective_server/internal/services/team_service"
|
||||
"evening_detective_server/internal/services/wifi_service"
|
||||
proto "evening_detective_server/proto"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -97,3 +103,32 @@ func TestDeviceErrors(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWiFiUsesExistingPresencePermissions(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "state.json")
|
||||
signal := -65
|
||||
snapshot := cudy.Snapshot{Enabled: true, CollectedAt: time.Now().UTC(), Clients: []cudy.Client{{IP: "192.168.10.9", MAC: "AA:BB:CC:DD:EE:01", Band: "5 GHz", Signal: &signal}}}
|
||||
data, err := json.Marshal(snapshot)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := &server{devicePresenceService: device_presence_service.NewDevicePresenceService(true, nil, nil, nil), wifiService: wifi_service.New(path)}
|
||||
for _, role := range []string{"organizer", "developer", "admin"} {
|
||||
ctx := processor_jwt.ContextWithClaims(context.Background(), &processor_jwt.JWTClaims{Roles: []string{role}})
|
||||
result, err := s.GetDevices(ctx, &proto.GetDevicesReq{})
|
||||
if err != nil || !result.Wifi.Enabled || result.Wifi.Stale || len(result.Wifi.Clients) != 1 || result.Wifi.Clients[0].GetSignal() != -65 {
|
||||
t.Fatalf("Wi-Fi for %s: %v, %v", role, result, err)
|
||||
}
|
||||
}
|
||||
if _, err := s.GetDevices(context.Background(), &proto.GetDevicesReq{}); status.Code(err) != codes.PermissionDenied {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.devicePresenceService = device_presence_service.NewDevicePresenceService(false, nil, nil, nil)
|
||||
ctx := processor_jwt.ContextWithClaims(context.Background(), &processor_jwt.JWTClaims{Roles: []string{"developer"}})
|
||||
if _, err := s.GetDevices(ctx, &proto.GetDevicesReq{}); status.Code(err) != codes.FailedPrecondition {
|
||||
t.Fatal("prod exposes Wi-Fi", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"evening_detective_server/internal/services/ticket_service"
|
||||
"evening_detective_server/internal/services/ui_service"
|
||||
"evening_detective_server/internal/services/users_service"
|
||||
"evening_detective_server/internal/services/wifi_service"
|
||||
proto "evening_detective_server/proto"
|
||||
)
|
||||
|
||||
@@ -28,6 +29,7 @@ type server struct {
|
||||
proto.UnsafeEveningDetectiveServerServer
|
||||
|
||||
devicePresenceService *device_presence_service.DevicePresenceService
|
||||
wifiService *wifi_service.Service
|
||||
usersService *users_service.UsersService
|
||||
uiService *ui_service.UIService
|
||||
taskService *task_service.TaskService
|
||||
@@ -73,9 +75,11 @@ func NewServer(
|
||||
gameResultsService *game_results_package_service.GameResultsPackageService,
|
||||
gameResultsApplyService *game_results_package_service.GameResultsApplyService,
|
||||
devicePresenceService *device_presence_service.DevicePresenceService,
|
||||
wifiService *wifi_service.Service,
|
||||
) proto.EveningDetectiveServerServer {
|
||||
return &server{
|
||||
devicePresenceService: devicePresenceService,
|
||||
wifiService: wifiService,
|
||||
gameImportService: gameImportService,
|
||||
gameResultsService: gameResultsService,
|
||||
gameResultsApplyService: gameResultsApplyService,
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
package cudy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
var ErrRead = errors.New("не удалось прочитать Wi-Fi Cudy")
|
||||
|
||||
const maxBody = 2 << 20
|
||||
|
||||
type reader struct {
|
||||
base string
|
||||
password string
|
||||
http *http.Client
|
||||
authenticated bool
|
||||
}
|
||||
|
||||
func NewReader(base, password string) (Reader, error) {
|
||||
u, err := url.Parse(base)
|
||||
if err != nil || u.User != nil || u.RawQuery != "" || u.Fragment != "" || (u.Path != "" && u.Path != "/") || (u.Scheme != "http" && u.Scheme != "https") {
|
||||
return nil, ErrRead
|
||||
}
|
||||
ip, err := netip.ParseAddr(u.Hostname())
|
||||
if err != nil || (!ip.IsPrivate() && !ip.IsLoopback()) || password == "" {
|
||||
return nil, ErrRead
|
||||
}
|
||||
jar, _ := cookiejar.New(nil)
|
||||
client := &http.Client{Timeout: 5 * time.Second, Jar: jar, Transport: &http.Transport{Proxy: nil}, CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) > 5 || req.URL.Host != u.Host || req.URL.Scheme != u.Scheme {
|
||||
return ErrRead
|
||||
}
|
||||
return nil
|
||||
}}
|
||||
return &reader{base: strings.TrimRight(base, "/"), password: password, http: client}, nil
|
||||
}
|
||||
|
||||
func (r *reader) request(ctx context.Context, path string, form url.Values) ([]byte, int, error) {
|
||||
method := http.MethodGet
|
||||
var body io.Reader
|
||||
if form != nil {
|
||||
method = http.MethodPost
|
||||
body = strings.NewReader(form.Encode())
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, r.base+path, body)
|
||||
if err != nil {
|
||||
return nil, 0, ErrRead
|
||||
}
|
||||
if form != nil {
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
}
|
||||
resp, err := r.http.Do(req)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return nil, 0, ctx.Err()
|
||||
}
|
||||
return nil, 0, ErrRead
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, maxBody+1))
|
||||
if err != nil || len(data) > maxBody {
|
||||
return nil, resp.StatusCode, ErrRead
|
||||
}
|
||||
return data, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
func (r *reader) login(ctx context.Context) error {
|
||||
data, code, err := r.request(ctx, "/cgi-bin/luci/", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if code != http.StatusForbidden && code != http.StatusOK {
|
||||
return ErrRead
|
||||
}
|
||||
doc, err := html.Parse(strings.NewReader(string(data)))
|
||||
if err != nil {
|
||||
return ErrRead
|
||||
}
|
||||
form := url.Values{}
|
||||
walk(doc, func(n *html.Node) {
|
||||
if n.Type == html.ElementNode && n.Data == "input" && attr(n, "type") == "hidden" {
|
||||
switch attr(n, "name") {
|
||||
case "_csrf", "salt", "token":
|
||||
form.Set(attr(n, "name"), attr(n, "value"))
|
||||
}
|
||||
}
|
||||
})
|
||||
if form.Get("salt") == "" || form.Get("_csrf") == "" {
|
||||
return ErrRead
|
||||
}
|
||||
token, code, err := r.request(ctx, "/cgi-bin/luci/admin/get_token", url.Values{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if code != http.StatusOK || len(token) > 256 || len(token) == 0 {
|
||||
return ErrRead
|
||||
}
|
||||
hash := sha256.Sum256([]byte(r.password + form.Get("salt")))
|
||||
hash = sha256.Sum256([]byte(hex.EncodeToString(hash[:]) + strings.TrimSpace(string(token))))
|
||||
form.Set("luci_username", "admin")
|
||||
form.Set("luci_password", hex.EncodeToString(hash[:]))
|
||||
form.Set("token", strings.TrimSpace(string(token)))
|
||||
form.Set("luci_language", "en")
|
||||
data, code, err = r.request(ctx, "/cgi-bin/luci/", form)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if code != http.StatusOK || strings.Contains(string(data), "luci_password_login") {
|
||||
return ErrRead
|
||||
}
|
||||
r.authenticated = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *reader) Read(ctx context.Context) ([]Client, error) {
|
||||
if !r.authenticated {
|
||||
if err := r.login(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
data, code, err := r.request(ctx, "/cgi-bin/luci/admin/network/devices/devlist?detail=1", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if code == http.StatusForbidden || code == http.StatusUnauthorized || strings.Contains(string(data), "luci_password_login") {
|
||||
r.authenticated = false
|
||||
if err := r.login(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, code, err = r.request(ctx, "/cgi-bin/luci/admin/network/devices/devlist?detail=1", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if code != http.StatusOK {
|
||||
return nil, ErrRead
|
||||
}
|
||||
return parseClients(data)
|
||||
}
|
||||
|
||||
func attr(n *html.Node, key string) string {
|
||||
for _, a := range n.Attr {
|
||||
if a.Key == key {
|
||||
return a.Val
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
func walk(n *html.Node, visit func(*html.Node)) {
|
||||
visit(n)
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
walk(c, visit)
|
||||
}
|
||||
}
|
||||
func nodeText(n *html.Node) string {
|
||||
var out strings.Builder
|
||||
walk(n, func(c *html.Node) {
|
||||
if c.Type == html.TextNode {
|
||||
out.WriteString(c.Data)
|
||||
out.WriteByte(' ')
|
||||
}
|
||||
})
|
||||
return strings.TrimSpace(out.String())
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package cudy
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/netip"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
func parseClients(data []byte) ([]Client, error) {
|
||||
doc, err := html.Parse(strings.NewReader(string(data)))
|
||||
if err != nil {
|
||||
return nil, ErrRead
|
||||
}
|
||||
clients := []Client{}
|
||||
var table *html.Node
|
||||
walk(doc, func(n *html.Node) {
|
||||
if n.Data == "th" && nodeText(n) == "IP / MAC Address" {
|
||||
for parent := n.Parent; parent != nil; parent = parent.Parent {
|
||||
if parent.Data == "table" {
|
||||
table = parent
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
if table == nil {
|
||||
return nil, ErrRead
|
||||
}
|
||||
invalid := false
|
||||
seen := map[string]bool{}
|
||||
walk(table, func(row *html.Node) {
|
||||
if row.Type != html.ElementNode {
|
||||
return
|
||||
}
|
||||
if row.Data != "tr" {
|
||||
return
|
||||
}
|
||||
hasData := false
|
||||
for child := row.FirstChild; child != nil; child = child.NextSibling {
|
||||
hasData = hasData || child.Data == "td"
|
||||
}
|
||||
if !hasData {
|
||||
return
|
||||
}
|
||||
if !strings.HasPrefix(attr(row, "id"), "cbi-table-") {
|
||||
invalid = true
|
||||
return
|
||||
}
|
||||
fields := map[string]*html.Node{}
|
||||
prefix := attr(row, "id") + "-"
|
||||
walk(row, func(n *html.Node) {
|
||||
if n.Data == "div" && strings.HasPrefix(attr(n, "id"), prefix) {
|
||||
fields[strings.TrimPrefix(attr(n, "id"), prefix)] = n
|
||||
}
|
||||
})
|
||||
var band string
|
||||
wired := false
|
||||
if hostname := fields["hostname"]; hostname != nil {
|
||||
walk(hostname, func(n *html.Node) {
|
||||
if n.Data == "span" {
|
||||
label := nodeText(n)
|
||||
wired = wired || label == "Ethernet"
|
||||
if label == "2.4G WiFi" {
|
||||
band = "2.4 GHz"
|
||||
}
|
||||
if label == "5G WiFi" {
|
||||
band = "5 GHz"
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
// shortcut: stock TR3000 2.5.27 DOM and two bands; update parser fixtures for other firmware.
|
||||
if band == "" {
|
||||
if !wired {
|
||||
invalid = true
|
||||
}
|
||||
return
|
||||
}
|
||||
client := Client{Band: band}
|
||||
if field := fields["ipmac"]; field != nil {
|
||||
walk(field, func(n *html.Node) {
|
||||
if n.Data != "p" || client.MAC != "" {
|
||||
return
|
||||
}
|
||||
for _, value := range strings.Fields(nodeText(n)) {
|
||||
if ip, e := netip.ParseAddr(value); e == nil {
|
||||
client.IP = ip.String()
|
||||
}
|
||||
if mac, e := net.ParseMAC(value); e == nil && len(mac) == 6 {
|
||||
client.MAC = strings.ToUpper(mac.String())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
if field := fields["signal"]; field != nil {
|
||||
walk(field, func(n *html.Node) {
|
||||
if n.Data == "p" && client.Signal == nil {
|
||||
parts := strings.Fields(nodeText(n))
|
||||
if len(parts) == 2 && parts[1] == "dBm" {
|
||||
if value, e := strconv.Atoi(parts[0]); e == nil && value >= -120 && value <= 0 {
|
||||
client.Signal = &value
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
if client.MAC == "" || seen[client.MAC] {
|
||||
invalid = true
|
||||
return
|
||||
}
|
||||
seen[client.MAC] = true
|
||||
clients = append(clients, client)
|
||||
})
|
||||
if invalid || len(clients) > 1024 {
|
||||
return nil, ErrRead
|
||||
}
|
||||
sort.Slice(clients, func(i, j int) bool { return clients[i].MAC < clients[j].MAC })
|
||||
return clients, nil
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package cudy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func clientRow(id, mac, band, signal string) string {
|
||||
return fmt.Sprintf(`<tr id="cbi-table-%s"><td><div id="cbi-table-%s-hostname"><p>PRIVATE NAME<br><span>%s</span></p></div></td><td><div id="cbi-table-%s-ipmac"><p>192.168.10.9<br>%s</p><p>192.168.10.9<br>%s</p></div></td><td><div id="cbi-table-%s-signal"><p>%s</p><p>%s</p></div></td></tr>`, id, id, band, id, mac, mac, id, signal, signal)
|
||||
}
|
||||
func clientTable(rows string) string {
|
||||
return `<table><thead><tr><th>IP / MAC Address</th></tr></thead><tbody>` + rows + `</tbody></table>`
|
||||
}
|
||||
|
||||
func TestParseClients(t *testing.T) {
|
||||
rows := clientRow("1", "AA:BB:CC:DD:EE:02", "2.4G WiFi", "-60 dBm") + clientRow("2", "AA:BB:CC:DD:EE:01", "5G WiFi", "—") + clientRow("3", "AA:BB:CC:DD:EE:03", "Ethernet", "—")
|
||||
clients, err := parseClients([]byte(clientTable(rows)))
|
||||
if err != nil || len(clients) != 2 {
|
||||
t.Fatalf("reading: %v, count %d", err, len(clients))
|
||||
}
|
||||
if clients[0].MAC != "AA:BB:CC:DD:EE:01" || clients[0].Signal != nil || clients[1].Signal == nil || *clients[1].Signal != -60 {
|
||||
t.Fatalf("unexpected snapshot: %+v", clients)
|
||||
}
|
||||
data, _ := json.Marshal(clients)
|
||||
if strings.Contains(string(data), "PRIVATE") {
|
||||
t.Fatal("hostname leaked")
|
||||
}
|
||||
for _, text := range []string{`<form>Login</form>`, clientTable(clientRow("1", "bad", "5G WiFi", "-60 dBm")), clientTable(strings.ReplaceAll(clientRow("1", "AA:BB:CC:DD:EE:01", "5G WiFi", "-60 dBm"), "cbi-table-", "changed-")), clientTable(clientRow("1", "AA:BB:CC:DD:EE:01", "5 GHz", "-60 dBm")), clientTable(clientRow("1", "AA:BB:CC:DD:EE:01", "", "-60 dBm")), clientTable(rows + clientRow("4", "AA:BB:CC:DD:EE:01", "5G WiFi", "-70 dBm"))} {
|
||||
if _, err := parseClients([]byte(text)); !errors.Is(err, ErrRead) {
|
||||
t.Fatalf("invalid DOM accepted: %v", err)
|
||||
}
|
||||
}
|
||||
clients, err = parseClients([]byte(clientTable("")))
|
||||
if err != nil || len(clients) != 0 {
|
||||
t.Fatal("empty table rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReaderSessionAndSafeErrors(t *testing.T) {
|
||||
sessions := 0
|
||||
expire := false
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/cgi-bin/luci/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "POST" {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
first := sha256.Sum256([]byte("private-password" + "salt"))
|
||||
want := sha256.Sum256([]byte(hex.EncodeToString(first[:]) + "token"))
|
||||
if r.Form.Get("luci_password") != hex.EncodeToString(want[:]) || r.Form.Get("_csrf") != "csrf" || r.Form.Get("luci_language") != "en" {
|
||||
t.Error("incorrect login")
|
||||
}
|
||||
sessions++
|
||||
http.SetCookie(w, &http.Cookie{Name: "sysauth", Value: "session", Path: "/"})
|
||||
fmt.Fprint(w, "authenticated")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(403)
|
||||
fmt.Fprint(w, `<form><input type="hidden" name="salt" value="salt"><input type="hidden" name="_csrf" value="csrf"></form>`)
|
||||
})
|
||||
mux.HandleFunc("/cgi-bin/luci/admin/get_token", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
t.Error("token method")
|
||||
}
|
||||
fmt.Fprint(w, "token")
|
||||
})
|
||||
mux.HandleFunc("/cgi-bin/luci/admin/network/devices/devlist", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" || r.URL.RawQuery != "detail=1" {
|
||||
t.Error("not a read-only request")
|
||||
}
|
||||
if _, err := r.Cookie("sysauth"); err != nil {
|
||||
t.Error("session cookie missing")
|
||||
}
|
||||
if expire {
|
||||
expire = false
|
||||
w.WriteHeader(403)
|
||||
return
|
||||
}
|
||||
fmt.Fprint(w, clientTable(clientRow("1", "AA:BB:CC:DD:EE:01", "5G WiFi", "-55 dBm")))
|
||||
})
|
||||
server := httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
reader, err := NewReader(server.URL, "private-password")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < 2; i++ {
|
||||
if _, err := reader.Read(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if sessions != 1 {
|
||||
t.Fatal("session not reused")
|
||||
}
|
||||
expire = true
|
||||
if _, err := reader.Read(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sessions != 2 {
|
||||
t.Fatal("expired session not refreshed once")
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if _, err := reader.Read(ctx); !errors.Is(err, context.Canceled) {
|
||||
t.Fatal("cancellation lost")
|
||||
}
|
||||
for _, base := range []string{"https://example.com", "http://user:secret@192.168.10.1", "http://192.168.10.1/path", "http://8.8.8.8"} {
|
||||
if _, err := NewReader(base, "secret"); !errors.Is(err, ErrRead) {
|
||||
t.Fatal("untrusted URL accepted")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// Package cudy reads Wi-Fi clients from the stock TR3000 web interface.
|
||||
package cudy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
IP string `json:"IP"`
|
||||
MAC string `json:"MAC"`
|
||||
Band string `json:"band"`
|
||||
Signal *int `json:"signal,omitempty"`
|
||||
}
|
||||
|
||||
// Snapshot holds only the latest sanitized reading, never names or credentials.
|
||||
type Snapshot struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
CollectedAt time.Time `json:"collectedAt"`
|
||||
HasError bool `json:"hasError"`
|
||||
Clients []Client `json:"clients"`
|
||||
}
|
||||
|
||||
type Reader interface {
|
||||
Read(context.Context) ([]Client, error)
|
||||
}
|
||||
@@ -377,7 +377,7 @@ func verifyExportedGameReads(t *testing.T, f serviceFixture, worker *game_export
|
||||
games := game_service.NewGameService(f.games, f.teams, f.scenarios, nil, "", actions_repo.NewActionsRepo(f.pool), applications_repo.NewApplicationsRepo(f.pool))
|
||||
mapRepo := game_map_repo.NewGameMapRepo(f.pool)
|
||||
server := app.NewServer(nil, nil, nil, nil, games, nil, nil, nil, nil, nil, nil,
|
||||
screen_service.NewScreenService(screen_sessions_repo.NewScreenSessionsRepo(f.pool)), nil, nil, map_service.NewMapService(mapRepo), nil, worker, nil, nil, nil, nil)
|
||||
screen_service.NewScreenService(screen_sessions_repo.NewScreenSessionsRepo(f.pool)), nil, nil, map_service.NewMapService(mapRepo), nil, worker, nil, nil, nil, nil, nil)
|
||||
ctx := processor_jwt.ContextWithClaims(context.Background(), &processor_jwt.JWTClaims{UserID: f.userID, Roles: []string{"organizer"}})
|
||||
game, err := server.GetGame(ctx, &proto.GetGameReq{ID: int32(f.gameID)})
|
||||
check(t, err)
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// Package wifi_service reads the collector's latest snapshot without LAN access.
|
||||
package wifi_service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"evening_detective_server/internal/modules/cudy"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
path string
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func New(path string) *Service { return &Service{path: path, now: time.Now} }
|
||||
|
||||
func (s *Service) Get(ctx context.Context) (cudy.Snapshot, bool) {
|
||||
if s == nil || s.path == "" {
|
||||
return cudy.Snapshot{}, false
|
||||
}
|
||||
snapshot := cudy.Snapshot{Enabled: true, HasError: true}
|
||||
if ctx.Err() != nil {
|
||||
return snapshot, true
|
||||
}
|
||||
file, err := os.Open(s.path)
|
||||
if err != nil {
|
||||
return snapshot, true
|
||||
}
|
||||
defer file.Close()
|
||||
data, err := io.ReadAll(io.LimitReader(file, 2<<20+1))
|
||||
if err != nil || len(data) > 2<<20 || json.Unmarshal(data, &snapshot) != nil {
|
||||
return cudy.Snapshot{Enabled: true, HasError: true}, true
|
||||
}
|
||||
if !snapshot.Enabled {
|
||||
return snapshot, false
|
||||
}
|
||||
stale := snapshot.HasError || snapshot.CollectedAt.IsZero() || s.now().Sub(snapshot.CollectedAt) >= 30*time.Second || snapshot.CollectedAt.After(s.now())
|
||||
return snapshot, stale
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package wifi_service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"evening_detective_server/internal/modules/cudy"
|
||||
)
|
||||
|
||||
func TestSnapshotFreshnessAndFailures(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "state.json")
|
||||
now := time.Unix(1234, 0)
|
||||
service := New(path)
|
||||
service.now = func() time.Time { return now }
|
||||
snapshot := cudy.Snapshot{Enabled: true, CollectedAt: now, Clients: []cudy.Client{{MAC: "AA:BB:CC:DD:EE:01"}}}
|
||||
write := func() {
|
||||
t.Helper()
|
||||
data, _ := json.Marshal(snapshot)
|
||||
if err := os.WriteFile(path, data, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
write()
|
||||
result, stale := service.Get(context.Background())
|
||||
if stale || len(result.Clients) != 1 {
|
||||
t.Fatal("fresh snapshot rejected")
|
||||
}
|
||||
now = now.Add(30 * time.Second)
|
||||
if _, stale = service.Get(context.Background()); !stale {
|
||||
t.Fatal("stale snapshot accepted")
|
||||
}
|
||||
snapshot.HasError = true
|
||||
write()
|
||||
result, stale = service.Get(context.Background())
|
||||
if !stale || !result.HasError || len(result.Clients) != 1 {
|
||||
t.Fatal("failed refresh lost snapshot")
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("bad JSON"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, stale = service.Get(context.Background())
|
||||
if !stale || !result.HasError {
|
||||
t.Fatal("corrupt snapshot accepted")
|
||||
}
|
||||
snapshot = cudy.Snapshot{}
|
||||
write()
|
||||
result, stale = service.Get(context.Background())
|
||||
if result.Enabled || stale {
|
||||
t.Fatal("disabled source marked stale")
|
||||
}
|
||||
if result, stale = New("").Get(context.Background()); result.Enabled || stale {
|
||||
t.Fatal("unconfigured source enabled")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user