working offers
This commit is contained in:
@@ -1,13 +1,25 @@
|
||||
load("@io_bazel_rules_go//go:def.bzl", "go_library")
|
||||
load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test")
|
||||
|
||||
go_library(
|
||||
name = "dhcp",
|
||||
srcs = [
|
||||
"offer.go",
|
||||
"options.go",
|
||||
"packet.go",
|
||||
"parameters.go",
|
||||
"read_options.go",
|
||||
],
|
||||
importpath = "git.wntrmute.dev/kyle/kdhcp/dhcp",
|
||||
visibility = ["//visibility:public"],
|
||||
deps = ["@dev_wntrmute_git_kyle_goutils//log"],
|
||||
deps = [
|
||||
"//leases",
|
||||
"@dev_wntrmute_git_kyle_goutils//assert",
|
||||
"@dev_wntrmute_git_kyle_goutils//log",
|
||||
],
|
||||
)
|
||||
|
||||
go_test(
|
||||
name = "dhcp_test",
|
||||
srcs = ["parameters_test.go"],
|
||||
embed = [":dhcp"],
|
||||
)
|
||||
|
||||
36
dhcp/offer.go
Normal file
36
dhcp/offer.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package dhcp
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
|
||||
"git.wntrmute.dev/kyle/kdhcp/leases"
|
||||
)
|
||||
|
||||
func NewOffer(request *Packet, server *leases.Server, lease *leases.Info) (offer *Packet, err error) {
|
||||
packet := &Packet{
|
||||
MessageType: MessageTypeBootResponse,
|
||||
HardwareType: request.HardwareType,
|
||||
HardwareAddress: request.HardwareAddress,
|
||||
Hops: 0,
|
||||
TransactionID: request.TransactionID,
|
||||
SecondsElapsed: 0,
|
||||
Flags: request.Flags,
|
||||
ServerName: "",
|
||||
FileName: "",
|
||||
ClientIP: request.ClientIP,
|
||||
YourIP: lease.Addr,
|
||||
NextIP: netip.IPv4Unspecified(),
|
||||
RelayIP: netip.IPv4Unspecified(),
|
||||
DHCPType: DHCPMessageTypeOffer,
|
||||
HostName: "",
|
||||
ClientID: request.ClientID,
|
||||
Parameters: []Parameter{},
|
||||
}
|
||||
|
||||
packet.AddParameter(ParameterDNS(server.Network.DNS))
|
||||
packet.AddParameter(ParameterHostName(lease.HostName))
|
||||
packet.AddParameter(ParameterRouter(server.Network.Gateway))
|
||||
packet.AddParameter(ParameterSubnetMask(server.Network.Mask))
|
||||
|
||||
return packet, nil
|
||||
}
|
||||
@@ -10,13 +10,13 @@ type OptionTag uint8
|
||||
func (opt OptionTag) String() string {
|
||||
s, ok := optionStrings[opt]
|
||||
if !ok {
|
||||
panic(fmt.Sprintf("no string for option %d", opt))
|
||||
return fmt.Sprintf("<unknown DHCP option [%d]>", opt)
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
type Option func(req *BootRequest, r io.Reader) error
|
||||
type Option func(req *Packet, r io.Reader) error
|
||||
|
||||
const (
|
||||
OptionTagPadding OptionTag = 0
|
||||
|
||||
232
dhcp/packet.go
232
dhcp/packet.go
@@ -5,10 +5,12 @@ import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/netip"
|
||||
"strings"
|
||||
|
||||
"git.wntrmute.dev/kyle/goutils/assert"
|
||||
log "git.wntrmute.dev/kyle/goutils/log"
|
||||
"git.wntrmute.dev/kyle/kdhcp/leases"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -17,7 +19,10 @@ const (
|
||||
maxFileName = 128
|
||||
)
|
||||
|
||||
var anyAddr = net.IP([]byte{0, 0, 0, 0})
|
||||
const (
|
||||
MessageTypeBootRequest = 1
|
||||
MessageTypeBootResponse = 2
|
||||
)
|
||||
|
||||
func formatMAC(mac []byte) string {
|
||||
s := []string{}
|
||||
@@ -28,27 +33,16 @@ func formatMAC(mac []byte) string {
|
||||
return strings.Join(s, ":")
|
||||
}
|
||||
|
||||
type BootRequest struct {
|
||||
MessageType uint8
|
||||
HardwareType uint8
|
||||
HardwareAddress []byte
|
||||
Hops uint8
|
||||
TransactionID uint32
|
||||
SecondsElapsed uint16
|
||||
Flags uint16
|
||||
ServerName string
|
||||
FileName string
|
||||
func readIPv4(r io.Reader) (netip.Addr, error) {
|
||||
var buf [4]byte
|
||||
|
||||
ClientIP net.IP
|
||||
YourIP net.IP
|
||||
NextIP net.IP
|
||||
RelayIP net.IP
|
||||
n, err := r.Read(buf[:])
|
||||
if err != nil {
|
||||
return netip.Addr{}, fmt.Errorf("dhcp: while reading IPv4 address from reader: %w", err)
|
||||
}
|
||||
|
||||
DHCPType DHCPMessageType // option 53
|
||||
HostName string // option 12
|
||||
ClientID string // option 61
|
||||
ParameterRequests []OptionTag
|
||||
endOptions bool
|
||||
assert.Bool(n == 4, fmt.Sprintf("read %d bytes, but expected to read 4 bytes", n))
|
||||
return netip.AddrFrom4(buf), nil
|
||||
}
|
||||
|
||||
func newPacketReaderFunc(r io.Reader) func(v any) error {
|
||||
@@ -57,87 +51,113 @@ func newPacketReaderFunc(r io.Reader) func(v any) error {
|
||||
}
|
||||
}
|
||||
|
||||
func (req *BootRequest) Read(packet []byte) error {
|
||||
func newPacketWriterFunc(w io.Writer) func(v any) error {
|
||||
return func(v any) error {
|
||||
return binary.Write(w, binary.BigEndian, v)
|
||||
}
|
||||
}
|
||||
|
||||
type Packet struct {
|
||||
MessageType uint8
|
||||
HardwareType uint8
|
||||
HardwareAddress leases.HardwareAddress
|
||||
Hops uint8
|
||||
TransactionID uint32
|
||||
SecondsElapsed uint16
|
||||
Flags uint16
|
||||
ServerName string
|
||||
FileName string
|
||||
|
||||
ClientIP netip.Addr
|
||||
YourIP netip.Addr
|
||||
NextIP netip.Addr
|
||||
RelayIP netip.Addr
|
||||
|
||||
DHCPType DHCPMessageType // option 53
|
||||
HostName string // option 12
|
||||
ClientID string // option 61
|
||||
ParameterRequests []OptionTag
|
||||
Parameters []Parameter
|
||||
endOptions bool
|
||||
}
|
||||
|
||||
func (pkt *Packet) Read(packet []byte) (err error) {
|
||||
buf := bytes.NewBuffer(packet)
|
||||
read := newPacketReaderFunc(buf)
|
||||
|
||||
if err := read(&req.MessageType); err != nil {
|
||||
if err = read(&pkt.MessageType); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := read(&req.HardwareType); err != nil {
|
||||
if err = read(&pkt.HardwareType); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var hwaLength uint8
|
||||
if err := read(&hwaLength); err != nil {
|
||||
if err = read(&hwaLength); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := read(&req.Hops); err != nil {
|
||||
if err = read(&pkt.Hops); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := read(&req.TransactionID); err != nil {
|
||||
if err = read(&pkt.TransactionID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := read(&req.SecondsElapsed); err != nil {
|
||||
if err = read(&pkt.SecondsElapsed); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := read(&req.Flags); err != nil {
|
||||
if err = read(&pkt.Flags); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req.ClientIP = anyAddr[:]
|
||||
if _, err := buf.Read(req.ClientIP); err != nil {
|
||||
if pkt.ClientIP, err = readIPv4(buf); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req.YourIP = anyAddr[:]
|
||||
if _, err := buf.Read(req.YourIP); err != nil {
|
||||
if pkt.YourIP, err = readIPv4(buf); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req.NextIP = anyAddr[:]
|
||||
if _, err := buf.Read(req.NextIP); err != nil {
|
||||
if pkt.NextIP, err = readIPv4(buf); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req.RelayIP = anyAddr[:]
|
||||
if _, err := buf.Read(req.RelayIP); err != nil {
|
||||
if pkt.RelayIP, err = readIPv4(buf); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req.HardwareAddress = make([]byte, int(hwaLength))
|
||||
if _, err := buf.Read(req.HardwareAddress); err != nil {
|
||||
pkt.HardwareAddress = make([]byte, int(hwaLength))
|
||||
if _, err = buf.Read(pkt.HardwareAddress); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
hwaPad := make([]byte, maxHardwareAddrLen-hwaLength)
|
||||
if _, err := buf.Read(hwaPad); err != nil {
|
||||
if _, err = buf.Read(hwaPad); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tempBuf := make([]byte, maxServerName)
|
||||
if _, err := buf.Read(tempBuf); err != nil {
|
||||
if _, err = buf.Read(tempBuf); err != nil {
|
||||
return err
|
||||
}
|
||||
req.ServerName = string(bytes.Trim(tempBuf, "\x00"))
|
||||
pkt.ServerName = string(bytes.Trim(tempBuf, "\x00"))
|
||||
|
||||
tempBuf = make([]byte, maxFileName)
|
||||
if _, err := buf.Read(tempBuf); err != nil {
|
||||
if _, err = buf.Read(tempBuf); err != nil {
|
||||
return err
|
||||
}
|
||||
req.FileName = string(bytes.Trim(tempBuf, "\x00"))
|
||||
pkt.FileName = string(bytes.Trim(tempBuf, "\x00"))
|
||||
|
||||
if err := ReadMagicCookie(buf); err != nil {
|
||||
if err = ReadMagicCookie(buf); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for {
|
||||
if req.endOptions {
|
||||
if pkt.endOptions {
|
||||
break
|
||||
}
|
||||
|
||||
@@ -149,9 +169,9 @@ func (req *BootRequest) Read(packet []byte) error {
|
||||
return err
|
||||
}
|
||||
|
||||
err = ReadOption(req, tag, buf)
|
||||
err = ReadOption(pkt, tag, buf)
|
||||
if err != nil {
|
||||
log.Spew(*req)
|
||||
log.Spew(*pkt)
|
||||
log.Spew(packet)
|
||||
return err
|
||||
}
|
||||
@@ -159,13 +179,119 @@ func (req *BootRequest) Read(packet []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func ReadRequest(pkt []byte) (*BootRequest, error) {
|
||||
req := &BootRequest{}
|
||||
err := req.Read(pkt)
|
||||
func ReadPacket(pkt []byte) (*Packet, error) {
|
||||
packet := &Packet{}
|
||||
err := packet.Read(pkt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Debugf("dhcp: BOOTP request with txid %d for %s", req.TransactionID, formatMAC(req.HardwareAddress))
|
||||
return req, nil
|
||||
log.Debugf("dhcp: BOOTP packet with txid %d for %s", packet.TransactionID, formatMAC(packet.HardwareAddress))
|
||||
return packet, nil
|
||||
}
|
||||
|
||||
func (pkt *Packet) Write(w io.Writer) error {
|
||||
write := newPacketWriterFunc(w)
|
||||
|
||||
if err := write(pkt.MessageType); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := write(pkt.HardwareType); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := write(uint8(len(pkt.HardwareAddress))); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := write(pkt.Hops); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := write(pkt.TransactionID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := write(pkt.SecondsElapsed); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := write(pkt.Flags); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := write(pkt.ClientIP.AsSlice()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := write(pkt.YourIP.AsSlice()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := write(pkt.NextIP.AsSlice()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := write(pkt.RelayIP.AsSlice()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
padding := make([]byte, maxHardwareAddrLen-len(pkt.HardwareAddress))
|
||||
if err := write(pkt.HardwareAddress); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := write(padding); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
padding = make([]byte, maxServerName-len(pkt.ServerName))
|
||||
if err := write([]byte(pkt.ServerName)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := write(padding); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
padding = make([]byte, maxFileName-len(pkt.FileName))
|
||||
if err := write([]byte(pkt.FileName)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := write(padding); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := write(magicCookie); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// TODO: write parameter request write
|
||||
|
||||
for _, p := range pkt.Parameters {
|
||||
if !p.Valid() {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := write(p.Bytes()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := write(OptionTagEnd); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func WritePacket(pkt *Packet) ([]byte, error) {
|
||||
buf := &bytes.Buffer{}
|
||||
if err := pkt.Write(buf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
124
dhcp/parameters.go
Normal file
124
dhcp/parameters.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package dhcp
|
||||
|
||||
import "net/netip"
|
||||
|
||||
type Parameter interface {
|
||||
Code() OptionTag
|
||||
Len() int
|
||||
Bytes() []byte // the serialized parameter
|
||||
Valid() bool
|
||||
}
|
||||
|
||||
func (packet *Packet) AddParameter(p Parameter) {
|
||||
packet.Parameters = append(packet.Parameters, p)
|
||||
}
|
||||
|
||||
type parameterWithAddr struct {
|
||||
code OptionTag
|
||||
addr netip.Addr
|
||||
}
|
||||
|
||||
func (p parameterWithAddr) Code() OptionTag {
|
||||
return p.code
|
||||
}
|
||||
|
||||
func (p parameterWithAddr) Len() int {
|
||||
return len(p.addr.AsSlice())
|
||||
}
|
||||
|
||||
func (p parameterWithAddr) Bytes() []byte {
|
||||
return append([]byte{byte(p.code), uint8(p.Len())},
|
||||
p.addr.AsSlice()...)
|
||||
}
|
||||
|
||||
func (p parameterWithAddr) Valid() bool {
|
||||
return p.addr.Is4() && p.addr.IsValid() && !p.addr.IsUnspecified()
|
||||
}
|
||||
|
||||
type parameterWithAddrs struct {
|
||||
code OptionTag
|
||||
addrs []netip.Addr
|
||||
}
|
||||
|
||||
func (p parameterWithAddrs) Code() OptionTag {
|
||||
return p.code
|
||||
}
|
||||
|
||||
func (p parameterWithAddrs) Len() int {
|
||||
plen := 0
|
||||
|
||||
for _, addr := range p.addrs {
|
||||
plen += len(addr.AsSlice())
|
||||
}
|
||||
|
||||
return plen
|
||||
}
|
||||
|
||||
func (p parameterWithAddrs) Bytes() []byte {
|
||||
out := []byte{byte(p.code), byte(p.Len())}
|
||||
|
||||
for _, addr := range p.addrs {
|
||||
out = append(out, addr.AsSlice()...)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func (p parameterWithAddrs) Valid() bool {
|
||||
for _, addr := range p.addrs {
|
||||
if !(addr.Is4() && addr.IsValid() && !addr.IsUnspecified()) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
type parameterWithString struct {
|
||||
code OptionTag
|
||||
data string
|
||||
}
|
||||
|
||||
func (p parameterWithString) Code() OptionTag {
|
||||
return p.code
|
||||
}
|
||||
|
||||
func (p parameterWithString) Len() int {
|
||||
return len(p.data)
|
||||
}
|
||||
|
||||
func (p parameterWithString) Bytes() []byte {
|
||||
out := []byte{byte(p.code), byte(p.Len())}
|
||||
return append(out, []byte(p.data)...)
|
||||
}
|
||||
|
||||
func (p parameterWithString) Valid() bool {
|
||||
return len(p.data) > 0
|
||||
}
|
||||
|
||||
func ParameterRouter(router netip.Addr) Parameter {
|
||||
return ¶meterWithAddr{
|
||||
code: OptionTagRouter,
|
||||
addr: router,
|
||||
}
|
||||
}
|
||||
|
||||
func ParameterSubnetMask(mask netip.Addr) Parameter {
|
||||
return ¶meterWithAddr{
|
||||
code: OptionTagSubnetMask,
|
||||
addr: mask,
|
||||
}
|
||||
}
|
||||
|
||||
func ParameterDNS(dns []netip.Addr) Parameter {
|
||||
return ¶meterWithAddrs{
|
||||
code: OptionTagDomainNameServer,
|
||||
addrs: dns,
|
||||
}
|
||||
}
|
||||
|
||||
func ParameterHostName(name string) Parameter {
|
||||
return ¶meterWithString{
|
||||
code: OptionTagHostName,
|
||||
data: name,
|
||||
}
|
||||
}
|
||||
35
dhcp/parameters_test.go
Normal file
35
dhcp/parameters_test.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package dhcp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/netip"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func Test_parameterSubnetMask(t *testing.T) {
|
||||
expected := []byte{
|
||||
byte(OptionTagSubnetMask), 4, 255, 255, 255, 0,
|
||||
}
|
||||
|
||||
prefix := netip.MustParseAddr("255.255.255.0")
|
||||
param := ParameterSubnetMask(prefix)
|
||||
|
||||
out := param.Bytes()
|
||||
if !bytes.Equal(expected, out) {
|
||||
t.Fatalf("invalid parameter subnet mask: have %x, want %x", out, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func Test_parameterRouter(t *testing.T) {
|
||||
expected := []byte{
|
||||
byte(OptionTagRouter), 4, 192, 168, 1, 1,
|
||||
}
|
||||
|
||||
router := netip.MustParseAddr("192.168.1.1")
|
||||
param := ParameterRouter(router)
|
||||
|
||||
out := param.Bytes()
|
||||
if !bytes.Equal(expected, out) {
|
||||
t.Fatalf("invalid parameter router: have %x, want %x", out, expected)
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ var optionRegistry = map[OptionTag]Option{
|
||||
OptionTagEnd: OptionEnd,
|
||||
}
|
||||
|
||||
func OptionPad(req *BootRequest, r io.Reader) error {
|
||||
func OptionPad(req *Packet, r io.Reader) error {
|
||||
// The padding option is a single 0 byte octet.
|
||||
return nil
|
||||
}
|
||||
@@ -46,7 +46,7 @@ func getOptionLength(r io.Reader) (int, error) {
|
||||
// character set restrictions.
|
||||
|
||||
// The code for this option is 12, and its minimum length is 1.
|
||||
func OptionHostName(req *BootRequest, r io.Reader) error {
|
||||
func OptionHostName(req *Packet, r io.Reader) error {
|
||||
length, err := getOptionLength(r)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -63,7 +63,7 @@ func OptionHostName(req *BootRequest, r io.Reader) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func OptionMessageType(req *BootRequest, r io.Reader) error {
|
||||
func OptionMessageType(req *Packet, r io.Reader) error {
|
||||
read := newPacketReaderFunc(r)
|
||||
|
||||
if length, err := getOptionLength(r); err != nil {
|
||||
@@ -79,7 +79,7 @@ func OptionMessageType(req *BootRequest, r io.Reader) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func OptionParameterRequestList(req *BootRequest, r io.Reader) error {
|
||||
func OptionParameterRequestList(req *Packet, r io.Reader) error {
|
||||
length, err := getOptionLength(r)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -103,7 +103,7 @@ func OptionParameterRequestList(req *BootRequest, r io.Reader) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func OptionClientID(req *BootRequest, r io.Reader) error {
|
||||
func OptionClientID(req *Packet, r io.Reader) error {
|
||||
length, err := getOptionLength(r)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -122,12 +122,12 @@ func OptionClientID(req *BootRequest, r io.Reader) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func OptionEnd(req *BootRequest, r io.Reader) error {
|
||||
func OptionEnd(req *Packet, r io.Reader) error {
|
||||
req.endOptions = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func ReadOption(req *BootRequest, tag byte, r io.Reader) error {
|
||||
func ReadOption(req *Packet, tag byte, r io.Reader) error {
|
||||
opt := OptionTag(tag)
|
||||
if f, ok := optionRegistry[opt]; ok {
|
||||
log.Debugf("dhcp: reading option=%s", opt)
|
||||
@@ -137,7 +137,7 @@ func ReadOption(req *BootRequest, tag byte, r io.Reader) error {
|
||||
return readUnknownOption(req, tag, r)
|
||||
}
|
||||
|
||||
func readUnknownOption(req *BootRequest, tag byte, r io.Reader) error {
|
||||
func readUnknownOption(req *Packet, tag byte, r io.Reader) error {
|
||||
length, err := getOptionLength(r)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
Reference in New Issue
Block a user