mirror of
				https://github.com/coredns/coredns.git
				synced 2025-10-29 01:04:15 -04:00 
			
		
		
		
	Add complete secondary support
Respond to notifies and allow a secondary to follow the SOA parameters to update a zone from a primary. Also sprinkle it with logging. Also extend monitoring to include qtype in more metrics.
This commit is contained in:
		| @@ -14,6 +14,16 @@ func File(c *Controller) (middleware.Middleware, error) { | |||||||
| 		return nil, err | 		return nil, err | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
|  | 	// Add startup functions to notify the master. | ||||||
|  | 	for _, n := range zones.Names { | ||||||
|  | 		if len(zones.Z[n].TransferTo) > 0 { | ||||||
|  | 			c.Startup = append(c.Startup, func() error { | ||||||
|  | 				zones.Z[n].Notify() | ||||||
|  | 				return err | ||||||
|  | 			}) | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  |  | ||||||
| 	return func(next middleware.Handler) middleware.Handler { | 	return func(next middleware.Handler) middleware.Handler { | ||||||
| 		return file.File{Next: next, Zones: zones} | 		return file.File{Next: next, Zones: zones} | ||||||
| 	}, nil | 	}, nil | ||||||
| @@ -58,7 +68,9 @@ func fileParse(c *Controller) (file.Zones, error) { | |||||||
| 				} | 				} | ||||||
| 				// discard from, here, maybe check and show log when we do? | 				// discard from, here, maybe check and show log when we do? | ||||||
| 				for _, origin := range origins { | 				for _, origin := range origins { | ||||||
| 					z[origin].TransferTo = append(z[origin].TransferTo, t) | 					if t != "" { | ||||||
|  | 						z[origin].TransferTo = append(z[origin].TransferTo, t) | ||||||
|  | 					} | ||||||
| 				} | 				} | ||||||
| 			} | 			} | ||||||
| 		} | 		} | ||||||
|   | |||||||
| @@ -13,13 +13,19 @@ func Secondary(c *Controller) (middleware.Middleware, error) { | |||||||
| 		return nil, err | 		return nil, err | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	// Setup retrieve the zone. | 	// Add startup functions to retrieve the zone and keep it up to date. | ||||||
| 	for _, n := range zones.Names { | 	for _, n := range zones.Names { | ||||||
| 		if len(zones.Z[n].TransferFrom) > 0 { | 		if len(zones.Z[n].TransferFrom) > 0 { | ||||||
| 			c.Startup = append(c.Startup, func() error { | 			c.Startup = append(c.Startup, func() error { | ||||||
| 				err := zones.Z[n].TransferIn() | 				err := zones.Z[n].TransferIn() | ||||||
| 				return err | 				return err | ||||||
| 			}) | 			}) | ||||||
|  | 			c.Startup = append(c.Startup, func() error { | ||||||
|  | 				go func() { | ||||||
|  | 					zones.Z[n].Update() | ||||||
|  | 				}() | ||||||
|  | 				return nil | ||||||
|  | 			}) | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| @@ -52,8 +58,12 @@ func secondaryParse(c *Controller) (file.Zones, error) { | |||||||
| 					return file.Zones{}, e | 					return file.Zones{}, e | ||||||
| 				} | 				} | ||||||
| 				for _, origin := range origins { | 				for _, origin := range origins { | ||||||
| 					z[origin].TransferTo = append(z[origin].TransferTo, t) | 					if t != "" { | ||||||
| 					z[origin].TransferFrom = append(z[origin].TransferFrom, f) | 						z[origin].TransferTo = append(z[origin].TransferTo, t) | ||||||
|  | 					} | ||||||
|  | 					if f != "" { | ||||||
|  | 						z[origin].TransferFrom = append(z[origin].TransferFrom, f) | ||||||
|  | 					} | ||||||
| 				} | 				} | ||||||
| 			} | 			} | ||||||
| 		} | 		} | ||||||
|   | |||||||
| @@ -28,7 +28,6 @@ func (f File) ServeDNS(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (i | |||||||
| 	if state.QClass() != dns.ClassINET { | 	if state.QClass() != dns.ClassINET { | ||||||
| 		return dns.RcodeServerFailure, fmt.Errorf("file: can only deal with ClassINET") | 		return dns.RcodeServerFailure, fmt.Errorf("file: can only deal with ClassINET") | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	qname := state.Name() | 	qname := state.Name() | ||||||
| 	zone := middleware.Zones(f.Zones.Names).Matches(qname) | 	zone := middleware.Zones(f.Zones.Names).Matches(qname) | ||||||
| 	if zone == "" { | 	if zone == "" { | ||||||
| @@ -41,7 +40,26 @@ func (f File) ServeDNS(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (i | |||||||
| 	if z == nil { | 	if z == nil { | ||||||
| 		return dns.RcodeServerFailure, nil | 		return dns.RcodeServerFailure, nil | ||||||
| 	} | 	} | ||||||
|  | 	if r.Opcode == dns.OpcodeNotify { | ||||||
|  | 		if z.isNotify(state) { | ||||||
|  | 			m := new(dns.Msg) | ||||||
|  | 			m.SetReply(r) | ||||||
|  | 			m.Authoritative, m.RecursionAvailable, m.Compress = true, true, true | ||||||
|  | 			w.WriteMsg(m) | ||||||
|  |  | ||||||
|  | 			if ok, _ := z.shouldTransfer(); ok { | ||||||
|  | 				log.Printf("[INFO] Valid notify from %s for %s: initiating transfer", state.IP(), zone) | ||||||
|  | 				z.TransferIn() | ||||||
|  | 			} | ||||||
|  |  | ||||||
|  | 			return dns.RcodeSuccess, nil | ||||||
|  | 		} | ||||||
|  | 		log.Printf("[INFO] Dropping notify from %s for %s", state.IP(), zone) | ||||||
|  | 		return dns.RcodeSuccess, nil | ||||||
|  | 	} | ||||||
|  |  | ||||||
| 	if z.Expired != nil && *z.Expired { | 	if z.Expired != nil && *z.Expired { | ||||||
|  | 		log.Printf("[ERROR] Zone %s is expired", zone) | ||||||
| 		return dns.RcodeServerFailure, nil | 		return dns.RcodeServerFailure, nil | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| @@ -81,7 +99,7 @@ func Parse(f io.Reader, origin, fileName string) (*Zone, error) { | |||||||
| 	z := NewZone(origin) | 	z := NewZone(origin) | ||||||
| 	for x := range tokens { | 	for x := range tokens { | ||||||
| 		if x.Error != nil { | 		if x.Error != nil { | ||||||
| 			log.Printf("[ERROR] failed to parse %s: %v", origin, x.Error) | 			log.Printf("[ERROR] Failed to parse %s: %v", origin, x.Error) | ||||||
| 			return nil, x.Error | 			return nil, x.Error | ||||||
| 		} | 		} | ||||||
| 		if x.RR.Header().Rrtype == dns.TypeSOA { | 		if x.RR.Header().Rrtype == dns.TypeSOA { | ||||||
|   | |||||||
| @@ -9,7 +9,26 @@ import ( | |||||||
| 	"github.com/miekg/dns" | 	"github.com/miekg/dns" | ||||||
| ) | ) | ||||||
|  |  | ||||||
| // Notify will send notifies to all configured IP addresses. | // isNotify checks if state is a notify message and if so, will *also* check if it | ||||||
|  | // is from one of the configured masters. If not it will not be a valid notify | ||||||
|  | // message. If the zone z is not a secondary zone the message will also be ignored. | ||||||
|  | func (z *Zone) isNotify(state middleware.State) bool { | ||||||
|  | 	if state.Req.Opcode != dns.OpcodeNotify { | ||||||
|  | 		return false | ||||||
|  | 	} | ||||||
|  | 	if len(z.TransferFrom) == 0 { | ||||||
|  | 		return false | ||||||
|  | 	} | ||||||
|  | 	remote := middleware.Addr(state.IP()).Normalize() | ||||||
|  | 	for _, from := range z.TransferFrom { | ||||||
|  | 		if from == remote { | ||||||
|  | 			return true | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 	return false | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // Notify will send notifies to all configured TransferTo IP addresses. | ||||||
| func (z *Zone) Notify() { | func (z *Zone) Notify() { | ||||||
| 	go notify(z.name, z.TransferTo) | 	go notify(z.name, z.TransferTo) | ||||||
| } | } | ||||||
| @@ -23,6 +42,10 @@ func notify(zone string, to []string) error { | |||||||
| 	c := new(dns.Client) | 	c := new(dns.Client) | ||||||
|  |  | ||||||
| 	for _, t := range to { | 	for _, t := range to { | ||||||
|  | 		// TODO(miek): these ACLs thingies not to be formalized. | ||||||
|  | 		if t == "*" { | ||||||
|  | 			continue | ||||||
|  | 		} | ||||||
| 		if err := notifyAddr(c, m, t); err != nil { | 		if err := notifyAddr(c, m, t); err != nil { | ||||||
| 			log.Printf("[ERROR] " + err.Error()) | 			log.Printf("[ERROR] " + err.Error()) | ||||||
| 		} else { | 		} else { | ||||||
| @@ -35,7 +58,10 @@ func notify(zone string, to []string) error { | |||||||
| func notifyAddr(c *dns.Client, m *dns.Msg, s string) error { | func notifyAddr(c *dns.Client, m *dns.Msg, s string) error { | ||||||
| 	for i := 0; i < 3; i++ { | 	for i := 0; i < 3; i++ { | ||||||
| 		ret, err := middleware.Exchange(c, m, s) | 		ret, err := middleware.Exchange(c, m, s) | ||||||
| 		if err == nil && ret.Rcode == dns.RcodeSuccess || ret.Rcode == dns.RcodeNotImplemented { | 		if err != nil { | ||||||
|  | 			continue | ||||||
|  | 		} | ||||||
|  | 		if ret.Rcode == dns.RcodeSuccess || ret.Rcode == dns.RcodeNotImplemented { | ||||||
| 			return nil | 			return nil | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
|   | |||||||
| @@ -2,6 +2,9 @@ package file | |||||||
|  |  | ||||||
| import ( | import ( | ||||||
| 	"log" | 	"log" | ||||||
|  | 	"time" | ||||||
|  |  | ||||||
|  | 	"github.com/miekg/coredns/middleware" | ||||||
|  |  | ||||||
| 	"github.com/miekg/dns" | 	"github.com/miekg/dns" | ||||||
| ) | ) | ||||||
| @@ -11,16 +14,18 @@ func (z *Zone) TransferIn() error { | |||||||
| 	if len(z.TransferFrom) == 0 { | 	if len(z.TransferFrom) == 0 { | ||||||
| 		return nil | 		return nil | ||||||
| 	} | 	} | ||||||
| 	t := new(dns.Transfer) |  | ||||||
| 	m := new(dns.Msg) | 	m := new(dns.Msg) | ||||||
| 	m.SetAxfr(z.name) | 	m.SetAxfr(z.name) | ||||||
|  |  | ||||||
|  | 	z1 := z.Copy() | ||||||
| 	var Err error | 	var Err error | ||||||
|  |  | ||||||
| Transfer: | Transfer: | ||||||
| 	for _, tr := range z.TransferFrom { | 	for _, tr := range z.TransferFrom { | ||||||
|  | 		t := new(dns.Transfer) | ||||||
| 		c, err := t.In(m, tr) | 		c, err := t.In(m, tr) | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
| 			log.Printf("[ERROR] Failed to setup transfer %s with %s: %v", z.name, z.TransferFrom[0], err) | 			log.Printf("[ERROR] Failed to setup transfer %s with %s: %v", z.name, tr, err) | ||||||
| 			Err = err | 			Err = err | ||||||
| 			continue Transfer | 			continue Transfer | ||||||
| 		} | 		} | ||||||
| @@ -32,21 +37,127 @@ Transfer: | |||||||
| 			} | 			} | ||||||
| 			for _, rr := range env.RR { | 			for _, rr := range env.RR { | ||||||
| 				if rr.Header().Rrtype == dns.TypeSOA { | 				if rr.Header().Rrtype == dns.TypeSOA { | ||||||
| 					z.SOA = rr.(*dns.SOA) | 					z1.SOA = rr.(*dns.SOA) | ||||||
| 					continue | 					continue | ||||||
| 				} | 				} | ||||||
| 				if rr.Header().Rrtype == dns.TypeRRSIG { | 				if rr.Header().Rrtype == dns.TypeRRSIG { | ||||||
| 					if x, ok := rr.(*dns.RRSIG); ok && x.TypeCovered == dns.TypeSOA { | 					if x, ok := rr.(*dns.RRSIG); ok && x.TypeCovered == dns.TypeSOA { | ||||||
| 						z.SIG = append(z.SIG, x) | 						z1.SIG = append(z1.SIG, x) | ||||||
| 					} | 					} | ||||||
| 				} | 				} | ||||||
| 				z.Insert(rr) | 				z1.Insert(rr) | ||||||
| 			} | 			} | ||||||
| 		} | 		} | ||||||
|  | 		Err = nil | ||||||
|  | 		break | ||||||
| 	} | 	} | ||||||
| 	if Err != nil { | 	if Err != nil { | ||||||
| 		log.Printf("[ERROR] Failed to transfer %s", z.name) | 		log.Printf("[ERROR] Failed to transfer %s", z.name) | ||||||
|  | 		return nil | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
|  | 	z.Tree = z1.Tree | ||||||
|  | 	*z.Expired = false | ||||||
|  | 	log.Printf("[INFO] Transfered: %s", z.name) | ||||||
|  | 	return nil | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // shouldTransfer checks the primaries of zone, retrieves the SOA record, checks the current serial | ||||||
|  | // and the remote serial and will return true if the remote one is higher than the locally configured one. | ||||||
|  | func (z *Zone) shouldTransfer() (bool, error) { | ||||||
|  | 	c := new(dns.Client) | ||||||
|  | 	c.Net = "tcp" // do this query over TCP to minimize spoofing | ||||||
|  | 	m := new(dns.Msg) | ||||||
|  | 	m.SetQuestion(z.name, dns.TypeSOA) | ||||||
|  |  | ||||||
|  | 	var Err error | ||||||
|  | 	serial := -1 | ||||||
|  |  | ||||||
|  | 	for _, tr := range z.TransferFrom { | ||||||
|  | 		Err = nil | ||||||
|  | 		ret, err := middleware.Exchange(c, m, tr) | ||||||
|  | 		if err != nil || ret.Rcode != dns.RcodeSuccess { | ||||||
|  | 			Err = err | ||||||
|  | 			continue | ||||||
|  | 		} | ||||||
|  | 		for _, a := range ret.Answer { | ||||||
|  | 			if a.Header().Rrtype == dns.TypeSOA { | ||||||
|  | 				serial = int(a.(*dns.SOA).Serial) | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 	if serial == -1 { | ||||||
|  | 		return false, Err | ||||||
|  | 	} | ||||||
|  | 	return less(z.SOA.Serial, uint32(serial)), Err | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // less return true of a is smaller than b when taking RFC 1982 serial arithmetic into account. | ||||||
|  | func less(a, b uint32) bool { | ||||||
|  | 	// TODO(miek): implement! | ||||||
|  | 	return a < b | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // Update updates the secondary zone according to its SOA. It will run for the life time of the server | ||||||
|  | // and uses the SOA parameters. Every refresh it will check for a new SOA number. If that fails (for all | ||||||
|  | // server) it wil retry every retry interval. If the zone failed to transfer before the expire, the zone | ||||||
|  | // will be marked expired. | ||||||
|  | func (z *Zone) Update() error { | ||||||
|  | 	// TODO(miek): if SOA changes we need to redo this with possible different timer values. | ||||||
|  | 	// TODO(miek): yeah... | ||||||
|  | 	for z.SOA == nil { | ||||||
|  | 		time.Sleep(1 * time.Second) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	refresh := time.Second * time.Duration(z.SOA.Refresh) | ||||||
|  | 	retry := time.Second * time.Duration(z.SOA.Retry) | ||||||
|  | 	expire := time.Second * time.Duration(z.SOA.Expire) | ||||||
|  | 	retryActive := false | ||||||
|  |  | ||||||
|  | 	// TODO(miek): check max as well? | ||||||
|  | 	if refresh < time.Hour { | ||||||
|  | 		refresh = time.Hour | ||||||
|  | 	} | ||||||
|  | 	if retry < time.Hour { | ||||||
|  | 		retry = time.Hour | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	refreshTicker := time.NewTicker(refresh) | ||||||
|  | 	retryTicker := time.NewTicker(retry) | ||||||
|  | 	expireTicker := time.NewTicker(expire) | ||||||
|  |  | ||||||
|  | 	for { | ||||||
|  | 		select { | ||||||
|  | 		case <-expireTicker.C: | ||||||
|  | 			if !retryActive { | ||||||
|  | 				break | ||||||
|  | 			} | ||||||
|  | 			// TODO(miek): should actually keep track of last succesfull transfer | ||||||
|  | 			*z.Expired = true | ||||||
|  |  | ||||||
|  | 		case <-retryTicker.C: | ||||||
|  | 			if !retryActive { | ||||||
|  | 				break | ||||||
|  | 			} | ||||||
|  | 			ok, err := z.shouldTransfer() | ||||||
|  | 			if err != nil && ok { | ||||||
|  | 				log.Printf("[INFO] Refreshing zone: %s: initiating transfer", z.name) | ||||||
|  | 				z.TransferIn() | ||||||
|  | 				retryActive = false | ||||||
|  | 			} | ||||||
|  |  | ||||||
|  | 		case <-refreshTicker.C: | ||||||
|  | 			ok, err := z.shouldTransfer() | ||||||
|  | 			retryActive = err != nil | ||||||
|  | 			if err != nil && ok { | ||||||
|  | 				log.Printf("[INFO] Refreshing zone: %s: initiating transfer", z.name) | ||||||
|  | 				z.TransferIn() | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	refreshTicker.Stop() | ||||||
|  | 	retryTicker.Stop() | ||||||
|  | 	expireTicker.Stop() | ||||||
| 	return nil | 	return nil | ||||||
| 	return Err // ignore errors for now. TODO(miek) |  | ||||||
| } | } | ||||||
|   | |||||||
| @@ -25,6 +25,15 @@ func NewZone(name string) *Zone { | |||||||
| 	return z | 	return z | ||||||
| } | } | ||||||
|  |  | ||||||
|  | // Copy copies a zone *without* copying the zone's content. It is not a deep copy. | ||||||
|  | func (z *Zone) Copy() *Zone { | ||||||
|  | 	z1 := NewZone(z.name) | ||||||
|  | 	z1.TransferTo = z.TransferTo | ||||||
|  | 	z1.TransferFrom = z.TransferFrom | ||||||
|  | 	z1.Expired = z.Expired | ||||||
|  | 	return z1 | ||||||
|  | } | ||||||
|  |  | ||||||
| // Insert inserts r into z. | // Insert inserts r into z. | ||||||
| func (z *Zone) Insert(r dns.RR) { z.Tree.Insert(r) } | func (z *Zone) Insert(r dns.RR) { z.Tree.Insert(r) } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -25,9 +25,9 @@ func (m *Metrics) ServeDNS(ctx context.Context, w dns.ResponseWriter, r *dns.Msg | |||||||
| 	status, err := m.Next.ServeDNS(ctx, rw, r) | 	status, err := m.Next.ServeDNS(ctx, rw, r) | ||||||
|  |  | ||||||
| 	requestCount.WithLabelValues(zone, qtype).Inc() | 	requestCount.WithLabelValues(zone, qtype).Inc() | ||||||
| 	requestDuration.WithLabelValues(zone).Observe(float64(time.Since(rw.Start()) / time.Second)) | 	requestDuration.WithLabelValues(zone, qtype).Observe(float64(time.Since(rw.Start()) / time.Second)) | ||||||
| 	responseSize.WithLabelValues(zone).Observe(float64(rw.Size())) | 	responseSize.WithLabelValues(zone, qtype).Observe(float64(rw.Size())) | ||||||
| 	responseRcode.WithLabelValues(zone, strconv.Itoa(rw.Rcode())).Inc() | 	responseRcode.WithLabelValues(zone, strconv.Itoa(rw.Rcode()), qtype).Inc() | ||||||
|  |  | ||||||
| 	return status, err | 	return status, err | ||||||
| } | } | ||||||
|   | |||||||
| @@ -53,7 +53,7 @@ func define(subsystem string) { | |||||||
| 		Namespace: namespace, | 		Namespace: namespace, | ||||||
| 		Subsystem: subsystem, | 		Subsystem: subsystem, | ||||||
| 		Name:      "request_count_total", | 		Name:      "request_count_total", | ||||||
| 		Help:      "Counter of DNS requests made per zone and type.", | 		Help:      "Counter of DNS requests made per zone and type and opcode.", | ||||||
| 	}, []string{"zone", "qtype"}) | 	}, []string{"zone", "qtype"}) | ||||||
|  |  | ||||||
| 	requestDuration = prometheus.NewHistogramVec(prometheus.HistogramOpts{ | 	requestDuration = prometheus.NewHistogramVec(prometheus.HistogramOpts{ | ||||||
| @@ -61,7 +61,7 @@ func define(subsystem string) { | |||||||
| 		Subsystem: subsystem, | 		Subsystem: subsystem, | ||||||
| 		Name:      "request_duration_seconds", | 		Name:      "request_duration_seconds", | ||||||
| 		Help:      "Histogram of the time (in seconds) each request took.", | 		Help:      "Histogram of the time (in seconds) each request took.", | ||||||
| 	}, []string{"zone"}) | 	}, []string{"zone", "qtype"}) | ||||||
|  |  | ||||||
| 	responseSize = prometheus.NewHistogramVec(prometheus.HistogramOpts{ | 	responseSize = prometheus.NewHistogramVec(prometheus.HistogramOpts{ | ||||||
| 		Namespace: namespace, | 		Namespace: namespace, | ||||||
| @@ -69,12 +69,12 @@ func define(subsystem string) { | |||||||
| 		Name:      "response_size_bytes", | 		Name:      "response_size_bytes", | ||||||
| 		Help:      "Size of the returns response in bytes.", | 		Help:      "Size of the returns response in bytes.", | ||||||
| 		Buckets:   []float64{0, 100, 200, 300, 400, 511, 1023, 2047, 4095, 8291, 16e3, 32e3, 48e3, 64e3}, | 		Buckets:   []float64{0, 100, 200, 300, 400, 511, 1023, 2047, 4095, 8291, 16e3, 32e3, 48e3, 64e3}, | ||||||
| 	}, []string{"zone"}) | 	}, []string{"zone", "qtype"}) | ||||||
|  |  | ||||||
| 	responseRcode = prometheus.NewCounterVec(prometheus.CounterOpts{ | 	responseRcode = prometheus.NewCounterVec(prometheus.CounterOpts{ | ||||||
| 		Namespace: namespace, | 		Namespace: namespace, | ||||||
| 		Subsystem: subsystem, | 		Subsystem: subsystem, | ||||||
| 		Name:      "response_rcode_count_total", | 		Name:      "response_rcode_count_total", | ||||||
| 		Help:      "Counter of response status codes.", | 		Help:      "Counter of response status codes.", | ||||||
| 	}, []string{"zone", "rcode"}) | 	}, []string{"zone", "rcode", "qtype"}) | ||||||
| } | } | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user