WIP
This commit is contained in:
@@ -45,7 +45,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ifaceName := strings.TrimSuffix(state.LocalDomain, ".local")
|
ifaceName := strings.TrimSuffix(state.LocalDomain, ".local")
|
||||||
app, err := peer.New(state, *hub, apiKey, ifaceName, state.LocalDomain)
|
app, err := peer.New(state, *hub, apiKey, ifaceName, state.LocalDomain, vppnPath(*name, "network.json"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("start: %v", err)
|
log.Fatalf("start: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ type HubPoller struct {
|
|||||||
vpnNet netip.Prefix
|
vpnNet netip.Prefix
|
||||||
hubURL string
|
hubURL string
|
||||||
apiKey string
|
apiKey string
|
||||||
|
statePath string // where the network state cache is persisted
|
||||||
addCh chan<- HubPeer
|
addCh chan<- HubPeer
|
||||||
removeCh chan<- wgtypes.Key
|
removeCh chan<- wgtypes.Key
|
||||||
known map[wgtypes.Key]int64 // pubKey → last seen version
|
known map[wgtypes.Key]int64 // pubKey → last seen version
|
||||||
@@ -30,6 +31,7 @@ func NewHubPoller(
|
|||||||
selfVPNIP netip.Addr,
|
selfVPNIP netip.Addr,
|
||||||
vpnNet netip.Prefix,
|
vpnNet netip.Prefix,
|
||||||
hubURL, apiKey string,
|
hubURL, apiKey string,
|
||||||
|
statePath string,
|
||||||
addCh chan<- HubPeer,
|
addCh chan<- HubPeer,
|
||||||
removeCh chan<- wgtypes.Key,
|
removeCh chan<- wgtypes.Key,
|
||||||
) (*HubPoller, error) {
|
) (*HubPoller, error) {
|
||||||
@@ -44,6 +46,7 @@ func NewHubPoller(
|
|||||||
vpnNet: vpnNet,
|
vpnNet: vpnNet,
|
||||||
hubURL: u.String(),
|
hubURL: u.String(),
|
||||||
apiKey: apiKey,
|
apiKey: apiKey,
|
||||||
|
statePath: statePath,
|
||||||
addCh: addCh,
|
addCh: addCh,
|
||||||
removeCh: removeCh,
|
removeCh: removeCh,
|
||||||
known: make(map[wgtypes.Key]int64),
|
known: make(map[wgtypes.Key]int64),
|
||||||
@@ -51,6 +54,14 @@ func NewHubPoller(
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (hp *HubPoller) Run() {
|
func (hp *HubPoller) Run() {
|
||||||
|
// Prime from the on-disk cache before reaching the hub, so the peer
|
||||||
|
// configures WireGuard from its last known state even if the hub is down.
|
||||||
|
// known starts empty, so this emits every cached peer as an add and seeds
|
||||||
|
// the version map; the first real poll then emits only deltas.
|
||||||
|
if state, err := loadNetworkState(hp.statePath); err == nil {
|
||||||
|
hp.apply(state)
|
||||||
|
}
|
||||||
|
|
||||||
hp.poll()
|
hp.poll()
|
||||||
for range time.Tick(hubPollInterval) {
|
for range time.Tick(hubPollInterval) {
|
||||||
hp.poll()
|
hp.poll()
|
||||||
@@ -90,10 +101,19 @@ func (hp *HubPoller) poll() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
hp.apply(state)
|
// Persist only when the state actually changed, to avoid needless writes
|
||||||
|
// on every poll.
|
||||||
|
if hp.apply(state) {
|
||||||
|
if err := saveNetworkState(hp.statePath, state); err != nil {
|
||||||
|
log.Printf("[HubPoller] save state: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (hp *HubPoller) apply(state m.NetworkState) {
|
// apply diffs state against the known versions, emitting add events for new or
|
||||||
|
// changed peers and remove events for peers that disappeared. It returns true
|
||||||
|
// if anything changed.
|
||||||
|
func (hp *HubPoller) apply(state m.NetworkState) (changed bool) {
|
||||||
seen := make(map[wgtypes.Key]struct{}, len(hp.known))
|
seen := make(map[wgtypes.Key]struct{}, len(hp.known))
|
||||||
|
|
||||||
netAddr := hp.vpnNet.Addr().As4()
|
netAddr := hp.vpnNet.Addr().As4()
|
||||||
@@ -122,14 +142,18 @@ func (hp *HubPoller) apply(state m.NetworkState) {
|
|||||||
}
|
}
|
||||||
hp.known[pubKey] = p.Version
|
hp.known[pubKey] = p.Version
|
||||||
hp.addCh <- hubPeerFrom(pubKey, vpnIP, p)
|
hp.addCh <- hubPeerFrom(pubKey, vpnIP, p)
|
||||||
|
changed = true
|
||||||
}
|
}
|
||||||
|
|
||||||
for key := range hp.known {
|
for key := range hp.known {
|
||||||
if _, ok := seen[key]; !ok {
|
if _, ok := seen[key]; !ok {
|
||||||
delete(hp.known, key)
|
delete(hp.known, key)
|
||||||
hp.removeCh <- key
|
hp.removeCh <- key
|
||||||
|
changed = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return changed
|
||||||
}
|
}
|
||||||
|
|
||||||
func hubPeerFrom(pubKey wgtypes.Key, vpnIP netip.Addr, p *m.Peer) HubPeer {
|
func hubPeerFrom(pubKey wgtypes.Key, vpnIP netip.Addr, p *m.Peer) HubPeer {
|
||||||
|
|||||||
99
peer/hub_poller_test.go
Normal file
99
peer/hub_poller_test.go
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
package peer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/netip"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||||
|
|
||||||
|
"vppn/m"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testPoller(t *testing.T) (*HubPoller, chan HubPeer, chan wgtypes.Key) {
|
||||||
|
t.Helper()
|
||||||
|
addCh := make(chan HubPeer, 8)
|
||||||
|
removeCh := make(chan wgtypes.Key, 8)
|
||||||
|
hp := &HubPoller{
|
||||||
|
selfVPNIP: netip.MustParseAddr("10.0.0.1"),
|
||||||
|
vpnNet: netip.MustParsePrefix("10.0.0.0/24"),
|
||||||
|
addCh: addCh,
|
||||||
|
removeCh: removeCh,
|
||||||
|
known: make(map[wgtypes.Key]int64),
|
||||||
|
}
|
||||||
|
return hp, addCh, removeCh
|
||||||
|
}
|
||||||
|
|
||||||
|
func stateWith(key wgtypes.Key, peerIP byte, version int64) m.NetworkState {
|
||||||
|
var s m.NetworkState
|
||||||
|
s.Peers[peerIP] = &m.Peer{
|
||||||
|
PeerIP: peerIP,
|
||||||
|
Version: version,
|
||||||
|
WGPubKey: key[:],
|
||||||
|
SignPubKey: make([]byte, 32),
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApply_EmitsAddsAndReportsChange(t *testing.T) {
|
||||||
|
hp, addCh, _ := testPoller(t)
|
||||||
|
key := mustKey(t)
|
||||||
|
|
||||||
|
if changed := hp.apply(stateWith(key, 2, 1)); !changed {
|
||||||
|
t.Fatal("expected changed=true on first apply")
|
||||||
|
}
|
||||||
|
if len(addCh) != 1 {
|
||||||
|
t.Fatalf("expected 1 add, got %d", len(addCh))
|
||||||
|
}
|
||||||
|
if got := <-addCh; got.PubKey != key {
|
||||||
|
t.Errorf("add pubkey mismatch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApply_NoChangeWhenVersionSame(t *testing.T) {
|
||||||
|
hp, addCh, _ := testPoller(t)
|
||||||
|
key := mustKey(t)
|
||||||
|
|
||||||
|
hp.apply(stateWith(key, 2, 1))
|
||||||
|
<-addCh // drain initial add
|
||||||
|
|
||||||
|
if changed := hp.apply(stateWith(key, 2, 1)); changed {
|
||||||
|
t.Fatal("expected changed=false when version unchanged")
|
||||||
|
}
|
||||||
|
if len(addCh) != 0 {
|
||||||
|
t.Fatalf("expected no re-emit, got %d adds", len(addCh))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApply_ReEmitsOnVersionBump(t *testing.T) {
|
||||||
|
hp, addCh, _ := testPoller(t)
|
||||||
|
key := mustKey(t)
|
||||||
|
|
||||||
|
hp.apply(stateWith(key, 2, 1))
|
||||||
|
<-addCh
|
||||||
|
|
||||||
|
if changed := hp.apply(stateWith(key, 2, 2)); !changed {
|
||||||
|
t.Fatal("expected changed=true on version bump")
|
||||||
|
}
|
||||||
|
if len(addCh) != 1 {
|
||||||
|
t.Fatalf("expected 1 re-emit, got %d", len(addCh))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApply_RemovesVanishedPeer(t *testing.T) {
|
||||||
|
hp, addCh, removeCh := testPoller(t)
|
||||||
|
key := mustKey(t)
|
||||||
|
|
||||||
|
hp.apply(stateWith(key, 2, 1))
|
||||||
|
<-addCh
|
||||||
|
|
||||||
|
// Empty state: the peer is gone.
|
||||||
|
if changed := hp.apply(m.NetworkState{}); !changed {
|
||||||
|
t.Fatal("expected changed=true when peer vanishes")
|
||||||
|
}
|
||||||
|
if len(removeCh) != 1 {
|
||||||
|
t.Fatalf("expected 1 remove, got %d", len(removeCh))
|
||||||
|
}
|
||||||
|
if got := <-removeCh; got != key {
|
||||||
|
t.Errorf("remove key mismatch")
|
||||||
|
}
|
||||||
|
}
|
||||||
112
peer/init.go
112
peer/init.go
@@ -9,7 +9,6 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
|
||||||
|
|
||||||
"golang.org/x/crypto/nacl/sign"
|
"golang.org/x/crypto/nacl/sign"
|
||||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||||
@@ -32,21 +31,27 @@ type LocalState struct {
|
|||||||
|
|
||||||
// localStateJSON is the on-disk representation.
|
// localStateJSON is the on-disk representation.
|
||||||
type localStateJSON struct {
|
type localStateJSON struct {
|
||||||
PrivKey string `json:"priv_key"` // standard base64
|
PrivKey string
|
||||||
SignKey string `json:"sign_key"` // standard base64
|
SignKey string
|
||||||
VPNIP netip.Addr `json:"vpn_ip"`
|
VPNIP netip.Addr
|
||||||
VPNNet netip.Prefix `json:"vpn_net"`
|
VPNNet netip.Prefix
|
||||||
WGPort uint16 `json:"wg_port"`
|
WGPort uint16
|
||||||
IsRelay bool `json:"is_relay"`
|
IsRelay bool
|
||||||
IsPublic bool `json:"is_public"`
|
IsPublic bool
|
||||||
LocalDomain string `json:"local_domain"`
|
LocalDomain string
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadOrInit loads LocalState from path, or registers with the hub and creates
|
// LoadOrInit loads LocalState from path, or registers with the hub and creates
|
||||||
// the file if it doesn't exist.
|
// the file if it doesn't exist.
|
||||||
func LoadOrInit(statePath, hubURL, apiKey string) (LocalState, error) {
|
func LoadOrInit(statePath, hubURL, apiKey string) (LocalState, error) {
|
||||||
if data, err := os.ReadFile(statePath); err == nil {
|
var state LocalState
|
||||||
return parseLocalState(data)
|
switch err := loadJSON(statePath, &state); {
|
||||||
|
case err == nil:
|
||||||
|
return state, nil
|
||||||
|
case !os.IsNotExist(err):
|
||||||
|
// File exists but is unreadable/corrupt: surface it rather than
|
||||||
|
// silently regenerating a new identity and re-registering.
|
||||||
|
return LocalState{}, fmt.Errorf("load state: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
privKey, err := wgtypes.GeneratePrivateKey()
|
privKey, err := wgtypes.GeneratePrivateKey()
|
||||||
@@ -54,12 +59,12 @@ func LoadOrInit(statePath, hubURL, apiKey string) (LocalState, error) {
|
|||||||
return LocalState{}, fmt.Errorf("generate key: %w", err)
|
return LocalState{}, fmt.Errorf("generate key: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
state, err := initFromHub(hubURL, apiKey, privKey)
|
state, err = initFromHub(hubURL, apiKey, privKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return LocalState{}, err
|
return LocalState{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := saveLocalState(statePath, state); err != nil {
|
if err := storeJSON(statePath, state); err != nil {
|
||||||
return LocalState{}, fmt.Errorf("save state: %w", err)
|
return LocalState{}, fmt.Errorf("save state: %w", err)
|
||||||
}
|
}
|
||||||
return state, nil
|
return state, nil
|
||||||
@@ -130,42 +135,8 @@ func initFromHub(hubURL, apiKey string, privKey wgtypes.Key) (LocalState, error)
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseLocalState(data []byte) (LocalState, error) {
|
func (s LocalState) MarshalJSON() ([]byte, error) {
|
||||||
var j localStateJSON
|
return json.Marshal(localStateJSON{
|
||||||
if err := json.Unmarshal(data, &j); err != nil {
|
|
||||||
return LocalState{}, fmt.Errorf("parse state: %w", err)
|
|
||||||
}
|
|
||||||
keyBytes, err := base64.StdEncoding.DecodeString(j.PrivKey)
|
|
||||||
if err != nil {
|
|
||||||
return LocalState{}, fmt.Errorf("decode key: %w", err)
|
|
||||||
}
|
|
||||||
key, err := wgtypes.NewKey(keyBytes)
|
|
||||||
if err != nil {
|
|
||||||
return LocalState{}, fmt.Errorf("invalid key: %w", err)
|
|
||||||
}
|
|
||||||
signKeyBytes, err := base64.StdEncoding.DecodeString(j.SignKey)
|
|
||||||
if err != nil {
|
|
||||||
return LocalState{}, fmt.Errorf("decode sign key: %w", err)
|
|
||||||
}
|
|
||||||
if len(signKeyBytes) != 64 {
|
|
||||||
return LocalState{}, fmt.Errorf("invalid sign key length: %d", len(signKeyBytes))
|
|
||||||
}
|
|
||||||
var signKey [64]byte
|
|
||||||
copy(signKey[:], signKeyBytes)
|
|
||||||
return LocalState{
|
|
||||||
PrivKey: key,
|
|
||||||
SignKey: signKey,
|
|
||||||
VPNIP: j.VPNIP,
|
|
||||||
VPNNet: j.VPNNet,
|
|
||||||
WGPort: j.WGPort,
|
|
||||||
IsRelay: j.IsRelay,
|
|
||||||
IsPublic: j.IsPublic,
|
|
||||||
LocalDomain: j.LocalDomain,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func saveLocalState(path string, s LocalState) error {
|
|
||||||
j := localStateJSON{
|
|
||||||
PrivKey: base64.StdEncoding.EncodeToString(s.PrivKey[:]),
|
PrivKey: base64.StdEncoding.EncodeToString(s.PrivKey[:]),
|
||||||
SignKey: base64.StdEncoding.EncodeToString(s.SignKey[:]),
|
SignKey: base64.StdEncoding.EncodeToString(s.SignKey[:]),
|
||||||
VPNIP: s.VPNIP,
|
VPNIP: s.VPNIP,
|
||||||
@@ -174,13 +145,38 @@ func saveLocalState(path string, s LocalState) error {
|
|||||||
IsRelay: s.IsRelay,
|
IsRelay: s.IsRelay,
|
||||||
IsPublic: s.IsPublic,
|
IsPublic: s.IsPublic,
|
||||||
LocalDomain: s.LocalDomain,
|
LocalDomain: s.LocalDomain,
|
||||||
}
|
})
|
||||||
data, err := json.MarshalIndent(j, "", " ")
|
}
|
||||||
if err != nil {
|
|
||||||
return err
|
func (s *LocalState) UnmarshalJSON(data []byte) error {
|
||||||
}
|
var j localStateJSON
|
||||||
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
|
if err := json.Unmarshal(data, &j); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return os.WriteFile(path, data, 0600)
|
keyBytes, err := base64.StdEncoding.DecodeString(j.PrivKey)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("decode key: %w", err)
|
||||||
|
}
|
||||||
|
key, err := wgtypes.NewKey(keyBytes)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid key: %w", err)
|
||||||
|
}
|
||||||
|
signKeyBytes, err := base64.StdEncoding.DecodeString(j.SignKey)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("decode sign key: %w", err)
|
||||||
|
}
|
||||||
|
if len(signKeyBytes) != 64 {
|
||||||
|
return fmt.Errorf("invalid sign key length: %d", len(signKeyBytes))
|
||||||
|
}
|
||||||
|
*s = LocalState{
|
||||||
|
PrivKey: key,
|
||||||
|
SignKey: [64]byte(signKeyBytes),
|
||||||
|
VPNIP: j.VPNIP,
|
||||||
|
VPNNet: j.VPNNet,
|
||||||
|
WGPort: j.WGPort,
|
||||||
|
IsRelay: j.IsRelay,
|
||||||
|
IsPublic: j.IsPublic,
|
||||||
|
LocalDomain: j.LocalDomain,
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
18
peer/network_state.go
Normal file
18
peer/network_state.go
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
package peer
|
||||||
|
|
||||||
|
import "vppn/m"
|
||||||
|
|
||||||
|
// loadNetworkState reads a cached network state from disk. Any error (most
|
||||||
|
// commonly a missing file on first run) is returned to the caller, which
|
||||||
|
// treats it as "no cache available".
|
||||||
|
func loadNetworkState(path string) (m.NetworkState, error) {
|
||||||
|
var state m.NetworkState
|
||||||
|
err := loadJSON(path, &state)
|
||||||
|
return state, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveNetworkState writes state to path atomically (see storeJSON), so a crash
|
||||||
|
// mid-write cannot leave a corrupt cache.
|
||||||
|
func saveNetworkState(path string, state m.NetworkState) error {
|
||||||
|
return storeJSON(path, state)
|
||||||
|
}
|
||||||
54
peer/network_state_test.go
Normal file
54
peer/network_state_test.go
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
package peer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"vppn/m"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNetworkState_RoundTrip(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "network.json")
|
||||||
|
|
||||||
|
var state m.NetworkState
|
||||||
|
state.Peers[1] = &m.Peer{
|
||||||
|
PeerIP: 1,
|
||||||
|
Version: 7,
|
||||||
|
Name: "hub",
|
||||||
|
Addr4: []byte{10, 11, 12, 1},
|
||||||
|
Port: 51820,
|
||||||
|
Relay: true,
|
||||||
|
WGPubKey: make([]byte, 32),
|
||||||
|
SignPubKey: make([]byte, 32),
|
||||||
|
}
|
||||||
|
state.Peers[10] = &m.Peer{
|
||||||
|
PeerIP: 10,
|
||||||
|
Version: 3,
|
||||||
|
Name: "laptop",
|
||||||
|
Addr4: []byte{10, 11, 12, 10},
|
||||||
|
Port: 51820,
|
||||||
|
WGPubKey: []byte("0123456789abcdef0123456789abcdef"),
|
||||||
|
SignPubKey: []byte("fedcba9876543210fedcba9876543210"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := saveNetworkState(path, state); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := loadNetworkState(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !reflect.DeepEqual(got, state) {
|
||||||
|
t.Errorf("round-trip mismatch:\n got: %+v\nwant: %+v", got.Peers[1], state.Peers[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNetworkState_LoadMissing(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "does-not-exist.json")
|
||||||
|
if _, err := loadNetworkState(path); err == nil {
|
||||||
|
t.Fatal("expected error loading missing cache, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
12
peer/new.go
12
peer/new.go
@@ -17,6 +17,7 @@ func New(
|
|||||||
hubURL, apiKey string,
|
hubURL, apiKey string,
|
||||||
ifaceName string,
|
ifaceName string,
|
||||||
localDomain string,
|
localDomain string,
|
||||||
|
networkStatePath string,
|
||||||
) (*App, error) {
|
) (*App, error) {
|
||||||
|
|
||||||
a4 := state.VPNIP.As4()
|
a4 := state.VPNIP.As4()
|
||||||
@@ -50,10 +51,13 @@ func New(
|
|||||||
multicastCh := make(chan MulticastEvent)
|
multicastCh := make(chan MulticastEvent)
|
||||||
|
|
||||||
poller, err := NewHubPoller(
|
poller, err := NewHubPoller(
|
||||||
state.VPNIP, state.VPNNet,
|
state.VPNIP,
|
||||||
hubURL, apiKey,
|
state.VPNNet,
|
||||||
hubAddCh, hubRemoveCh,
|
hubURL,
|
||||||
)
|
apiKey,
|
||||||
|
networkStatePath,
|
||||||
|
hubAddCh,
|
||||||
|
hubRemoveCh)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("hub poller: %w", err)
|
return nil, fmt.Errorf("hub poller: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user