2024-11-10 04:11:21 +00:00
|
|
|
package route
|
|
|
|
|
|
|
|
import (
|
|
|
|
"context"
|
|
|
|
"errors"
|
2024-11-11 08:23:45 +00:00
|
|
|
"net"
|
2024-11-10 04:11:21 +00:00
|
|
|
"os"
|
|
|
|
"runtime"
|
2024-11-11 08:23:45 +00:00
|
|
|
"strings"
|
2024-11-10 04:11:21 +00:00
|
|
|
"syscall"
|
2024-11-12 11:37:10 +00:00
|
|
|
"time"
|
2024-11-10 04:11:21 +00:00
|
|
|
|
|
|
|
"github.com/sagernet/sing-box/adapter"
|
|
|
|
"github.com/sagernet/sing-box/common/conntrack"
|
|
|
|
"github.com/sagernet/sing-box/common/taskmonitor"
|
|
|
|
C "github.com/sagernet/sing-box/constant"
|
|
|
|
"github.com/sagernet/sing-box/experimental/libbox/platform"
|
|
|
|
"github.com/sagernet/sing-box/option"
|
|
|
|
"github.com/sagernet/sing-tun"
|
2024-11-11 08:23:45 +00:00
|
|
|
"github.com/sagernet/sing/common"
|
|
|
|
"github.com/sagernet/sing/common/atomic"
|
2024-11-10 04:11:21 +00:00
|
|
|
"github.com/sagernet/sing/common/control"
|
|
|
|
E "github.com/sagernet/sing/common/exceptions"
|
2024-11-11 08:23:45 +00:00
|
|
|
F "github.com/sagernet/sing/common/format"
|
2024-11-10 04:11:21 +00:00
|
|
|
"github.com/sagernet/sing/common/logger"
|
|
|
|
M "github.com/sagernet/sing/common/metadata"
|
|
|
|
"github.com/sagernet/sing/common/winpowrprof"
|
|
|
|
"github.com/sagernet/sing/service"
|
|
|
|
"github.com/sagernet/sing/service/pause"
|
2024-11-11 08:23:45 +00:00
|
|
|
|
|
|
|
"golang.org/x/exp/slices"
|
2024-11-10 04:11:21 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
var _ adapter.NetworkManager = (*NetworkManager)(nil)
|
|
|
|
|
|
|
|
type NetworkManager struct {
|
2024-11-11 08:23:45 +00:00
|
|
|
logger logger.ContextLogger
|
|
|
|
interfaceFinder *control.DefaultInterfaceFinder
|
|
|
|
networkInterfaces atomic.TypedValue[[]adapter.NetworkInterface]
|
|
|
|
|
2024-11-10 04:11:21 +00:00
|
|
|
autoDetectInterface bool
|
2024-11-12 11:37:10 +00:00
|
|
|
defaultOptions adapter.NetworkOptions
|
2024-11-10 04:11:21 +00:00
|
|
|
autoRedirectOutputMark uint32
|
2024-11-11 08:23:45 +00:00
|
|
|
|
|
|
|
networkMonitor tun.NetworkUpdateMonitor
|
|
|
|
interfaceMonitor tun.DefaultInterfaceMonitor
|
|
|
|
packageManager tun.PackageManager
|
|
|
|
powerListener winpowrprof.EventListener
|
|
|
|
pauseManager pause.Manager
|
|
|
|
platformInterface platform.Interface
|
|
|
|
outboundManager adapter.OutboundManager
|
|
|
|
wifiState adapter.WIFIState
|
|
|
|
started bool
|
2024-11-10 04:11:21 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
func NewNetworkManager(ctx context.Context, logger logger.ContextLogger, routeOptions option.RouteOptions) (*NetworkManager, error) {
|
|
|
|
nm := &NetworkManager{
|
|
|
|
logger: logger,
|
|
|
|
interfaceFinder: control.NewDefaultInterfaceFinder(),
|
|
|
|
autoDetectInterface: routeOptions.AutoDetectInterface,
|
2024-11-12 11:37:10 +00:00
|
|
|
defaultOptions: adapter.NetworkOptions{
|
|
|
|
DefaultInterface: routeOptions.DefaultInterface,
|
|
|
|
DefaultMark: routeOptions.DefaultMark,
|
|
|
|
DefaultNetworkStrategy: C.NetworkStrategy(routeOptions.DefaultNetworkStrategy),
|
|
|
|
DefaultFallbackDelay: time.Duration(routeOptions.DefaultFallbackDelay),
|
|
|
|
},
|
|
|
|
pauseManager: service.FromContext[pause.Manager](ctx),
|
|
|
|
platformInterface: service.FromContext[platform.Interface](ctx),
|
|
|
|
outboundManager: service.FromContext[adapter.OutboundManager](ctx),
|
|
|
|
}
|
|
|
|
if C.NetworkStrategy(routeOptions.DefaultNetworkStrategy) != C.NetworkStrategyDefault {
|
|
|
|
if routeOptions.DefaultInterface != "" {
|
|
|
|
return nil, E.New("`default_network_strategy` is conflict with `default_interface`")
|
|
|
|
}
|
|
|
|
if !routeOptions.AutoDetectInterface {
|
|
|
|
return nil, E.New("`auto_detect_interface` is required by `default_network_strategy`")
|
|
|
|
}
|
2024-11-10 04:11:21 +00:00
|
|
|
}
|
2024-11-11 08:23:45 +00:00
|
|
|
usePlatformDefaultInterfaceMonitor := nm.platformInterface != nil
|
2024-11-10 04:11:21 +00:00
|
|
|
enforceInterfaceMonitor := routeOptions.AutoDetectInterface
|
|
|
|
if !usePlatformDefaultInterfaceMonitor {
|
|
|
|
networkMonitor, err := tun.NewNetworkUpdateMonitor(logger)
|
|
|
|
if !((err != nil && !enforceInterfaceMonitor) || errors.Is(err, os.ErrInvalid)) {
|
|
|
|
if err != nil {
|
|
|
|
return nil, E.Cause(err, "create network monitor")
|
|
|
|
}
|
|
|
|
nm.networkMonitor = networkMonitor
|
|
|
|
networkMonitor.RegisterCallback(func() {
|
|
|
|
_ = nm.interfaceFinder.Update()
|
|
|
|
})
|
|
|
|
interfaceMonitor, err := tun.NewDefaultInterfaceMonitor(nm.networkMonitor, logger, tun.DefaultInterfaceMonitorOptions{
|
|
|
|
InterfaceFinder: nm.interfaceFinder,
|
|
|
|
OverrideAndroidVPN: routeOptions.OverrideAndroidVPN,
|
|
|
|
UnderNetworkExtension: nm.platformInterface != nil && nm.platformInterface.UnderNetworkExtension(),
|
|
|
|
})
|
|
|
|
if err != nil {
|
|
|
|
return nil, E.New("auto_detect_interface unsupported on current platform")
|
|
|
|
}
|
2024-11-12 11:37:10 +00:00
|
|
|
interfaceMonitor.RegisterCallback(nm.notifyInterfaceUpdate)
|
2024-11-10 04:11:21 +00:00
|
|
|
nm.interfaceMonitor = interfaceMonitor
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
interfaceMonitor := nm.platformInterface.CreateDefaultInterfaceMonitor(logger)
|
2024-11-12 11:37:10 +00:00
|
|
|
interfaceMonitor.RegisterCallback(nm.notifyInterfaceUpdate)
|
2024-11-10 04:11:21 +00:00
|
|
|
nm.interfaceMonitor = interfaceMonitor
|
|
|
|
}
|
|
|
|
return nm, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (r *NetworkManager) Start(stage adapter.StartStage) error {
|
|
|
|
monitor := taskmonitor.New(r.logger, C.StartTimeout)
|
|
|
|
switch stage {
|
|
|
|
case adapter.StartStateInitialize:
|
2024-11-11 08:23:45 +00:00
|
|
|
if r.networkMonitor != nil {
|
|
|
|
monitor.Start("initialize network monitor")
|
|
|
|
err := r.networkMonitor.Start()
|
2024-11-10 04:11:21 +00:00
|
|
|
monitor.Finish()
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
}
|
2024-11-11 08:23:45 +00:00
|
|
|
if r.interfaceMonitor != nil {
|
|
|
|
monitor.Start("initialize interface monitor")
|
|
|
|
err := r.interfaceMonitor.Start()
|
2024-11-10 04:11:21 +00:00
|
|
|
monitor.Finish()
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
}
|
|
|
|
case adapter.StartStateStart:
|
|
|
|
if runtime.GOOS == "windows" {
|
|
|
|
powerListener, err := winpowrprof.NewEventListener(r.notifyWindowsPowerEvent)
|
|
|
|
if err == nil {
|
|
|
|
r.powerListener = powerListener
|
|
|
|
} else {
|
|
|
|
r.logger.Warn("initialize power listener: ", err)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if r.powerListener != nil {
|
|
|
|
monitor.Start("start power listener")
|
|
|
|
err := r.powerListener.Start()
|
|
|
|
monitor.Finish()
|
|
|
|
if err != nil {
|
|
|
|
return E.Cause(err, "start power listener")
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if C.IsAndroid && r.platformInterface == nil {
|
|
|
|
monitor.Start("initialize package manager")
|
|
|
|
packageManager, err := tun.NewPackageManager(tun.PackageManagerOptions{
|
|
|
|
Callback: r,
|
|
|
|
Logger: r.logger,
|
|
|
|
})
|
|
|
|
monitor.Finish()
|
|
|
|
if err != nil {
|
|
|
|
return E.Cause(err, "create package manager")
|
|
|
|
}
|
|
|
|
monitor.Start("start package manager")
|
|
|
|
err = packageManager.Start()
|
|
|
|
monitor.Finish()
|
|
|
|
if err != nil {
|
|
|
|
r.logger.Warn("initialize package manager: ", err)
|
|
|
|
} else {
|
|
|
|
r.packageManager = packageManager
|
|
|
|
}
|
|
|
|
}
|
|
|
|
case adapter.StartStatePostStart:
|
|
|
|
r.started = true
|
|
|
|
}
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (r *NetworkManager) Close() error {
|
|
|
|
monitor := taskmonitor.New(r.logger, C.StopTimeout)
|
|
|
|
var err error
|
|
|
|
if r.packageManager != nil {
|
|
|
|
monitor.Start("close package manager")
|
|
|
|
err = E.Append(err, r.packageManager.Close(), func(err error) error {
|
|
|
|
return E.Cause(err, "close package manager")
|
|
|
|
})
|
|
|
|
monitor.Finish()
|
|
|
|
}
|
|
|
|
if r.powerListener != nil {
|
|
|
|
monitor.Start("close power listener")
|
|
|
|
err = E.Append(err, r.powerListener.Close(), func(err error) error {
|
|
|
|
return E.Cause(err, "close power listener")
|
|
|
|
})
|
|
|
|
monitor.Finish()
|
|
|
|
}
|
2024-11-11 08:23:45 +00:00
|
|
|
if r.interfaceMonitor != nil {
|
|
|
|
monitor.Start("close interface monitor")
|
|
|
|
err = E.Append(err, r.interfaceMonitor.Close(), func(err error) error {
|
|
|
|
return E.Cause(err, "close interface monitor")
|
|
|
|
})
|
|
|
|
monitor.Finish()
|
|
|
|
}
|
|
|
|
if r.networkMonitor != nil {
|
|
|
|
monitor.Start("close network monitor")
|
|
|
|
err = E.Append(err, r.networkMonitor.Close(), func(err error) error {
|
|
|
|
return E.Cause(err, "close network monitor")
|
|
|
|
})
|
|
|
|
monitor.Finish()
|
|
|
|
}
|
2024-11-10 04:11:21 +00:00
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (r *NetworkManager) InterfaceFinder() control.InterfaceFinder {
|
|
|
|
return r.interfaceFinder
|
|
|
|
}
|
|
|
|
|
|
|
|
func (r *NetworkManager) UpdateInterfaces() error {
|
2024-11-11 08:23:45 +00:00
|
|
|
if r.platformInterface == nil {
|
2024-11-10 04:11:21 +00:00
|
|
|
return r.interfaceFinder.Update()
|
|
|
|
} else {
|
|
|
|
interfaces, err := r.platformInterface.Interfaces()
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
2024-11-11 08:23:45 +00:00
|
|
|
if C.IsDarwin {
|
|
|
|
err = r.interfaceFinder.Update()
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
// NEInterface only provides name,index and type
|
|
|
|
interfaces = common.Map(interfaces, func(it adapter.NetworkInterface) adapter.NetworkInterface {
|
|
|
|
iif, _ := r.interfaceFinder.ByIndex(it.Index)
|
|
|
|
if iif != nil {
|
|
|
|
it.Interface = *iif
|
|
|
|
}
|
|
|
|
return it
|
|
|
|
})
|
|
|
|
} else {
|
|
|
|
r.interfaceFinder.UpdateInterfaces(common.Map(interfaces, func(it adapter.NetworkInterface) control.Interface { return it.Interface }))
|
|
|
|
}
|
|
|
|
oldInterfaces := r.networkInterfaces.Load()
|
|
|
|
newInterfaces := common.Filter(interfaces, func(it adapter.NetworkInterface) bool {
|
|
|
|
return it.Flags&net.FlagUp != 0
|
|
|
|
})
|
|
|
|
r.networkInterfaces.Store(newInterfaces)
|
|
|
|
if !slices.EqualFunc(oldInterfaces, newInterfaces, func(oldInterface adapter.NetworkInterface, newInterface adapter.NetworkInterface) bool {
|
|
|
|
return oldInterface.Interface.Index == newInterface.Interface.Index &&
|
|
|
|
oldInterface.Interface.Name == newInterface.Interface.Name &&
|
|
|
|
oldInterface.Interface.Flags == newInterface.Interface.Flags &&
|
|
|
|
oldInterface.Type == newInterface.Type &&
|
|
|
|
oldInterface.Expensive == newInterface.Expensive &&
|
|
|
|
oldInterface.Constrained == newInterface.Constrained
|
|
|
|
}) {
|
|
|
|
r.logger.Info("updated available networks: ", strings.Join(common.Map(newInterfaces, func(it adapter.NetworkInterface) string {
|
|
|
|
var options []string
|
|
|
|
options = append(options, F.ToString(it.Type))
|
|
|
|
if it.Expensive {
|
|
|
|
options = append(options, "expensive")
|
|
|
|
}
|
|
|
|
if it.Constrained {
|
|
|
|
options = append(options, "constrained")
|
|
|
|
}
|
|
|
|
return F.ToString(it.Name, " (", strings.Join(options, ", "), ")")
|
|
|
|
}), ", "))
|
|
|
|
}
|
2024-11-10 04:11:21 +00:00
|
|
|
return nil
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-11-11 08:23:45 +00:00
|
|
|
func (r *NetworkManager) DefaultNetworkInterface() *adapter.NetworkInterface {
|
|
|
|
iif := r.interfaceMonitor.DefaultInterface()
|
|
|
|
if iif == nil {
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
for _, it := range r.networkInterfaces.Load() {
|
|
|
|
if it.Interface.Index == iif.Index {
|
|
|
|
return &it
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return &adapter.NetworkInterface{Interface: *iif}
|
|
|
|
}
|
|
|
|
|
|
|
|
func (r *NetworkManager) NetworkInterfaces() []adapter.NetworkInterface {
|
|
|
|
return r.networkInterfaces.Load()
|
|
|
|
}
|
|
|
|
|
2024-11-10 04:11:21 +00:00
|
|
|
func (r *NetworkManager) AutoDetectInterface() bool {
|
|
|
|
return r.autoDetectInterface
|
|
|
|
}
|
|
|
|
|
|
|
|
func (r *NetworkManager) AutoDetectInterfaceFunc() control.Func {
|
|
|
|
if r.platformInterface != nil && r.platformInterface.UsePlatformAutoDetectInterfaceControl() {
|
|
|
|
return func(network, address string, conn syscall.RawConn) error {
|
|
|
|
return control.Raw(conn, func(fd uintptr) error {
|
|
|
|
return r.platformInterface.AutoDetectInterfaceControl(int(fd))
|
|
|
|
})
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
if r.interfaceMonitor == nil {
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
return control.BindToInterfaceFunc(r.interfaceFinder, func(network string, address string) (interfaceName string, interfaceIndex int, err error) {
|
|
|
|
remoteAddr := M.ParseSocksaddr(address).Addr
|
2024-11-11 08:23:45 +00:00
|
|
|
if remoteAddr.IsValid() {
|
|
|
|
iif, err := r.interfaceFinder.ByAddr(remoteAddr)
|
|
|
|
if err == nil {
|
|
|
|
return iif.Name, iif.Index, nil
|
2024-11-10 04:11:21 +00:00
|
|
|
}
|
|
|
|
}
|
2024-11-11 08:23:45 +00:00
|
|
|
defaultInterface := r.interfaceMonitor.DefaultInterface()
|
|
|
|
if defaultInterface == nil {
|
|
|
|
return "", -1, tun.ErrNoRoute
|
|
|
|
}
|
|
|
|
return defaultInterface.Name, defaultInterface.Index, nil
|
2024-11-10 04:11:21 +00:00
|
|
|
})
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-11-12 11:37:10 +00:00
|
|
|
func (r *NetworkManager) ProtectFunc() control.Func {
|
|
|
|
if r.platformInterface != nil && r.platformInterface.UsePlatformAutoDetectInterfaceControl() {
|
|
|
|
return func(network, address string, conn syscall.RawConn) error {
|
|
|
|
return control.Raw(conn, func(fd uintptr) error {
|
|
|
|
return r.platformInterface.AutoDetectInterfaceControl(int(fd))
|
|
|
|
})
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (r *NetworkManager) DefaultOptions() adapter.NetworkOptions {
|
|
|
|
return r.defaultOptions
|
2024-11-10 04:11:21 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
func (r *NetworkManager) RegisterAutoRedirectOutputMark(mark uint32) error {
|
|
|
|
if r.autoRedirectOutputMark > 0 {
|
|
|
|
return E.New("only one auto-redirect can be configured")
|
|
|
|
}
|
|
|
|
r.autoRedirectOutputMark = mark
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (r *NetworkManager) AutoRedirectOutputMark() uint32 {
|
|
|
|
return r.autoRedirectOutputMark
|
|
|
|
}
|
|
|
|
|
|
|
|
func (r *NetworkManager) NetworkMonitor() tun.NetworkUpdateMonitor {
|
|
|
|
return r.networkMonitor
|
|
|
|
}
|
|
|
|
|
|
|
|
func (r *NetworkManager) InterfaceMonitor() tun.DefaultInterfaceMonitor {
|
|
|
|
return r.interfaceMonitor
|
|
|
|
}
|
|
|
|
|
|
|
|
func (r *NetworkManager) PackageManager() tun.PackageManager {
|
|
|
|
return r.packageManager
|
|
|
|
}
|
|
|
|
|
|
|
|
func (r *NetworkManager) WIFIState() adapter.WIFIState {
|
|
|
|
return r.wifiState
|
|
|
|
}
|
|
|
|
|
|
|
|
func (r *NetworkManager) ResetNetwork() {
|
|
|
|
conntrack.Close()
|
|
|
|
|
|
|
|
for _, outbound := range r.outboundManager.Outbounds() {
|
|
|
|
listener, isListener := outbound.(adapter.InterfaceUpdateListener)
|
|
|
|
if isListener {
|
|
|
|
listener.InterfaceUpdated()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-11-12 11:37:10 +00:00
|
|
|
func (r *NetworkManager) notifyInterfaceUpdate(defaultInterface *control.Interface, flags int) {
|
|
|
|
if defaultInterface == nil {
|
2024-11-10 04:11:21 +00:00
|
|
|
r.pauseManager.NetworkPause()
|
|
|
|
r.logger.Error("missing default interface")
|
2024-11-12 11:37:10 +00:00
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
r.pauseManager.NetworkWake()
|
|
|
|
var options []string
|
|
|
|
options = append(options, F.ToString("index ", defaultInterface.Index))
|
|
|
|
if C.IsAndroid && r.platformInterface == nil {
|
|
|
|
var vpnStatus string
|
|
|
|
if r.interfaceMonitor.AndroidVPNEnabled() {
|
|
|
|
vpnStatus = "enabled"
|
2024-11-10 04:11:21 +00:00
|
|
|
} else {
|
2024-11-12 11:37:10 +00:00
|
|
|
vpnStatus = "disabled"
|
2024-11-10 04:11:21 +00:00
|
|
|
}
|
2024-11-12 11:37:10 +00:00
|
|
|
options = append(options, "vpn "+vpnStatus)
|
|
|
|
} else if r.platformInterface != nil {
|
|
|
|
networkInterface := common.Find(r.networkInterfaces.Load(), func(it adapter.NetworkInterface) bool {
|
|
|
|
return it.Interface.Index == defaultInterface.Index
|
|
|
|
})
|
|
|
|
if networkInterface.Type == "" {
|
|
|
|
// race
|
|
|
|
return
|
|
|
|
}
|
|
|
|
options = append(options, F.ToString("type ", networkInterface.Type))
|
|
|
|
if networkInterface.Expensive {
|
|
|
|
options = append(options, "expensive")
|
|
|
|
}
|
|
|
|
if networkInterface.Constrained {
|
|
|
|
options = append(options, "constrained")
|
|
|
|
}
|
|
|
|
}
|
|
|
|
r.logger.Info("updated default interface ", defaultInterface.Name, ", ", strings.Join(options, ", "))
|
|
|
|
if r.platformInterface != nil {
|
|
|
|
state := r.platformInterface.ReadWIFIState()
|
|
|
|
if state != r.wifiState {
|
|
|
|
r.wifiState = state
|
|
|
|
if state.SSID != "" {
|
|
|
|
r.logger.Info("updated WIFI state: SSID=", state.SSID, ", BSSID=", state.BSSID)
|
2024-11-10 04:11:21 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if !r.started {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
r.ResetNetwork()
|
|
|
|
}
|
|
|
|
|
|
|
|
func (r *NetworkManager) notifyWindowsPowerEvent(event int) {
|
|
|
|
switch event {
|
|
|
|
case winpowrprof.EVENT_SUSPEND:
|
|
|
|
r.pauseManager.DevicePause()
|
|
|
|
r.ResetNetwork()
|
|
|
|
case winpowrprof.EVENT_RESUME:
|
|
|
|
if !r.pauseManager.IsDevicePaused() {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
fallthrough
|
|
|
|
case winpowrprof.EVENT_RESUME_AUTOMATIC:
|
|
|
|
r.pauseManager.DeviceWake()
|
|
|
|
r.ResetNetwork()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func (r *NetworkManager) OnPackagesUpdated(packages int, sharedUsers int) {
|
|
|
|
r.logger.Info("updated packages list: ", packages, " packages, ", sharedUsers, " shared users")
|
|
|
|
}
|