mirror of
https://github.com/netbirdio/netbird.git
synced 2026-08-27 01:51:30 +02:00
Files move directly between peers over the overlay, with no server in the path. The receiver listens on the WireGuard address only, so the port is unreachable from outside the tunnel, and every offer is matched to a known peer before anything is read. Consent is the default: an offer carries metadata alone, and no payload moves until the receiver accepts. Policy is per profile and device-local — off, ask, or auto-accept, with per-sender exceptions on top. Policy and history live in the profile's preferences, so removing a profile takes its file drop state with it. Transfers interrupted by a restart are settled on load; nothing survives to finish them, and left alone they would sit in the log as permanently pending. The Android bindings pull payload bytes through a chunk-returning stream: gomobile copies a []byte argument into a fresh Java array and never copies it back, so a fill-my-buffer method would hand back the right length with no data.
661 lines
16 KiB
Go
661 lines
16 KiB
Go
package filedrop
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net/netip"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
log "github.com/sirupsen/logrus"
|
|
"golang.zx2c4.com/wireguard/tun/netstack"
|
|
|
|
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
|
)
|
|
|
|
// The event kinds. Progress is not an event: live transfers are polled.
|
|
const (
|
|
EventOffer EventKind = iota
|
|
EventCompleted
|
|
EventFailed
|
|
EventWithdrawn
|
|
)
|
|
|
|
// portSignalGrace bounds how long a failed attempt waits for one signal message
|
|
// that may advertise the receiver's actual port before giving up.
|
|
const portSignalGrace = 3 * time.Second
|
|
|
|
// ErrNotConnected indicates the operation needs a running tunnel.
|
|
var ErrNotConnected = errors.New("not connected")
|
|
|
|
// EventKind classifies the events the manager surfaces to the platform layer.
|
|
type EventKind uint8
|
|
|
|
// EventSink receives transfer events. Calls may come from server goroutines.
|
|
type EventSink func(kind EventKind, transfer Transfer)
|
|
|
|
// ManagerConfig configures a per-profile file drop manager. Policy and history
|
|
// live in Store; DataDir only holds the spool of partially received files,
|
|
// which is disposable and never outlives an offer's TTL.
|
|
type ManagerConfig struct {
|
|
Profile profilemanager.ID
|
|
DataDir string
|
|
Store Store
|
|
Events EventSink
|
|
OfferTTL time.Duration
|
|
}
|
|
|
|
type sendHandle struct {
|
|
cancel context.CancelFunc
|
|
ip netip.Addr
|
|
addr netip.AddrPort
|
|
remoteID OfferID
|
|
}
|
|
|
|
// Manager owns one profile's file drop state.
|
|
type Manager struct {
|
|
mu sync.Mutex
|
|
profile profilemanager.ID
|
|
dataDir string
|
|
store Store
|
|
policy *PolicyStore
|
|
history *History
|
|
events EventSink
|
|
offerTTL time.Duration
|
|
|
|
server *Server
|
|
ports *PortRegistry
|
|
dial DialFunc
|
|
senderName string
|
|
sends map[OfferID]*sendHandle
|
|
sendWg sync.WaitGroup
|
|
}
|
|
|
|
// NewManager loads or initializes the file drop state for one profile.
|
|
func NewManager(cfg ManagerConfig) (*Manager, error) {
|
|
if cfg.DataDir == "" {
|
|
return nil, errors.New("data dir is required")
|
|
}
|
|
if err := os.MkdirAll(cfg.DataDir, 0o700); err != nil {
|
|
return nil, fmt.Errorf("create file drop dir: %w", err)
|
|
}
|
|
|
|
m := &Manager{
|
|
profile: cfg.Profile,
|
|
dataDir: cfg.DataDir,
|
|
store: cfg.Store,
|
|
policy: LoadPolicyStore(cfg.Profile, cfg.Store),
|
|
history: LoadHistory(cfg.Store),
|
|
events: cfg.Events,
|
|
offerTTL: cfg.OfferTTL,
|
|
ports: NewPortRegistry(),
|
|
sends: make(map[OfferID]*sendHandle),
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
// Profile returns the profile this manager belongs to.
|
|
func (m *Manager) Profile() profilemanager.ID {
|
|
return m.profile
|
|
}
|
|
|
|
// Policy returns the receiving policy store.
|
|
func (m *Manager) Policy() *PolicyStore {
|
|
return m.policy
|
|
}
|
|
|
|
// Ports returns the registry of peer-advertised listen ports; the engine feeds it
|
|
// from incoming signal messages.
|
|
func (m *Manager) Ports() *PortRegistry {
|
|
return m.ports
|
|
}
|
|
|
|
// ReceiverPort returns the port the receiver is actually bound to, 0 when stopped.
|
|
func (m *Manager) ReceiverPort() uint16 {
|
|
m.mu.Lock()
|
|
server := m.server
|
|
m.mu.Unlock()
|
|
if server == nil {
|
|
return 0
|
|
}
|
|
return server.BoundPort()
|
|
}
|
|
|
|
// Transfers returns the history entries, newest first, with pending offers included.
|
|
func (m *Manager) Transfers() []Transfer {
|
|
return m.history.List()
|
|
}
|
|
|
|
// DeleteTransfer removes a history entry. A live transfer is cancelled first.
|
|
func (m *Manager) DeleteTransfer(id OfferID) {
|
|
if t, ok := m.history.Get(id); ok && !t.terminal() {
|
|
m.Cancel(id)
|
|
}
|
|
m.history.Delete(id)
|
|
}
|
|
|
|
// DestinationDir returns the directory received files are delivered to.
|
|
func (m *Manager) DestinationDir() string {
|
|
return m.policy.DestinationDir()
|
|
}
|
|
|
|
// SetDestinationDir persists the delivery directory.
|
|
func (m *Manager) SetDestinationDir(dir string) error {
|
|
return m.policy.SetDestinationDir(dir)
|
|
}
|
|
|
|
// StartReceiver binds the receiving server on addr.
|
|
func (m *Manager) StartReceiver(ctx context.Context, addr netip.AddrPort, netstackNet *netstack.Net, resolver PeerResolver) error {
|
|
m.mu.Lock()
|
|
if m.server != nil {
|
|
m.mu.Unlock()
|
|
return errors.New("receiver is already running")
|
|
}
|
|
m.mu.Unlock()
|
|
|
|
server, err := NewServer(ServerConfig{
|
|
SpoolDir: filepath.Join(m.dataDir, "spool"),
|
|
Policy: m.policy,
|
|
Resolver: resolver,
|
|
Notifier: m,
|
|
OfferTTL: m.offerTTL,
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("create receiver: %w", err)
|
|
}
|
|
if netstackNet != nil {
|
|
server.SetNetstackNet(netstackNet)
|
|
}
|
|
|
|
if err := server.Start(ctx, addr); err != nil {
|
|
return fmt.Errorf("start receiver: %w", err)
|
|
}
|
|
|
|
m.mu.Lock()
|
|
m.server = server
|
|
m.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
// AddReceiverListener serves the receiver on an additional address, such as IPv6.
|
|
func (m *Manager) AddReceiverListener(ctx context.Context, addr netip.AddrPort) error {
|
|
m.mu.Lock()
|
|
server := m.server
|
|
m.mu.Unlock()
|
|
|
|
if server == nil {
|
|
return errors.New("receiver is not running")
|
|
}
|
|
return server.AddListener(ctx, addr)
|
|
}
|
|
|
|
// StopReceiver shuts the receiving server down and drops the tunnel dialer.
|
|
func (m *Manager) StopReceiver() error {
|
|
m.mu.Lock()
|
|
server := m.server
|
|
m.server = nil
|
|
m.dial = nil
|
|
m.mu.Unlock()
|
|
|
|
if server == nil {
|
|
return nil
|
|
}
|
|
return server.Stop()
|
|
}
|
|
|
|
// SetTunnel gives the manager the tunnel dialer and the local sender name.
|
|
func (m *Manager) SetTunnel(dial DialFunc, senderName string) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.dial = dial
|
|
m.senderName = senderName
|
|
}
|
|
|
|
// Close stops the receiver and aborts every outgoing transfer.
|
|
func (m *Manager) Close() error {
|
|
err := m.StopReceiver()
|
|
|
|
m.mu.Lock()
|
|
for _, h := range m.sends {
|
|
h.cancel()
|
|
}
|
|
m.mu.Unlock()
|
|
|
|
m.sendWg.Wait()
|
|
return err
|
|
}
|
|
|
|
// Send starts an asynchronous transfer and returns its local transfer ID.
|
|
func (m *Manager) Send(peer PeerKey, peerName string, addr netip.Addr, payloads []Payload) (OfferID, error) {
|
|
if len(payloads) == 0 {
|
|
return "", fmt.Errorf("%w: no payloads", ErrInvalidOffer)
|
|
}
|
|
|
|
m.mu.Lock()
|
|
dial, senderName := m.dial, m.senderName
|
|
m.mu.Unlock()
|
|
if dial == nil {
|
|
return "", ErrNotConnected
|
|
}
|
|
|
|
client, err := NewClient(ClientConfig{Dial: dial, SenderName: senderName, OfferTimeout: m.offerTTL})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
id := OfferID(uuid.NewString())
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
handle := &sendHandle{cancel: cancel, ip: addr}
|
|
|
|
m.mu.Lock()
|
|
m.sends[id] = handle
|
|
m.mu.Unlock()
|
|
|
|
transfer := Transfer{
|
|
ID: id,
|
|
Direction: DirectionSent,
|
|
PeerKey: peer,
|
|
PeerName: peerName,
|
|
Files: payloadMetas(payloads),
|
|
State: StatePending,
|
|
TotalSize: payloadTotal(payloads),
|
|
CreatedAt: time.Now(),
|
|
}
|
|
m.history.Upsert(transfer)
|
|
|
|
m.sendWg.Add(1)
|
|
go func() {
|
|
defer m.sendWg.Done()
|
|
defer cancel()
|
|
m.runSend(ctx, client, handle, transfer, payloads)
|
|
|
|
m.mu.Lock()
|
|
delete(m.sends, id)
|
|
m.mu.Unlock()
|
|
}()
|
|
|
|
return id, nil
|
|
}
|
|
|
|
// Cancel aborts a transfer in either direction.
|
|
func (m *Manager) Cancel(id OfferID) {
|
|
m.mu.Lock()
|
|
handle := m.sends[id]
|
|
server := m.server
|
|
var remoteAddr netip.AddrPort
|
|
var remoteID OfferID
|
|
if handle != nil {
|
|
remoteAddr, remoteID = handle.addr, handle.remoteID
|
|
}
|
|
m.mu.Unlock()
|
|
|
|
if handle != nil {
|
|
handle.cancel()
|
|
if remoteID != "" && remoteAddr.IsValid() {
|
|
m.withdrawRemote(remoteAddr, remoteID)
|
|
}
|
|
m.finishTransfer(id, StateCancelled, "")
|
|
return
|
|
}
|
|
|
|
if server != nil {
|
|
if offer, ok := server.Offers().Decide(id, DecisionDeclined); ok {
|
|
server.Spool().Remove(offer.ID)
|
|
}
|
|
}
|
|
m.finishTransfer(id, StateCancelled, "")
|
|
}
|
|
|
|
// Accept releases a pending incoming offer for upload.
|
|
func (m *Manager) Accept(id OfferID) error {
|
|
m.mu.Lock()
|
|
server := m.server
|
|
m.mu.Unlock()
|
|
if server == nil {
|
|
return ErrNotConnected
|
|
}
|
|
|
|
offer, ok := server.Offers().Decide(id, DecisionAccepted)
|
|
if !ok {
|
|
return ErrOfferNotFound
|
|
}
|
|
|
|
if offer.State == StateCompleted {
|
|
m.OnCompleted(offer)
|
|
return nil
|
|
}
|
|
|
|
m.history.SetProgress(id, 0)
|
|
return nil
|
|
}
|
|
|
|
// Decline refuses a pending incoming offer.
|
|
func (m *Manager) Decline(id OfferID) error {
|
|
m.mu.Lock()
|
|
server := m.server
|
|
m.mu.Unlock()
|
|
if server == nil {
|
|
return ErrNotConnected
|
|
}
|
|
|
|
offer, ok := server.Offers().Decide(id, DecisionDeclined)
|
|
if !ok {
|
|
return ErrOfferNotFound
|
|
}
|
|
|
|
server.Spool().Remove(offer.ID)
|
|
m.finishTransfer(id, StateDeclined, "")
|
|
return nil
|
|
}
|
|
|
|
// SetSenderRule records a per-sender exception.
|
|
func (m *Manager) SetSenderRule(peer PeerKey, rule SenderRule) error {
|
|
if err := m.policy.SetSenderRule(peer, rule); err != nil {
|
|
return err
|
|
}
|
|
if rule != SenderRuleBlock {
|
|
return nil
|
|
}
|
|
|
|
m.mu.Lock()
|
|
server := m.server
|
|
m.mu.Unlock()
|
|
if server == nil {
|
|
return nil
|
|
}
|
|
|
|
for _, offer := range server.Offers().List() {
|
|
if offer.Sender != peer || offer.Decision != DecisionPending {
|
|
continue
|
|
}
|
|
if declined, ok := server.Offers().Decide(offer.ID, DecisionDeclined); ok {
|
|
server.Spool().Remove(declined.ID)
|
|
m.finishTransfer(declined.ID, StateDeclined, "")
|
|
m.emit(EventWithdrawn, m.transferOf(declined.ID))
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *Manager) runSend(ctx context.Context, client *Client, handle *sendHandle, transfer Transfer, payloads []Payload) {
|
|
addr, remoteID, decision, err := m.offerWithPortRetry(ctx, client, handle, transfer.PeerKey, payloads)
|
|
if err != nil {
|
|
m.failSend(ctx, transfer.ID, err)
|
|
return
|
|
}
|
|
|
|
decision, err = client.AwaitDecision(ctx, addr, remoteID, decision)
|
|
if err != nil {
|
|
m.failSend(ctx, transfer.ID, err)
|
|
return
|
|
}
|
|
if err := decisionError(decision); err != nil {
|
|
m.failSend(ctx, transfer.ID, err)
|
|
return
|
|
}
|
|
|
|
m.history.SetProgress(transfer.ID, 0)
|
|
|
|
completed := make([]int64, len(payloads))
|
|
progress := func(index int, sent, _ int64) {
|
|
completed[index] = sent
|
|
var total int64
|
|
for _, n := range completed {
|
|
total += n
|
|
}
|
|
m.history.SetProgress(transfer.ID, total)
|
|
}
|
|
|
|
if err := client.Upload(ctx, addr, remoteID, payloads, progress); err != nil {
|
|
m.failSend(ctx, transfer.ID, err)
|
|
return
|
|
}
|
|
|
|
m.history.SetProgress(transfer.ID, transfer.TotalSize)
|
|
m.finishTransfer(transfer.ID, StateCompleted, "")
|
|
m.emit(EventCompleted, m.transferOf(transfer.ID))
|
|
}
|
|
|
|
// offerWithPortRetry places the offer on the last advertised port, falling back to
|
|
// the default. When the attempt fails on the transport, it waits out one signal
|
|
// message that may carry the receiver's actual port and retries there once. A port
|
|
// learned mid-attempt aborts the attempt immediately instead of letting it hang.
|
|
func (m *Manager) offerWithPortRetry(ctx context.Context, client *Client, handle *sendHandle, key PeerKey, payloads []Payload) (netip.AddrPort, OfferID, Decision, error) {
|
|
used := m.ports.Port(key)
|
|
addr := netip.AddrPortFrom(handle.ip, effectivePort(used))
|
|
|
|
remoteID, decision, err := m.offerWatchingPorts(ctx, client, key, used, addr, payloads)
|
|
if err == nil {
|
|
m.storeRemote(handle, addr, remoteID)
|
|
return addr, remoteID, decision, nil
|
|
}
|
|
if ctx.Err() != nil || !transportFailure(err) {
|
|
return addr, remoteID, decision, err
|
|
}
|
|
|
|
graceCtx, cancel := context.WithTimeout(ctx, portSignalGrace)
|
|
port, changed := m.ports.Await(graceCtx, key, used)
|
|
cancel()
|
|
if !changed {
|
|
return addr, remoteID, decision, err
|
|
}
|
|
|
|
addr = netip.AddrPortFrom(handle.ip, effectivePort(port))
|
|
remoteID, decision, err = client.Offer(ctx, addr, payloads)
|
|
if err != nil {
|
|
return addr, remoteID, decision, err
|
|
}
|
|
m.storeRemote(handle, addr, remoteID)
|
|
return addr, remoteID, decision, nil
|
|
}
|
|
|
|
// offerWatchingPorts runs the offer while watching for a port advertisement that
|
|
// differs from the one in use; such an advertisement aborts the in-flight attempt.
|
|
func (m *Manager) offerWatchingPorts(ctx context.Context, client *Client, key PeerKey, used uint16, addr netip.AddrPort, payloads []Payload) (OfferID, Decision, error) {
|
|
watchCtx, cancel := context.WithCancel(ctx)
|
|
defer cancel()
|
|
|
|
go func() {
|
|
if _, changed := m.ports.Await(watchCtx, key, used); changed {
|
|
cancel()
|
|
}
|
|
}()
|
|
|
|
return client.Offer(watchCtx, addr, payloads)
|
|
}
|
|
|
|
func (m *Manager) storeRemote(handle *sendHandle, addr netip.AddrPort, remoteID OfferID) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
handle.addr = addr
|
|
handle.remoteID = remoteID
|
|
}
|
|
|
|
func (m *Manager) failSend(ctx context.Context, id OfferID, err error) {
|
|
if ctx.Err() != nil {
|
|
m.finishTransfer(id, StateCancelled, "")
|
|
return
|
|
}
|
|
|
|
state := StateFailed
|
|
switch {
|
|
case errors.Is(err, ErrDeclined):
|
|
state = StateDeclined
|
|
case errors.Is(err, ErrExpired):
|
|
state = StateExpired
|
|
}
|
|
|
|
message := ""
|
|
reason := ReasonNone
|
|
if state == StateFailed {
|
|
message = err.Error()
|
|
if transportFailure(err) {
|
|
reason = ReasonUnreachable
|
|
}
|
|
}
|
|
m.finishTransferReason(id, state, message, reason)
|
|
m.emit(EventFailed, m.transferOf(id))
|
|
}
|
|
|
|
func (m *Manager) withdrawRemote(addr netip.AddrPort, remoteID OfferID) {
|
|
m.mu.Lock()
|
|
dial, senderName := m.dial, m.senderName
|
|
m.mu.Unlock()
|
|
if dial == nil {
|
|
return
|
|
}
|
|
|
|
client, err := NewClient(ClientConfig{Dial: dial, SenderName: senderName})
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
if err := client.Cancel(ctx, addr, remoteID); err != nil {
|
|
log.Debugf("failed to withdraw file drop offer: %v", err)
|
|
}
|
|
}
|
|
|
|
// OnOffer implements Notifier for the receiver server.
|
|
func (m *Manager) OnOffer(offer Offer) {
|
|
transfer := Transfer{
|
|
ID: offer.ID,
|
|
Direction: DirectionReceived,
|
|
PeerKey: offer.Sender,
|
|
PeerName: offer.SenderName,
|
|
Files: offer.Files,
|
|
State: offer.State,
|
|
TotalSize: offer.TotalSize(),
|
|
CreatedAt: offer.CreatedAt,
|
|
}
|
|
m.history.Upsert(transfer)
|
|
|
|
if offer.Decision == DecisionPending {
|
|
m.emit(EventOffer, transfer)
|
|
}
|
|
if offer.Decision == DecisionAccepted && offer.State == StateCompleted {
|
|
m.OnCompleted(offer)
|
|
}
|
|
}
|
|
|
|
// OnProgress implements Notifier.
|
|
func (m *Manager) OnProgress(offer Offer, index int, received int64) {
|
|
var total int64
|
|
for i, n := range offer.Progress {
|
|
if i == index {
|
|
n = received
|
|
}
|
|
total += n
|
|
}
|
|
m.history.SetProgress(offer.ID, total)
|
|
}
|
|
|
|
// OnCompleted implements Notifier.
|
|
func (m *Manager) OnCompleted(offer Offer) {
|
|
m.mu.Lock()
|
|
server := m.server
|
|
m.mu.Unlock()
|
|
if server == nil {
|
|
return
|
|
}
|
|
|
|
transfer, ok := m.history.Get(offer.ID)
|
|
if !ok || transfer.State == StateCompleted {
|
|
return
|
|
}
|
|
|
|
delivered, err := deliver(server.Spool(), offer, m.policy.DestinationDir())
|
|
if err != nil {
|
|
log.Errorf("failed to deliver file drop payloads: %v", err)
|
|
m.finishTransfer(offer.ID, StateFailed, err.Error())
|
|
m.emit(EventFailed, m.transferOf(offer.ID))
|
|
return
|
|
}
|
|
|
|
transfer.State = StateCompleted
|
|
transfer.Transferred = transfer.TotalSize
|
|
transfer.DeliveredPaths = delivered
|
|
transfer.Error = ""
|
|
m.history.Upsert(transfer)
|
|
m.emit(EventCompleted, transfer)
|
|
}
|
|
|
|
// OnFailed implements Notifier.
|
|
func (m *Manager) OnFailed(offer Offer, err error) {
|
|
if errors.Is(err, ErrExpired) {
|
|
m.finishTransfer(offer.ID, StateExpired, "")
|
|
m.emit(EventWithdrawn, m.transferOf(offer.ID))
|
|
return
|
|
}
|
|
m.finishTransfer(offer.ID, StateFailed, err.Error())
|
|
m.emit(EventFailed, m.transferOf(offer.ID))
|
|
}
|
|
|
|
// OnWithdrawn implements Notifier: the sender cancelled, so the consent prompt goes away.
|
|
func (m *Manager) OnWithdrawn(offer Offer) {
|
|
m.finishTransfer(offer.ID, StateCancelled, "")
|
|
m.emit(EventWithdrawn, m.transferOf(offer.ID))
|
|
}
|
|
|
|
func (m *Manager) finishTransfer(id OfferID, state State, message string) {
|
|
m.finishTransferReason(id, state, message, ReasonNone)
|
|
}
|
|
|
|
func (m *Manager) finishTransferReason(id OfferID, state State, message string, reason FailureReason) {
|
|
transfer, ok := m.history.Get(id)
|
|
if !ok || transfer.terminal() {
|
|
return
|
|
}
|
|
transfer.State = state
|
|
transfer.Error = message
|
|
transfer.Reason = reason
|
|
m.history.Upsert(transfer)
|
|
}
|
|
|
|
func (m *Manager) transferOf(id OfferID) Transfer {
|
|
t, _ := m.history.Get(id)
|
|
return t
|
|
}
|
|
|
|
func (m *Manager) emit(kind EventKind, transfer Transfer) {
|
|
if m.events != nil && transfer.ID != "" {
|
|
m.events(kind, transfer)
|
|
}
|
|
}
|
|
|
|
func payloadMetas(payloads []Payload) []FileMeta {
|
|
metas := make([]FileMeta, len(payloads))
|
|
for i, p := range payloads {
|
|
metas[i] = p.Meta
|
|
}
|
|
return metas
|
|
}
|
|
|
|
func payloadTotal(payloads []Payload) int64 {
|
|
var total int64
|
|
for _, p := range payloads {
|
|
total += p.Meta.Size
|
|
}
|
|
return total
|
|
}
|
|
|
|
func effectivePort(advertised uint16) uint16 {
|
|
if advertised == 0 {
|
|
return Port
|
|
}
|
|
return advertised
|
|
}
|
|
|
|
// transportFailure reports whether the offer never reached the receiver; any HTTP
|
|
// response, refusal included, proves the port right and is not retried elsewhere.
|
|
func transportFailure(err error) bool {
|
|
var urlErr *url.Error
|
|
return errors.As(err, &urlErr)
|
|
}
|