Zone name slices -> sets
This commit is contained in:
parent
5f3bfe2f76
commit
10d4610e0d
9 changed files with 124 additions and 56 deletions
23
main.go
23
main.go
|
@ -80,27 +80,6 @@ const routingTableTemplate = `
|
||||||
</table>
|
</table>
|
||||||
`
|
`
|
||||||
|
|
||||||
const zoneTableTemplate = `
|
|
||||||
<table>
|
|
||||||
<thead><tr>
|
|
||||||
<th>Network</th>
|
|
||||||
<th>Name</th>
|
|
||||||
<th>Local Port</th>
|
|
||||||
<th>Last seen</th>
|
|
||||||
</tr></thead>
|
|
||||||
<tbody>
|
|
||||||
{{range $zone := . }}
|
|
||||||
<tr>
|
|
||||||
<td>{{$zone.Network}}</td>
|
|
||||||
<td>{{$zone.Name}}</td>
|
|
||||||
<td>{{with $zone.LocalPort}}{{.Device}}{{else}}-{{end}}</td>
|
|
||||||
<td>{{$zone.LastSeenAgo}}</td>
|
|
||||||
</tr>
|
|
||||||
{{end}}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
`
|
|
||||||
|
|
||||||
const peerTableTemplate = `
|
const peerTableTemplate = `
|
||||||
<table>
|
<table>
|
||||||
<thead><tr>
|
<thead><tr>
|
||||||
|
@ -353,7 +332,7 @@ func main() {
|
||||||
NetStart: cfg.EtherTalk.NetStart,
|
NetStart: cfg.EtherTalk.NetStart,
|
||||||
NetEnd: cfg.EtherTalk.NetEnd,
|
NetEnd: cfg.EtherTalk.NetEnd,
|
||||||
DefaultZoneName: cfg.EtherTalk.ZoneName,
|
DefaultZoneName: cfg.EtherTalk.ZoneName,
|
||||||
AvailableZones: []string{cfg.EtherTalk.ZoneName},
|
AvailableZones: router.SetFromSlice([]string{cfg.EtherTalk.ZoneName}),
|
||||||
PcapHandle: pcapHandle,
|
PcapHandle: pcapHandle,
|
||||||
AARPMachine: aarpMachine,
|
AARPMachine: aarpMachine,
|
||||||
Router: rooter,
|
Router: rooter,
|
||||||
|
|
52
router/misc.go
Normal file
52
router/misc.go
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
/*
|
||||||
|
Copyright 2024 Josh Deprez
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package router
|
||||||
|
|
||||||
|
// StringSet is a set of strings.
|
||||||
|
// Yep, yet another string set implementation. Took me 2 minutes to write *shrug*
|
||||||
|
type StringSet map[string]struct{}
|
||||||
|
|
||||||
|
func (set StringSet) ToSlice() []string {
|
||||||
|
ss := make([]string, 0, len(set))
|
||||||
|
for s := range set {
|
||||||
|
ss = append(ss, s)
|
||||||
|
}
|
||||||
|
return ss
|
||||||
|
}
|
||||||
|
|
||||||
|
func (set StringSet) Contains(s string) bool {
|
||||||
|
_, c := set[s]
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func (set StringSet) Insert(ss ...string) {
|
||||||
|
for _, s := range ss {
|
||||||
|
set[s] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (set StringSet) Add(t StringSet) {
|
||||||
|
for s := range t {
|
||||||
|
set[s] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetFromSlice(ss []string) StringSet {
|
||||||
|
set := make(StringSet, len(ss))
|
||||||
|
set.Insert(ss...)
|
||||||
|
return set
|
||||||
|
}
|
|
@ -20,7 +20,6 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"slices"
|
|
||||||
|
|
||||||
"gitea.drjosh.dev/josh/jrouter/atalk"
|
"gitea.drjosh.dev/josh/jrouter/atalk"
|
||||||
"gitea.drjosh.dev/josh/jrouter/atalk/nbp"
|
"gitea.drjosh.dev/josh/jrouter/atalk/nbp"
|
||||||
|
@ -170,7 +169,7 @@ func (rtr *Router) handleNBPFwdReq(ctx context.Context, ddpkt *ddp.ExtPacket, nb
|
||||||
tuple := &nbpkt.Tuples[0]
|
tuple := &nbpkt.Tuples[0]
|
||||||
|
|
||||||
for _, outPort := range rtr.Ports {
|
for _, outPort := range rtr.Ports {
|
||||||
if !slices.Contains(outPort.AvailableZones, tuple.Zone) {
|
if !outPort.AvailableZones.Contains(tuple.Zone) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
log.Printf("NBP: Converting FwdReq to LkUp (%v)", tuple)
|
log.Printf("NBP: Converting FwdReq to LkUp (%v)", tuple)
|
||||||
|
|
|
@ -618,7 +618,7 @@ func (p *AURPPeer) Handle(ctx context.Context) error {
|
||||||
case *aurp.ZIRspPacket:
|
case *aurp.ZIRspPacket:
|
||||||
log.Printf("AURP Peer: Learned about these zones: %v", pkt.Zones)
|
log.Printf("AURP Peer: Learned about these zones: %v", pkt.Zones)
|
||||||
for _, zt := range pkt.Zones {
|
for _, zt := range pkt.Zones {
|
||||||
p.RouteTable.AddZoneToNetwork(zt.Network, zt.Name)
|
p.RouteTable.AddZonesToNetwork(zt.Network, zt.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
case *aurp.GDZLReqPacket:
|
case *aurp.GDZLReqPacket:
|
||||||
|
|
|
@ -37,7 +37,7 @@ type EtherTalkPort struct {
|
||||||
NetEnd ddp.Network
|
NetEnd ddp.Network
|
||||||
MyAddr ddp.Addr
|
MyAddr ddp.Addr
|
||||||
DefaultZoneName string
|
DefaultZoneName string
|
||||||
AvailableZones []string
|
AvailableZones StringSet
|
||||||
PcapHandle *pcap.Handle
|
PcapHandle *pcap.Handle
|
||||||
AARPMachine *AARPMachine
|
AARPMachine *AARPMachine
|
||||||
Router *Router
|
Router *Router
|
||||||
|
|
|
@ -36,7 +36,7 @@ type Route struct {
|
||||||
|
|
||||||
// ZoneNames may be empty between learning the existence of a route and
|
// ZoneNames may be empty between learning the existence of a route and
|
||||||
// receiving zone information.
|
// receiving zone information.
|
||||||
ZoneNames []string
|
ZoneNames StringSet
|
||||||
|
|
||||||
// Exactly one of the following should be set
|
// Exactly one of the following should be set
|
||||||
AURPPeer *AURPPeer // Next hop is this peer router (over AURP)
|
AURPPeer *AURPPeer // Next hop is this peer router (over AURP)
|
||||||
|
@ -51,8 +51,11 @@ func (r Route) LastSeenAgo() string {
|
||||||
return fmt.Sprintf("%v ago", time.Since(r.LastSeen).Truncate(time.Millisecond))
|
return fmt.Sprintf("%v ago", time.Since(r.LastSeen).Truncate(time.Millisecond))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Valid reports whether the route is valid.
|
||||||
|
// A valid route has one or more zone names, and if it is learned from a peer
|
||||||
|
// router over EtherTalk is not too old.
|
||||||
func (r *Route) Valid() bool {
|
func (r *Route) Valid() bool {
|
||||||
return r.EtherTalkPeer == nil || time.Since(r.LastSeen) <= maxRouteAge
|
return len(r.ZoneNames) > 0 && (r.EtherTalkPeer == nil || time.Since(r.LastSeen) <= maxRouteAge)
|
||||||
}
|
}
|
||||||
|
|
||||||
type RouteTable struct {
|
type RouteTable struct {
|
||||||
|
@ -150,12 +153,12 @@ func (rt *RouteTable) UpdateAURPRouteDistance(peer *AURPPeer, network ddp.Networ
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rt *RouteTable) UpsertEtherTalkRoute(peer *EtherTalkPeer, extended bool, netStart, netEnd ddp.Network, metric uint8) error {
|
func (rt *RouteTable) UpsertEtherTalkRoute(peer *EtherTalkPeer, extended bool, netStart, netEnd ddp.Network, metric uint8) (*Route, error) {
|
||||||
if netStart > netEnd {
|
if netStart > netEnd {
|
||||||
return fmt.Errorf("invalid network range [%d, %d]", netStart, netEnd)
|
return nil, fmt.Errorf("invalid network range [%d, %d]", netStart, netEnd)
|
||||||
}
|
}
|
||||||
if netStart != netEnd && !extended {
|
if netStart != netEnd && !extended {
|
||||||
return fmt.Errorf("invalid network range [%d, %d] for nonextended network", netStart, netEnd)
|
return nil, fmt.Errorf("invalid network range [%d, %d] for nonextended network", netStart, netEnd)
|
||||||
}
|
}
|
||||||
|
|
||||||
rt.mu.Lock()
|
rt.mu.Lock()
|
||||||
|
@ -177,7 +180,7 @@ func (rt *RouteTable) UpsertEtherTalkRoute(peer *EtherTalkPeer, extended bool, n
|
||||||
}
|
}
|
||||||
r.Distance = metric
|
r.Distance = metric
|
||||||
r.LastSeen = time.Now()
|
r.LastSeen = time.Now()
|
||||||
return nil
|
return r, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insert.
|
// Insert.
|
||||||
|
@ -190,7 +193,7 @@ func (rt *RouteTable) UpsertEtherTalkRoute(peer *EtherTalkPeer, extended bool, n
|
||||||
EtherTalkPeer: peer,
|
EtherTalkPeer: peer,
|
||||||
}
|
}
|
||||||
rt.routes[r] = struct{}{}
|
rt.routes[r] = struct{}{}
|
||||||
return nil
|
return r, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rt *RouteTable) InsertAURPRoute(peer *AURPPeer, extended bool, netStart, netEnd ddp.Network, metric uint8) error {
|
func (rt *RouteTable) InsertAURPRoute(peer *AURPPeer, extended bool, netStart, netEnd ddp.Network, metric uint8) error {
|
||||||
|
@ -216,6 +219,7 @@ func (rt *RouteTable) InsertAURPRoute(peer *AURPPeer, extended bool, netStart, n
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ValidRoutes returns all valid routes.
|
||||||
func (rt *RouteTable) ValidRoutes() []*Route {
|
func (rt *RouteTable) ValidRoutes() []*Route {
|
||||||
rt.mu.Lock()
|
rt.mu.Lock()
|
||||||
defer rt.mu.Unlock()
|
defer rt.mu.Unlock()
|
||||||
|
@ -228,6 +232,7 @@ func (rt *RouteTable) ValidRoutes() []*Route {
|
||||||
return valid
|
return valid
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ValidNonAURPRoutes returns all valid routes that were not learned via AURP.
|
||||||
func (rt *RouteTable) ValidNonAURPRoutes() []*Route {
|
func (rt *RouteTable) ValidNonAURPRoutes() []*Route {
|
||||||
rt.mu.Lock()
|
rt.mu.Lock()
|
||||||
defer rt.mu.Unlock()
|
defer rt.mu.Unlock()
|
||||||
|
|
|
@ -24,6 +24,7 @@ import (
|
||||||
|
|
||||||
"gitea.drjosh.dev/josh/jrouter/atalk"
|
"gitea.drjosh.dev/josh/jrouter/atalk"
|
||||||
"gitea.drjosh.dev/josh/jrouter/atalk/rtmp"
|
"gitea.drjosh.dev/josh/jrouter/atalk/rtmp"
|
||||||
|
"gitea.drjosh.dev/josh/jrouter/atalk/zip"
|
||||||
"gitea.drjosh.dev/josh/jrouter/status"
|
"gitea.drjosh.dev/josh/jrouter/status"
|
||||||
|
|
||||||
"github.com/sfiera/multitalk/pkg/ddp"
|
"github.com/sfiera/multitalk/pkg/ddp"
|
||||||
|
@ -110,18 +111,45 @@ func (port *EtherTalkPort) HandleRTMP(ctx context.Context, pkt *ddp.ExtPacket) e
|
||||||
log.Print("RTMP: Got Response or Data")
|
log.Print("RTMP: Got Response or Data")
|
||||||
dataPkt, err := rtmp.UnmarshalDataPacket(pkt.Data)
|
dataPkt, err := rtmp.UnmarshalDataPacket(pkt.Data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("RTMP: Couldn't unmarshal RTMP Data packet: %v", err)
|
return fmt.Errorf("unmarshal RTMP Data packet: %w", err)
|
||||||
break
|
|
||||||
}
|
}
|
||||||
peer := &EtherTalkPeer{
|
peer := &EtherTalkPeer{
|
||||||
Port: port,
|
Port: port,
|
||||||
PeerAddr: dataPkt.RouterAddr,
|
PeerAddr: dataPkt.RouterAddr,
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, rt := range dataPkt.NetworkTuples {
|
var noZones []ddp.Network
|
||||||
if err := port.Router.RouteTable.UpsertEtherTalkRoute(peer, rt.Extended, rt.RangeStart, rt.RangeEnd, rt.Distance+1); err != nil {
|
for _, nt := range dataPkt.NetworkTuples {
|
||||||
|
route, err := port.Router.RouteTable.UpsertEtherTalkRoute(peer, nt.Extended, nt.RangeStart, nt.RangeEnd, nt.Distance+1)
|
||||||
|
if err != nil {
|
||||||
log.Printf("RTMP: Couldn't upsert EtherTalk route: %v", err)
|
log.Printf("RTMP: Couldn't upsert EtherTalk route: %v", err)
|
||||||
}
|
}
|
||||||
|
if len(route.ZoneNames) == 0 {
|
||||||
|
noZones = append(noZones, route.NetStart)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(noZones) > 0 {
|
||||||
|
// Send a ZIP Query for all networks we don't have zone names for.
|
||||||
|
// TODO: split networks to fit in multiple packets as needed
|
||||||
|
qryPkt, err := (&zip.QueryPacket{Networks: noZones}).Marshal()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal ZIP Query packet: %w", err)
|
||||||
|
}
|
||||||
|
outDDP := &ddp.ExtPacket{
|
||||||
|
ExtHeader: ddp.ExtHeader{
|
||||||
|
Size: uint16(len(qryPkt)) + atalk.DDPExtHeaderSize,
|
||||||
|
Cksum: 0,
|
||||||
|
SrcNet: port.MyAddr.Network,
|
||||||
|
SrcNode: port.MyAddr.Node,
|
||||||
|
SrcSocket: 6,
|
||||||
|
DstNet: pkt.SrcNet,
|
||||||
|
DstNode: pkt.SrcNode,
|
||||||
|
DstSocket: 6, // ZIP socket
|
||||||
|
Proto: ddp.ProtoZIP,
|
||||||
|
},
|
||||||
|
Data: qryPkt,
|
||||||
|
}
|
||||||
|
port.Send(ctx, outDDP)
|
||||||
}
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
|
|
|
@ -20,7 +20,6 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"slices"
|
|
||||||
|
|
||||||
"gitea.drjosh.dev/josh/jrouter/atalk"
|
"gitea.drjosh.dev/josh/jrouter/atalk"
|
||||||
"gitea.drjosh.dev/josh/jrouter/atalk/atp"
|
"gitea.drjosh.dev/josh/jrouter/atalk/atp"
|
||||||
|
@ -52,6 +51,9 @@ func (port *EtherTalkPort) handleZIPZIP(ctx context.Context, ddpkt *ddp.ExtPacke
|
||||||
case *zip.QueryPacket:
|
case *zip.QueryPacket:
|
||||||
return port.handleZIPQuery(ctx, ddpkt, zipkt)
|
return port.handleZIPQuery(ctx, ddpkt, zipkt)
|
||||||
|
|
||||||
|
case *zip.ReplyPacket:
|
||||||
|
return port.handleZIPReply(ctx, zipkt)
|
||||||
|
|
||||||
case *zip.GetNetInfoPacket:
|
case *zip.GetNetInfoPacket:
|
||||||
return port.handleZIPGetNetInfo(ctx, ddpkt, zipkt)
|
return port.handleZIPGetNetInfo(ctx, ddpkt, zipkt)
|
||||||
|
|
||||||
|
@ -156,11 +158,21 @@ func (port *EtherTalkPort) handleZIPQuery(ctx context.Context, ddpkt *ddp.ExtPac
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (port *EtherTalkPort) handleZIPReply(ctx context.Context, zipkt *zip.ReplyPacket) error {
|
||||||
|
log.Printf("ZIP: Got Reply for networks %v", zipkt.Networks)
|
||||||
|
|
||||||
|
// Integrate new zone information into route table.
|
||||||
|
for n, zs := range zipkt.Networks {
|
||||||
|
port.Router.RouteTable.AddZonesToNetwork(n, zs...)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (port *EtherTalkPort) handleZIPGetNetInfo(ctx context.Context, ddpkt *ddp.ExtPacket, zipkt *zip.GetNetInfoPacket) error {
|
func (port *EtherTalkPort) handleZIPGetNetInfo(ctx context.Context, ddpkt *ddp.ExtPacket, zipkt *zip.GetNetInfoPacket) error {
|
||||||
log.Printf("ZIP: Got GetNetInfo for zone %q", zipkt.ZoneName)
|
log.Printf("ZIP: Got GetNetInfo for zone %q", zipkt.ZoneName)
|
||||||
|
|
||||||
// The request is zoneValid if the zone name is available on this network.
|
// The request is zoneValid if the zone name is available on this network.
|
||||||
zoneValid := slices.Contains(port.AvailableZones, zipkt.ZoneName)
|
zoneValid := port.AvailableZones.Contains(zipkt.ZoneName)
|
||||||
|
|
||||||
// The multicast address we return depends on the validity of the zone
|
// The multicast address we return depends on the validity of the zone
|
||||||
// name.
|
// name.
|
||||||
|
@ -261,7 +273,7 @@ func (port *EtherTalkPort) handleZIPTReq(ctx context.Context, ddpkt *ddp.ExtPack
|
||||||
resp.Zones = port.Router.RouteTable.AllZoneNames()
|
resp.Zones = port.Router.RouteTable.AllZoneNames()
|
||||||
|
|
||||||
case zip.FunctionGetLocalZones:
|
case zip.FunctionGetLocalZones:
|
||||||
resp.Zones = port.AvailableZones
|
resp.Zones = port.AvailableZones.ToSlice()
|
||||||
|
|
||||||
case zip.FunctionGetMyZone:
|
case zip.FunctionGetMyZone:
|
||||||
// Note: This shouldn't happen on extended networks (e.g. EtherTalk)
|
// Note: This shouldn't happen on extended networks (e.g. EtherTalk)
|
||||||
|
|
|
@ -22,7 +22,7 @@ import (
|
||||||
"github.com/sfiera/multitalk/pkg/ddp"
|
"github.com/sfiera/multitalk/pkg/ddp"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (rt *RouteTable) AddZoneToNetwork(n ddp.Network, z string) {
|
func (rt *RouteTable) AddZonesToNetwork(n ddp.Network, zs ...string) {
|
||||||
rt.mu.Lock()
|
rt.mu.Lock()
|
||||||
defer rt.mu.Unlock()
|
defer rt.mu.Unlock()
|
||||||
for r := range rt.routes {
|
for r := range rt.routes {
|
||||||
|
@ -32,10 +32,7 @@ func (rt *RouteTable) AddZoneToNetwork(n ddp.Network, z string) {
|
||||||
if !r.Valid() {
|
if !r.Valid() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if slices.Contains(r.ZoneNames, z) {
|
r.ZoneNames.Insert(zs...)
|
||||||
continue
|
|
||||||
}
|
|
||||||
r.ZoneNames = append(r.ZoneNames, z)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -49,7 +46,9 @@ func (rt *RouteTable) ZonesForNetworks(ns []ddp.Network) map[ddp.Network][]strin
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if _, ok := slices.BinarySearch(ns, r.NetStart); ok {
|
if _, ok := slices.BinarySearch(ns, r.NetStart); ok {
|
||||||
zs[r.NetStart] = append(zs[r.NetStart], r.ZoneNames...)
|
for z := range r.ZoneNames {
|
||||||
|
zs[r.NetStart] = append(zs[r.NetStart], z)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return zs
|
return zs
|
||||||
|
@ -64,7 +63,7 @@ func (rt *RouteTable) RoutesForZone(zone string) []*Route {
|
||||||
if !r.Valid() {
|
if !r.Valid() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if slices.Contains(r.ZoneNames, zone) {
|
if r.ZoneNames.Contains(zone) {
|
||||||
routes = append(routes, r)
|
routes = append(routes, r)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -77,19 +76,13 @@ func (rt *RouteTable) AllZoneNames() (zones []string) {
|
||||||
rt.mu.Lock()
|
rt.mu.Lock()
|
||||||
defer rt.mu.Unlock()
|
defer rt.mu.Unlock()
|
||||||
|
|
||||||
seen := make(map[string]struct{})
|
zs := make(StringSet)
|
||||||
for r := range rt.routes {
|
for r := range rt.routes {
|
||||||
if !r.Valid() {
|
if !r.Valid() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, z := range r.ZoneNames {
|
zs.Add(r.ZoneNames)
|
||||||
if _, s := seen[z]; s {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[z] = struct{}{}
|
|
||||||
zones = append(zones, z)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return zones
|
return zs.ToSlice()
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue