mirror of https://github.com/tailscale/tailscale/
You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
620 lines
16 KiB
Go
620 lines
16 KiB
Go
// Copyright (c) Tailscale Inc & AUTHORS
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
package ipnlocal
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httputil"
|
|
"net/netip"
|
|
"net/url"
|
|
"os"
|
|
"path"
|
|
pathpkg "path"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"golang.org/x/exp/slices"
|
|
"tailscale.com/ipn"
|
|
"tailscale.com/logtail/backoff"
|
|
"tailscale.com/net/netutil"
|
|
"tailscale.com/syncs"
|
|
"tailscale.com/tailcfg"
|
|
"tailscale.com/types/logger"
|
|
"tailscale.com/util/mak"
|
|
"tailscale.com/version"
|
|
)
|
|
|
|
// serveHTTPContextKey is the context.Value key for a *serveHTTPContext.
|
|
type serveHTTPContextKey struct{}
|
|
|
|
type serveHTTPContext struct {
|
|
SrcAddr netip.AddrPort
|
|
DestPort uint16
|
|
}
|
|
|
|
// serveListener is the state of host-level net.Listen for a specific (Tailscale IP, serve port)
|
|
// combination. If there are two TailscaleIPs (v4 and v6) and three ports being served,
|
|
// then there will be six of these active and looping in their Run method.
|
|
//
|
|
// This is not used in userspace-networking mode.
|
|
//
|
|
// Most serve traffic is intercepted by netstack. This exists purely for connections
|
|
// from the machine itself, as that goes via the kernel, so we need to be in the
|
|
// kernel's listening/routing tables.
|
|
type serveListener struct {
|
|
b *LocalBackend
|
|
ap netip.AddrPort
|
|
ctx context.Context // valid while listener is desired
|
|
cancel context.CancelFunc // for ctx, to close listener
|
|
logf logger.Logf
|
|
bo *backoff.Backoff // for retrying failed Listen calls
|
|
|
|
closeListener syncs.AtomicValue[func() error] // Listener's Close method, if any
|
|
}
|
|
|
|
func (b *LocalBackend) newServeListener(ctx context.Context, ap netip.AddrPort, logf logger.Logf) *serveListener {
|
|
ctx, cancel := context.WithCancel(ctx)
|
|
return &serveListener{
|
|
b: b,
|
|
ap: ap,
|
|
ctx: ctx,
|
|
cancel: cancel,
|
|
logf: logf,
|
|
|
|
bo: backoff.NewBackoff("serve-listener", logf, 30*time.Second),
|
|
}
|
|
}
|
|
|
|
// Close cancels the context and closes the listener, if any.
|
|
func (s *serveListener) Close() error {
|
|
s.cancel()
|
|
if close, ok := s.closeListener.LoadOk(); ok {
|
|
s.closeListener.Store(nil)
|
|
close()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Run starts a net.Listen for the serveListener's address and port.
|
|
// If unable to listen, it retries with exponential backoff.
|
|
// Listen is retried until the context is canceled.
|
|
func (s *serveListener) Run() {
|
|
for {
|
|
ip := s.ap.Addr()
|
|
ipStr := ip.String()
|
|
|
|
var lc net.ListenConfig
|
|
if initListenConfig != nil {
|
|
// On macOS, this sets the lc.Control hook to
|
|
// setsockopt the interface index to bind to. This is
|
|
// required by the network sandbox to allow binding to
|
|
// a specific interface. Without this hook, the system
|
|
// chooses a default interface to bind to.
|
|
if err := initListenConfig(&lc, ip, s.b.prevIfState, s.b.dialer.TUNName()); err != nil {
|
|
s.logf("serve failed to init listen config %v, backing off: %v", s.ap, err)
|
|
s.bo.BackOff(s.ctx, err)
|
|
continue
|
|
}
|
|
// On macOS (AppStore or macsys) and if we're binding to a privileged port,
|
|
if version.IsSandboxedMacOS() && s.ap.Port() < 1024 {
|
|
// On macOS, we need to bind to ""/all-interfaces due to
|
|
// the network sandbox. Ideally we would only bind to the
|
|
// Tailscale interface, but macOS errors out if we try to
|
|
// to listen on privileged ports binding only to a specific
|
|
// interface. (#6364)
|
|
ipStr = ""
|
|
}
|
|
}
|
|
|
|
tcp4or6 := "tcp4"
|
|
if ip.Is6() {
|
|
tcp4or6 = "tcp6"
|
|
}
|
|
|
|
ln, err := lc.Listen(s.ctx, tcp4or6, net.JoinHostPort(ipStr, fmt.Sprint(s.ap.Port())))
|
|
if err != nil {
|
|
if s.shouldWarnAboutListenError(err) {
|
|
s.logf("serve failed to listen on %v, backing off: %v", s.ap, err)
|
|
}
|
|
s.bo.BackOff(s.ctx, err)
|
|
continue
|
|
}
|
|
s.closeListener.Store(ln.Close)
|
|
|
|
s.logf("serve listening on %v", s.ap)
|
|
err = s.handleServeListenersAccept(ln)
|
|
if s.ctx.Err() != nil {
|
|
// context canceled, we're done
|
|
return
|
|
}
|
|
if err != nil {
|
|
s.logf("serve listener accept error, retrying: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *serveListener) shouldWarnAboutListenError(err error) bool {
|
|
if !s.b.e.GetLinkMonitor().InterfaceState().HasIP(s.ap.Addr()) {
|
|
// Machine likely doesn't have IPv6 enabled (or the IP is still being
|
|
// assigned). No need to warn. Notably, WSL2 (Issue 6303).
|
|
return false
|
|
}
|
|
// TODO(bradfitz): check errors.Is(err, syscall.EADDRNOTAVAIL) etc? Let's
|
|
// see what happens in practice.
|
|
return true
|
|
}
|
|
|
|
// handleServeListenersAccept accepts connections for the Listener.
|
|
// Calls incoming handler in a new goroutine for each accepted connection.
|
|
func (s *serveListener) handleServeListenersAccept(ln net.Listener) error {
|
|
for {
|
|
conn, err := ln.Accept()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
srcAddr := conn.RemoteAddr().(*net.TCPAddr).AddrPort()
|
|
getConn := func() (net.Conn, bool) { return conn, true }
|
|
sendRST := func() {
|
|
s.b.logf("serve RST for %v", srcAddr)
|
|
conn.Close()
|
|
}
|
|
go s.b.HandleInterceptedTCPConn(s.ap.Port(), srcAddr, getConn, sendRST)
|
|
}
|
|
}
|
|
|
|
// updateServeTCPPortNetMapAddrListenersLocked starts a net.Listen for configured
|
|
// Serve ports on all the node's addresses.
|
|
// Existing Listeners are closed if port no longer in incoming ports list.
|
|
//
|
|
// b.mu must be held.
|
|
func (b *LocalBackend) updateServeTCPPortNetMapAddrListenersLocked(ports []uint16) {
|
|
// close existing listeners where port
|
|
// is no longer in incoming ports list
|
|
for ap, sl := range b.serveListeners {
|
|
if !slices.Contains(ports, ap.Port()) {
|
|
b.logf("closing listener %v", ap)
|
|
sl.Close()
|
|
delete(b.serveListeners, ap)
|
|
}
|
|
}
|
|
|
|
nm := b.netMap
|
|
if nm == nil {
|
|
b.logf("netMap is nil")
|
|
return
|
|
}
|
|
if nm.SelfNode == nil {
|
|
b.logf("netMap SelfNode is nil")
|
|
return
|
|
}
|
|
|
|
for _, a := range nm.Addresses {
|
|
for _, p := range ports {
|
|
addrPort := netip.AddrPortFrom(a.Addr(), p)
|
|
if _, ok := b.serveListeners[addrPort]; ok {
|
|
continue // already listening
|
|
}
|
|
|
|
sl := b.newServeListener(context.Background(), addrPort, b.logf)
|
|
mak.Set(&b.serveListeners, addrPort, sl)
|
|
|
|
go sl.Run()
|
|
}
|
|
}
|
|
}
|
|
|
|
// SetServeConfig establishes or replaces the current serve config.
|
|
func (b *LocalBackend) SetServeConfig(config *ipn.ServeConfig) error {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
|
|
nm := b.netMap
|
|
if nm == nil {
|
|
return errors.New("netMap is nil")
|
|
}
|
|
if nm.SelfNode == nil {
|
|
return errors.New("netMap SelfNode is nil")
|
|
}
|
|
profileID := b.pm.CurrentProfile().ID
|
|
confKey := ipn.ServeConfigKey(profileID)
|
|
|
|
var bs []byte
|
|
if config != nil {
|
|
j, err := json.Marshal(config)
|
|
if err != nil {
|
|
return fmt.Errorf("encoding serve config: %w", err)
|
|
}
|
|
bs = j
|
|
}
|
|
if err := b.store.WriteState(confKey, bs); err != nil {
|
|
return fmt.Errorf("writing ServeConfig to StateStore: %w", err)
|
|
}
|
|
|
|
b.setTCPPortsInterceptedFromNetmapAndPrefsLocked(b.pm.CurrentPrefs())
|
|
return nil
|
|
}
|
|
|
|
// ServeConfig provides a view of the current serve mappings.
|
|
// If serving is not configured, the returned view is not Valid.
|
|
func (b *LocalBackend) ServeConfig() ipn.ServeConfigView {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
return b.serveConfig
|
|
}
|
|
|
|
func (b *LocalBackend) HandleIngressTCPConn(ingressPeer *tailcfg.Node, target ipn.HostPort, srcAddr netip.AddrPort, getConn func() (net.Conn, bool), sendRST func()) {
|
|
b.mu.Lock()
|
|
sc := b.serveConfig
|
|
b.mu.Unlock()
|
|
|
|
if !sc.Valid() {
|
|
b.logf("localbackend: got ingress conn w/o serveConfig; rejecting")
|
|
sendRST()
|
|
return
|
|
}
|
|
|
|
if !sc.AllowFunnel().Get(target) {
|
|
b.logf("localbackend: got ingress conn for unconfigured %q; rejecting", target)
|
|
sendRST()
|
|
return
|
|
}
|
|
|
|
_, port, err := net.SplitHostPort(string(target))
|
|
if err != nil {
|
|
b.logf("localbackend: got ingress conn for bad target %q; rejecting", target)
|
|
sendRST()
|
|
return
|
|
}
|
|
port16, err := strconv.ParseUint(port, 10, 16)
|
|
if err != nil {
|
|
b.logf("localbackend: got ingress conn for bad target %q; rejecting", target)
|
|
sendRST()
|
|
return
|
|
}
|
|
dport := uint16(port16)
|
|
if b.getTCPHandlerForFunnelFlow != nil {
|
|
handler := b.getTCPHandlerForFunnelFlow(srcAddr, dport)
|
|
if handler != nil {
|
|
c, ok := getConn()
|
|
if !ok {
|
|
b.logf("localbackend: getConn didn't complete from %v to port %v", srcAddr, dport)
|
|
return
|
|
}
|
|
handler(c)
|
|
return
|
|
}
|
|
}
|
|
// TODO(bradfitz): pass ingressPeer etc in context to HandleInterceptedTCPConn,
|
|
// extend serveHTTPContext or similar.
|
|
b.HandleInterceptedTCPConn(dport, srcAddr, getConn, sendRST)
|
|
}
|
|
|
|
func (b *LocalBackend) HandleInterceptedTCPConn(dport uint16, srcAddr netip.AddrPort, getConn func() (net.Conn, bool), sendRST func()) {
|
|
b.mu.Lock()
|
|
sc := b.serveConfig
|
|
b.mu.Unlock()
|
|
|
|
if !sc.Valid() {
|
|
b.logf("[unexpected] localbackend: got TCP conn w/o serveConfig; from %v to port %v", srcAddr, dport)
|
|
sendRST()
|
|
return
|
|
}
|
|
|
|
tcph, ok := sc.TCP().GetOk(dport)
|
|
if !ok {
|
|
b.logf("[unexpected] localbackend: got TCP conn without TCP config for port %v; from %v", dport, srcAddr)
|
|
sendRST()
|
|
return
|
|
}
|
|
|
|
if tcph.HTTPS() {
|
|
conn, ok := getConn()
|
|
if !ok {
|
|
b.logf("localbackend: getConn didn't complete from %v to port %v", srcAddr, dport)
|
|
return
|
|
}
|
|
hs := &http.Server{
|
|
TLSConfig: &tls.Config{
|
|
GetCertificate: b.getTLSServeCertForPort(dport),
|
|
},
|
|
Handler: http.HandlerFunc(b.serveWebHandler),
|
|
BaseContext: func(_ net.Listener) context.Context {
|
|
return context.WithValue(context.Background(), serveHTTPContextKey{}, &serveHTTPContext{
|
|
SrcAddr: srcAddr,
|
|
DestPort: dport,
|
|
})
|
|
},
|
|
}
|
|
hs.ServeTLS(netutil.NewOneConnListener(conn, nil), "", "")
|
|
return
|
|
}
|
|
|
|
if backDst := tcph.TCPForward(); backDst != "" {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
backConn, err := b.dialer.SystemDial(ctx, "tcp", backDst)
|
|
cancel()
|
|
if err != nil {
|
|
b.logf("localbackend: failed to TCP proxy port %v (from %v) to %s: %v", dport, srcAddr, backDst, err)
|
|
sendRST()
|
|
return
|
|
}
|
|
conn, ok := getConn()
|
|
if !ok {
|
|
b.logf("localbackend: getConn didn't complete from %v to port %v", srcAddr, dport)
|
|
backConn.Close()
|
|
return
|
|
}
|
|
defer conn.Close()
|
|
defer backConn.Close()
|
|
|
|
if sni := tcph.TerminateTLS(); sni != "" {
|
|
conn = tls.Server(conn, &tls.Config{
|
|
GetCertificate: func(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
|
defer cancel()
|
|
pair, err := b.GetCertPEM(ctx, sni)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
cert, err := tls.X509KeyPair(pair.CertPEM, pair.KeyPEM)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &cert, nil
|
|
},
|
|
})
|
|
}
|
|
|
|
// TODO(bradfitz): do the RegisterIPPortIdentity and
|
|
// UnregisterIPPortIdentity stuff that netstack does
|
|
|
|
errc := make(chan error, 1)
|
|
go func() {
|
|
_, err := io.Copy(backConn, conn)
|
|
errc <- err
|
|
}()
|
|
go func() {
|
|
_, err := io.Copy(conn, backConn)
|
|
errc <- err
|
|
}()
|
|
<-errc
|
|
return
|
|
}
|
|
|
|
b.logf("closing TCP conn to port %v (from %v) with actionless TCPPortHandler", dport, srcAddr)
|
|
sendRST()
|
|
}
|
|
|
|
func (b *LocalBackend) getServeHandler(r *http.Request) (_ ipn.HTTPHandlerView, at string, ok bool) {
|
|
var z ipn.HTTPHandlerView // zero value
|
|
|
|
if r.TLS == nil {
|
|
return z, "", false
|
|
}
|
|
|
|
sctx, ok := r.Context().Value(serveHTTPContextKey{}).(*serveHTTPContext)
|
|
if !ok {
|
|
b.logf("[unexpected] localbackend: no serveHTTPContext in request")
|
|
return z, "", false
|
|
}
|
|
wsc, ok := b.webServerConfig(r.TLS.ServerName, sctx.DestPort)
|
|
if !ok {
|
|
return z, "", false
|
|
}
|
|
|
|
if h, ok := wsc.Handlers().GetOk(r.URL.Path); ok {
|
|
return h, r.URL.Path, true
|
|
}
|
|
path := path.Clean(r.URL.Path)
|
|
for {
|
|
withSlash := path + "/"
|
|
if h, ok := wsc.Handlers().GetOk(withSlash); ok {
|
|
return h, withSlash, true
|
|
}
|
|
if h, ok := wsc.Handlers().GetOk(path); ok {
|
|
return h, path, true
|
|
}
|
|
if path == "/" {
|
|
return z, "", false
|
|
}
|
|
path = pathpkg.Dir(path)
|
|
}
|
|
}
|
|
|
|
// proxyHandlerForBackend creates a new HTTP reverse proxy for a particular backend that
|
|
// we serve requests for. `backend` is a HTTPHandler.Proxy string (url, hostport or just port).
|
|
func (b *LocalBackend) proxyHandlerForBackend(backend string) (*httputil.ReverseProxy, error) {
|
|
targetURL, insecure := expandProxyArg(backend)
|
|
u, err := url.Parse(targetURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid url %s: %w", targetURL, err)
|
|
}
|
|
rp := httputil.NewSingleHostReverseProxy(u)
|
|
rp.Transport = &http.Transport{
|
|
DialContext: b.dialer.SystemDial,
|
|
TLSClientConfig: &tls.Config{
|
|
InsecureSkipVerify: insecure,
|
|
},
|
|
// Values for the following parameters have been copied from http.DefaultTransport.
|
|
ForceAttemptHTTP2: true,
|
|
MaxIdleConns: 100,
|
|
IdleConnTimeout: 90 * time.Second,
|
|
TLSHandshakeTimeout: 10 * time.Second,
|
|
ExpectContinueTimeout: 1 * time.Second,
|
|
}
|
|
return rp, nil
|
|
}
|
|
|
|
func (b *LocalBackend) serveWebHandler(w http.ResponseWriter, r *http.Request) {
|
|
h, mountPoint, ok := b.getServeHandler(r)
|
|
if !ok {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
if s := h.Text(); s != "" {
|
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
io.WriteString(w, s)
|
|
return
|
|
}
|
|
if v := h.Path(); v != "" {
|
|
b.serveFileOrDirectory(w, r, v, mountPoint)
|
|
return
|
|
}
|
|
if v := h.Proxy(); v != "" {
|
|
p, ok := b.serveProxyHandlers.Load(v)
|
|
if !ok {
|
|
http.Error(w, "unknown proxy destination", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
p.(http.Handler).ServeHTTP(w, r)
|
|
return
|
|
}
|
|
|
|
http.Error(w, "empty handler", 500)
|
|
}
|
|
|
|
func (b *LocalBackend) serveFileOrDirectory(w http.ResponseWriter, r *http.Request, fileOrDir, mountPoint string) {
|
|
fi, err := os.Stat(fileOrDir)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
http.Error(w, err.Error(), 500)
|
|
return
|
|
}
|
|
if fi.Mode().IsRegular() {
|
|
if mountPoint != r.URL.Path {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
f, err := os.Open(fileOrDir)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), 500)
|
|
return
|
|
}
|
|
defer f.Close()
|
|
http.ServeContent(w, r, path.Base(mountPoint), fi.ModTime(), f)
|
|
return
|
|
}
|
|
if !fi.IsDir() {
|
|
http.Error(w, "not a file or directory", 500)
|
|
return
|
|
}
|
|
if len(r.URL.Path) < len(mountPoint) && r.URL.Path+"/" == mountPoint {
|
|
http.Redirect(w, r, mountPoint, http.StatusFound)
|
|
return
|
|
}
|
|
|
|
var fs http.Handler = http.FileServer(http.Dir(fileOrDir))
|
|
if mountPoint != "/" {
|
|
fs = http.StripPrefix(strings.TrimSuffix(mountPoint, "/"), fs)
|
|
}
|
|
fs.ServeHTTP(&fixLocationHeaderResponseWriter{
|
|
ResponseWriter: w,
|
|
mountPoint: mountPoint,
|
|
}, r)
|
|
}
|
|
|
|
// fixLocationHeaderResponseWriter is an http.ResponseWriter wrapper that, upon
|
|
// flushing HTTP headers, prefixes any Location header with the mount point.
|
|
type fixLocationHeaderResponseWriter struct {
|
|
http.ResponseWriter
|
|
mountPoint string
|
|
fixOnce sync.Once // guards call to fix
|
|
}
|
|
|
|
func (w *fixLocationHeaderResponseWriter) fix() {
|
|
h := w.ResponseWriter.Header()
|
|
if v := h.Get("Location"); v != "" {
|
|
h.Set("Location", w.mountPoint+v)
|
|
}
|
|
}
|
|
|
|
func (w *fixLocationHeaderResponseWriter) WriteHeader(code int) {
|
|
w.fixOnce.Do(w.fix)
|
|
w.ResponseWriter.WriteHeader(code)
|
|
}
|
|
|
|
func (w *fixLocationHeaderResponseWriter) Write(p []byte) (int, error) {
|
|
w.fixOnce.Do(w.fix)
|
|
return w.ResponseWriter.Write(p)
|
|
}
|
|
|
|
// expandProxyArg returns a URL from s, where s can be of form:
|
|
//
|
|
// * port number ("8080")
|
|
// * host:port ("localhost:8080")
|
|
// * full URL ("http://localhost:8080", in which case it's returned unchanged)
|
|
// * insecure TLS ("https+insecure://127.0.0.1:4430")
|
|
func expandProxyArg(s string) (targetURL string, insecureSkipVerify bool) {
|
|
if s == "" {
|
|
return "", false
|
|
}
|
|
if strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://") {
|
|
return s, false
|
|
}
|
|
if rest, ok := strings.CutPrefix(s, "https+insecure://"); ok {
|
|
return "https://" + rest, true
|
|
}
|
|
if allNumeric(s) {
|
|
return "http://127.0.0.1:" + s, false
|
|
}
|
|
return "http://" + s, false
|
|
}
|
|
|
|
func allNumeric(s string) bool {
|
|
for i := 0; i < len(s); i++ {
|
|
if s[i] < '0' || s[i] > '9' {
|
|
return false
|
|
}
|
|
}
|
|
return s != ""
|
|
}
|
|
|
|
func (b *LocalBackend) webServerConfig(sniName string, port uint16) (c ipn.WebServerConfigView, ok bool) {
|
|
key := ipn.HostPort(fmt.Sprintf("%s:%v", sniName, port))
|
|
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
|
|
if !b.serveConfig.Valid() {
|
|
return c, false
|
|
}
|
|
return b.serveConfig.Web().GetOk(key)
|
|
}
|
|
|
|
func (b *LocalBackend) getTLSServeCertForPort(port uint16) func(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
|
return func(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
|
if hi == nil || hi.ServerName == "" {
|
|
return nil, errors.New("no SNI ServerName")
|
|
}
|
|
_, ok := b.webServerConfig(hi.ServerName, port)
|
|
if !ok {
|
|
return nil, errors.New("no webserver configured for name/port")
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
|
defer cancel()
|
|
pair, err := b.GetCertPEM(ctx, hi.ServerName)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
cert, err := tls.X509KeyPair(pair.CertPEM, pair.KeyPEM)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &cert, nil
|
|
}
|
|
}
|