Skip to content

Commit

Permalink
Define new share URL format
Browse files Browse the repository at this point in the history
  • Loading branch information
enfein committed Dec 16, 2024
1 parent 558a92f commit 71f475f
Show file tree
Hide file tree
Showing 6 changed files with 391 additions and 69 deletions.
181 changes: 181 additions & 0 deletions pkg/appctl/url.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,22 @@ package appctl
import (
"encoding/base64"
"fmt"
"net"
"net/url"
"regexp"
"strconv"
"strings"

pb "github.com/enfein/mieru/v3/pkg/appctl/appctlpb"
"github.com/enfein/mieru/v3/pkg/common"
"github.com/enfein/mieru/v3/pkg/stderror"
"google.golang.org/protobuf/proto"
)

var (
safeURLRegExp = regexp.MustCompile(`^[0-9A-Za-z_-]+$`)
)

// ClientConfigToURL creates a URL to share the client configuration.
func ClientConfigToURL(config *pb.ClientConfig) (string, error) {
if config == nil {
Expand All @@ -37,6 +46,69 @@ func ClientConfigToURL(config *pb.ClientConfig) (string, error) {
return "mieru://" + base64.StdEncoding.EncodeToString(b), nil
}

// ClientProfileToMultiURLs creates a list of human readable URLs to share
// the client profile configuration.
func ClientProfileToMultiURLs(profile *pb.ClientProfile) (urls []string, err error) {
if profile == nil {
return nil, stderror.ErrNullPointer
}
profileName := profile.GetProfileName()
userName := profile.GetUser().GetName()
password := profile.GetUser().GetPassword()
servers := profile.GetServers()
if profileName == "" {
return nil, fmt.Errorf("profile name is empty")
}
if userName == "" {
return nil, fmt.Errorf("user name in profile %s is empty", profileName)
}
if !isSafeURLString(userName) {
return nil, fmt.Errorf("user name %q in profile %s can't be safely encoded to URL. Only allow A-Z, a-z, 0-9, underscore _, and hyphen -", userName, profileName)
}
if password == "" {
return nil, fmt.Errorf("password in profile %s is empty", profileName)
}
if !isSafeURLString(password) {
return nil, fmt.Errorf("password %q in profile %s can't be safely encoded to URL. Only allow A-Z, a-z, 0-9, underscore _, and hyphen -", password, profileName)
}
if len(servers) == 0 {
return nil, fmt.Errorf("profile %s has no server", profileName)
}
for _, server := range servers {
u := &url.URL{Scheme: "mierus"} // mierus => mieru simple
u.User = url.UserPassword(userName, password)
if server.GetDomainName() != "" {
u.Host = server.GetDomainName()
} else if server.GetIpAddress() != "" {
u.Host = common.MaybeDecorateIPv6(server.GetIpAddress())
} else {
return nil, fmt.Errorf("profile %s has a server with no domain name or IP address", profileName)
}
if len(server.GetPortBindings()) == 0 {
return nil, fmt.Errorf("profile %s has a server %s with no port bindings", profileName, u.Host)
}
q := url.Values{}
q.Add("profile", profileName)
if profile.Mtu != nil {
q.Add("mtu", strconv.Itoa(int(profile.GetMtu())))
}
if profile.Multiplexing != nil && profile.Multiplexing.Level != nil {
q.Add("multiplexing", profile.GetMultiplexing().GetLevel().String())
}
for _, binding := range server.GetPortBindings() {
if binding.GetPortRange() != "" {
q.Add("port", binding.GetPortRange())
} else {
q.Add("port", strconv.Itoa(int(binding.GetPort())))
}
q.Add("protocol", binding.GetProtocol().String())
}
u.RawQuery = q.Encode()
urls = append(urls, u.String())
}
return
}

// URLToClientConfig returns a client configuration based on the URL.
func URLToClientConfig(s string) (*pb.ClientConfig, error) {
u, err := url.Parse(s)
Expand All @@ -59,3 +131,112 @@ func URLToClientConfig(s string) (*pb.ClientConfig, error) {
}
return c, nil
}

// URLToClientProfile returns a client profile based on the mieru simple URL.
func URLToClientProfile(s string) (*pb.ClientProfile, error) {
u, err := url.Parse(s)
if err != nil {
return nil, fmt.Errorf("url.Parse() failed: %w", err)
}
if u.Scheme != "mierus" {
return nil, fmt.Errorf("unrecognized URL scheme %q", u.Scheme)
}
if u.Opaque != "" {
return nil, fmt.Errorf("URL is opaque")
}

p := &pb.ClientProfile{
User: &pb.User{},
}
if u.User == nil {
return nil, fmt.Errorf("URL has no user info")
}
if u.User.Username() == "" {
return nil, fmt.Errorf("URL has no user name")
}
pw, _ := u.User.Password()
if pw == "" {
return nil, fmt.Errorf("URL has no password")
}
p.User.Name = proto.String(u.User.Username())
p.User.Password = proto.String(pw)

if u.Hostname() == "" {
return nil, fmt.Errorf("URL has no host")
}
server := &pb.ServerEndpoint{}
if net.ParseIP(u.Hostname()) != nil {
server.IpAddress = proto.String(u.Hostname())
} else {
server.DomainName = proto.String(u.Hostname())
}

q := u.Query()
if q.Get("profile") == "" {
return nil, fmt.Errorf("URL has no profile name")
}
p.ProfileName = proto.String(q.Get("profile"))
if q.Get("mtu") != "" {
mtu, err := strconv.Atoi(q.Get("mtu"))
if err != nil {
return nil, fmt.Errorf("URL has invalid MTU %q", q.Get("mtu"))
}
p.Mtu = proto.Int32(int32(mtu))
}
if q.Get("multiplexing") != "" {
level := pb.MultiplexingLevel(pb.MultiplexingLevel_value[q.Get("multiplexing")])
p.Multiplexing = &pb.MultiplexingConfig{
Level: &level,
}
}

portList := q["port"]
protocolList := q["protocol"]
if len(portList) != len(protocolList) {
return nil, fmt.Errorf("URL has mismatched number of port and number of protocol")
}
for idx, port := range portList {
portNum, err := strconv.Atoi(port)
if err != nil {
portRangeParts := strings.Split(port, "-")
if len(portRangeParts) != 2 {
return nil, fmt.Errorf("URL has invalid port or port range %q", port)
}
beginPort, err := strconv.Atoi(portRangeParts[0])
if err != nil {
return nil, fmt.Errorf("URL has invalid begin of port range %q", portRangeParts[0])
}
endPort, err := strconv.Atoi(portRangeParts[1])
if err != nil {
return nil, fmt.Errorf("URL has invalid end of port range %q", portRangeParts[1])
}
if beginPort < 1 || beginPort > 65535 {
return nil, fmt.Errorf("URL has invalid begin port number %d", beginPort)
}
if endPort < 1 || endPort > 65535 {
return nil, fmt.Errorf("URL has invalid end port number %d", endPort)
}
if beginPort > endPort {
return nil, fmt.Errorf("URL's begin port number %d is greater than end port number %d", beginPort, endPort)
}
server.PortBindings = append(server.PortBindings, &pb.PortBinding{
PortRange: proto.String(fmt.Sprintf("%d-%d", beginPort, endPort)),
Protocol: pb.TransportProtocol(pb.TransportProtocol_value[protocolList[idx]]).Enum(),
})
} else {
if portNum < 1 || portNum > 65535 {
return nil, fmt.Errorf("URL has invalid port number %d", portNum)
}
server.PortBindings = append(server.PortBindings, &pb.PortBinding{
Port: proto.Int32(int32(portNum)),
Protocol: pb.TransportProtocol(pb.TransportProtocol_value[protocolList[idx]]).Enum(),
})
}
}
p.Servers = append(p.Servers, server)
return p, nil
}

func isSafeURLString(input string) bool {
return safeURLRegExp.MatchString(input)
}
117 changes: 116 additions & 1 deletion pkg/appctl/url_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import (
"google.golang.org/protobuf/proto"
)

func TestURL(t *testing.T) {
func TestClientConfigWithURL(t *testing.T) {
c := &pb.ClientConfig{
Profiles: []*pb.ClientProfile{
{
Expand All @@ -42,6 +42,10 @@ func TestURL(t *testing.T) {
},
},
},
Mtu: proto.Int32(1280),
Multiplexing: &pb.MultiplexingConfig{
Level: pb.MultiplexingLevel_MULTIPLEXING_MIDDLE.Enum(),
},
},
},
ActiveProfile: proto.String("default"),
Expand All @@ -62,3 +66,114 @@ func TestURL(t *testing.T) {
t.Fatalf("client config is not equal after generating and loading URL:\n%s\n%s", c.String(), c2.String())
}
}

func TestClientProfileWithMultiURLs(t *testing.T) {
p := &pb.ClientProfile{
ProfileName: proto.String("default"),
User: &pb.User{
Name: proto.String("qingguanyidao"),
Password: proto.String("tongshangkuanyi"),
},
Servers: []*pb.ServerEndpoint{
{
IpAddress: proto.String("2001:db8::1"),
PortBindings: []*pb.PortBinding{
{
Port: proto.Int32(6666),
Protocol: pb.TransportProtocol_TCP.Enum(),
},
{
PortRange: proto.String("8964-8965"),
Protocol: pb.TransportProtocol_UDP.Enum(),
},
},
},
{
DomainName: proto.String("example.com"),
PortBindings: []*pb.PortBinding{
{
Port: proto.Int32(9999),
Protocol: pb.TransportProtocol_TCP.Enum(),
},
},
},
},
Mtu: proto.Int32(1280),
Multiplexing: &pb.MultiplexingConfig{
Level: pb.MultiplexingLevel_MULTIPLEXING_MIDDLE.Enum(),
},
}

p0 := &pb.ClientProfile{
ProfileName: proto.String("default"),
User: &pb.User{
Name: proto.String("qingguanyidao"),
Password: proto.String("tongshangkuanyi"),
},
Servers: []*pb.ServerEndpoint{
{
IpAddress: proto.String("2001:db8::1"),
PortBindings: []*pb.PortBinding{
{
Port: proto.Int32(6666),
Protocol: pb.TransportProtocol_TCP.Enum(),
},
{
PortRange: proto.String("8964-8965"),
Protocol: pb.TransportProtocol_UDP.Enum(),
},
},
},
},
Mtu: proto.Int32(1280),
Multiplexing: &pb.MultiplexingConfig{
Level: pb.MultiplexingLevel_MULTIPLEXING_MIDDLE.Enum(),
},
}

p1 := &pb.ClientProfile{
ProfileName: proto.String("default"),
User: &pb.User{
Name: proto.String("qingguanyidao"),
Password: proto.String("tongshangkuanyi"),
},
Servers: []*pb.ServerEndpoint{
{
DomainName: proto.String("example.com"),
PortBindings: []*pb.PortBinding{
{
Port: proto.Int32(9999),
Protocol: pb.TransportProtocol_TCP.Enum(),
},
},
},
},
Mtu: proto.Int32(1280),
Multiplexing: &pb.MultiplexingConfig{
Level: pb.MultiplexingLevel_MULTIPLEXING_MIDDLE.Enum(),
},
}

urls, err := ClientProfileToMultiURLs(p)
if err != nil {
t.Fatalf("ClientConfigToMultiURLs() failed: %v", err)
}
if len(urls) != 2 {
t.Fatalf("got %d URLs, want 2", len(urls))
}

profile0, err := URLToClientProfile(urls[0])
if err != nil {
t.Fatalf("URLToClientProfile() failed: %v", err)
}
if !proto.Equal(profile0, p0) {
t.Errorf("profile is not equal after generating and loading URL %q", urls[0])
}
profile1, err := URLToClientProfile(urls[1])
if err != nil {
t.Fatalf("URLToClientProfile() failed: %v", err)
}
if !proto.Equal(profile1, p1) {
t.Errorf("profile is not equal after generating and loading URL %q", urls[1])
}
}
Loading

0 comments on commit 71f475f

Please sign in to comment.