This commit is contained in:
2026-10-11 14:59:51 +07:00
parent e3576db693
commit 5da08f6916
17 changed files with 1235 additions and 258 deletions
+2
View File
@@ -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
+27
View File
@@ -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
}
+35
View File
@@ -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)
}
}
+4
View File
@@ -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,
+175
View File
@@ -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())
}
+123
View File
@@ -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
}
+120
View File
@@ -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")
}
}
}
+26
View File
@@ -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)
+43
View File
@@ -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")
}
}