From 7dec9e6fd221131870c7b7062512cdbb765e891e Mon Sep 17 00:00:00 2001 From: Tyler Date: Tue, 21 May 2019 00:02:20 -0400 Subject: [PATCH] Initial commit --- .gitignore | 4 + browser.go | 24 + cert.go | 91 ++ go.mod | 18 + go.sum | 29 + icon/icon.ico | Bin 0 -> 34494 bytes icon/iconwin.go | 2884 ++++++++++++++++++++++++++++++++++++++++++++ icon/make_icon.bat | 41 + icon/make_icon.sh | 36 + main.go | 143 +++ util.go | 38 + 11 files changed, 3308 insertions(+) create mode 100644 .gitignore create mode 100644 browser.go create mode 100644 cert.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 icon/icon.ico create mode 100644 icon/iconwin.go create mode 100644 icon/make_icon.bat create mode 100644 icon/make_icon.sh create mode 100644 main.go create mode 100644 util.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3bb3d94 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +# Server generated files +/server.crt +/server.key +/token.txt \ No newline at end of file diff --git a/browser.go b/browser.go new file mode 100644 index 0000000..326d106 --- /dev/null +++ b/browser.go @@ -0,0 +1,24 @@ +package main + +import ( + "fmt" + "os/exec" + "runtime" +) + +func openUrl(url string) error { + var err error + + switch runtime.GOOS { + case "linux": + err = exec.Command("xdg-open", url).Start() + case "windows": + err = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start() + case "darwin": + err = exec.Command("open", url).Start() + default: + err = fmt.Errorf("unsupported platform") + } + + return err +} diff --git a/cert.go b/cert.go new file mode 100644 index 0000000..577484a --- /dev/null +++ b/cert.go @@ -0,0 +1,91 @@ +package main + +import ( + "crypto/ecdsa" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "fmt" + "math/big" + "os" + "time" +) + +func publicKey(priv interface{}) interface{} { + switch k := priv.(type) { + case *rsa.PrivateKey: + return &k.PublicKey + case *ecdsa.PrivateKey: + return &k.PublicKey + default: + return nil + } +} + +func pemBlockForKey(priv interface{}) *pem.Block { + switch k := priv.(type) { + case *rsa.PrivateKey: + return &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(k)} + case *ecdsa.PrivateKey: + b, err := x509.MarshalECPrivateKey(k) + if err != nil { + fmt.Fprintf(os.Stderr, "Unable to marshal ECDSA private key: %v", err) + os.Exit(2) + } + return &pem.Block{Type: "EC PRIVATE KEY", Bytes: b} + default: + return nil + } +} + +func generateCert(crtFile, keyFile string) error { + priv, err := rsa.GenerateKey(rand.Reader, 2048) + + if err != nil { + return err + } + + template := x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{ + Organization: []string{"Acme Co"}, + }, + NotBefore: time.Now(), + NotAfter: time.Now().Add(time.Hour * 24 * 180), + + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + } + + derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, publicKey(priv), priv) + + if err != nil { + return err + } + + f, err := os.Create(crtFile) + + if err != nil { + return err + } + + pem.Encode(f, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}) + + f.Close() + + + f, err = os.Create(keyFile) + + if err != nil { + return err + } + + pem.Encode(f, pemBlockForKey(priv)) + + f.Close() + + return nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7d0ab65 --- /dev/null +++ b/go.mod @@ -0,0 +1,18 @@ +module meow.tf/streamdeck-remote-server + +go 1.12 + +require ( + github.com/atotto/clipboard v0.1.2 + github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 // indirect + github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 // indirect + github.com/getlantern/golog v0.0.0-20170508214112-cca714f7feb5 // indirect + github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7 // indirect + github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 // indirect + github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f // indirect + github.com/getlantern/systray v0.0.0-20190131073753-26d5b920200d + github.com/go-stack/stack v1.8.0 // indirect + github.com/oxtoacart/bpool v0.0.0-20190227141107-8c4636f812cc // indirect + github.com/stretchr/testify v1.3.0 // indirect + golang.org/x/sys v0.0.0-20190520201301-c432e742b0af // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e6ac394 --- /dev/null +++ b/go.sum @@ -0,0 +1,29 @@ +github.com/atotto/clipboard v0.1.2 h1:YZCtFu5Ie8qX2VmVTBnrqLSiU9XOWwqNRmdT3gIQzbY= +github.com/atotto/clipboard v0.1.2/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 h1:NRUJuo3v3WGC/g5YiyF790gut6oQr5f3FBI88Wv0dx4= +github.com/getlantern/context v0.0.0-20190109183933-c447772a6520/go.mod h1:L+mq6/vvYHKjCX2oez0CgEAJmbq1fbb/oNJIWQkBybY= +github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 h1:6uJ+sZ/e03gkbqZ0kUG6mfKoqDb4XMAzMIwlajq19So= +github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7/go.mod h1:l+xpFBrCtDLpK9qNjxs+cHU6+BAdlBaxHqikB6Lku3A= +github.com/getlantern/golog v0.0.0-20170508214112-cca714f7feb5 h1:Okd7vkn9CfIgDBj1ST/vtBTCfD/kxIhYD412K+FRKPc= +github.com/getlantern/golog v0.0.0-20170508214112-cca714f7feb5/go.mod h1:Vwx1Cg64gCdIalad44uvQsKZw6LsVczIKZrUBStEjVw= +github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7 h1:micT5vkcr9tOVk1FiH8SWKID8ultN44Z+yzd2y/Vyb0= +github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7/go.mod h1:dD3CgOrwlzca8ed61CsZouQS5h5jIzkK9ZWrTcf0s+o= +github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 h1:XYzSdCbkzOC0FDNrgJqGRo8PCMFOBFL9py72DRs7bmc= +github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55/go.mod h1:6mmzY2kW1TOOrVy+r41Za2MxXM+hhqTtY3oBKd2AgFA= +github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f h1:wrYrQttPS8FHIRSlsrcuKazukx/xqO/PpLZzZXsF+EA= +github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f/go.mod h1:D5ao98qkA6pxftxoqzibIBBrLSUli+kYnJqrgBf9cIA= +github.com/getlantern/systray v0.0.0-20190131073753-26d5b920200d h1:4P2eDMAoQcQoWIIKCNIkuVbQb+paRmpMxVXVfbs7B4U= +github.com/getlantern/systray v0.0.0-20190131073753-26d5b920200d/go.mod h1:7Splj4WBQSps8jODnMgrIV6goKL0N1HR+mhCAEVWlA0= +github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/oxtoacart/bpool v0.0.0-20190227141107-8c4636f812cc h1:uhnyuvDwdKbjemAXHKsiEZOPagHim2nRjMcazH1g26A= +github.com/oxtoacart/bpool v0.0.0-20190227141107-8c4636f812cc/go.mod h1:L3UMQOThbttwfYRNFOWLLVXMhk5Lkio4GGOtw5UrxS0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +golang.org/x/sys v0.0.0-20190520201301-c432e742b0af h1:NXfmMfXz6JqGfG3ikSxcz2N93j6DgScr19Oo2uwFu88= +golang.org/x/sys v0.0.0-20190520201301-c432e742b0af/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= diff --git a/icon/icon.ico b/icon/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..60ae0e3a320f49351391e740243b792a6190c3d7 GIT binary patch literal 34494 zcmeI5dz4jG9mn@&&_PTGr4W2@!4xrG^uNU^l`@g@_1uLqXSkncGW|SLC+Fo1y_p2;&SlE&d$zJP_t#r7T1)B zBGU#`?nB2&Xeu4>p9fY0l||^-7Yt|nbTAv73y!C*0lJlL>g%XK8>|H?Pk?`c4(-^o z0lEkr1q?FXv1!}tIES|PKva2&#uGpf9oh$nfmI-`EXCe1=YuE$LQ|X|?H_<|A=A$< zPoF6V^ApH#gr^DF0Z=ao(qrWpM99BG+Y+!DsK2{)NS=M+3J_M_rSeSpJ_WrQYynOO zquV(73DmCv!E!zR^_Lwmm`0~VxwuU7wyi(=@Z_UW@`&2oIwF%M{bER?*8 z)v+seH-i6yX7DIbd0jf7j&C=%rvZ+>4~x~YJN4t(R-cRkj{_Cykc_9}L#gmB9f#rt zejU%UzmMvZ6e}+s3*@eBps;(0b3w3`PF#x&O((^A)kW@vMcZJJs}0M0oD&P%QE z72zvh*>#?w%FyJUzaYlYt0Cu^KVbJBa4C7%MY+ndpw|^|T%dnomNrn{>I^_NelO3` z2Kvqd{{gP_u%TlVdpd6%2_}OHU=$eG-rl~|c`=w<V zhE_d{j)UWDD4*~&g=&bed;}Yxg)KA=#Q=7pePO(+P!65f=4{%uDJNaQLYwqALf3(` zMd!rZkRK}DrS!vdL0ZL@e=RysWII&1VzF)4m#JT3kAXu$s14PFH2QTtc?0<86Pjaq zHgul$7wV;dHb_#`m&*58=x>4l80og*?+17k*GW*#X`+gB-wy8{k_UUS|2q(B!^L2E zhrstw5LGna8zO$?z{<%+Fb=Oj{QBWhj=m0bt}azppz}x?yZbT1#yqEA)bb5P_6DFl zxT4to@h^whn-eOAjT<*Q**)RYd`P^T;F;^!zkdCC$2XYvn}KWy^MU02Hf3WRbzYu) zxf)8{cfi@S5BBSK+tka>C*hd~G)|On_01h17)Os>IURA-+Gv0d(l&Z!a}R67AIHIrT+iZL1=Ct~P{K^5kguTJtpdHM^ z|4)D*J9Dg}sRv97O)jSiSCeTrjm)j`rj?0T!PEoMyrtT&Glg8v6zZByYh8J!zo6PVH%n z?dxgSf3<`UGuqnPbPm$|eG)uB0e=Q7fc(}y&;#Il+D`#P@FVf~f#1aV@G3m#f!ndM z2Kdjj?Di`*}`LGFHQFdwWQP#6}1A4t{eG(@U_|U^Zf*qa5??Ufh z(w(G;Z}aBO>bvR4YK~QDK~?8`kk|(d>Kr}-EU(nKl~=#Xhdn`L8q^N2a3S!|FWIfA zJdf@%N$kU;`Nbm@S?ekMB08sR*swvzaAf?5iDx`i_ncXj)##gnqFCETIG#%t3&HBi$k~~eUM*3_e{@($VOFpw%4s&=f<#TDIau= znoIjud60cw$7$DmFll)Oxf52bSmD|B9Y@;VAB5#WK6qm?*bnjn98SB|nv#?!;6G4u z{`rU1<9t{YqUe9{oDpW<=HN%jBsveE=Rq*q*>pPHK2&+w$_MJMcD#OS<#xmVtsr^!=rORqBx`MmumC*a0z+PxvfW`{r6xu@l~r} zP<^4bol8N`ht<@L6HhgZ;?fT1Z@Ma4yyeIP~U0>)U}$ubFF3~YZ8qh z-`rsuS~^XAfmM$6#cQyz%(KwMDFC#9LX)0Lj{!45_d3OXq3aY=anX;-#O19~Ma75O znke|L`v3LaFvSznw7~+^*Cos zP>m(UP_ttRzcp`&TEnR6u|#es;Q!JR`FGVdJC;~i(KYI59DUR|TY`q3myngbP!A3Q zGr%{&`QRdOE|>|XfRSJako7Sj`$yroo`pq~_u=~q@Xkw`&$B%ooCEaU!Lwi$XajCc zcCf8$_nY7;`0fN}f?=93Y7SdzL6^&+VuETobm`9FcGUWcgoCk^f*1O>&;knzcdG=9!rWDX#Vz2l^oWygJmFYUZSz2e%Mt$B`wzf zgOPm!WLbA=lfWzUp55A&VsLtP|3Bp4IS44FNQdq@F|9)K~xNzXg?`Rm)hVFWcMawsAL?q zqvK~_Fo-m(b_;#`@;G+G$|~9>L@Kh4R@X@AJt)$-q}-yhq!`kSCB+at9;oWnv+~`O z_%9xK1^2#z&MCp7y2pUdfnn{AUt{SS*_O_zqIRIMq-*t^C|C}jV*3}+kswOEM;kIO z2EEgv_dNYR`mar6X?qAn6~%Bhh#gBP@$SQ*JHfB~?F)7QJ^V|uBVCdMqrrXb%WrpW z8ml(Ta~L!(28|`f;5|o|Y;P6W0;7P|UjG7B&b`>&eJgc`Qr7~aiq@fZJXG~vfM8y5 z$mLnbw8oR?yPr=y)GLO+M+HEe#*&_~#U2lS{ng%9Og4tY6U3mgq!mFIVP?0|QW1%&Ts2JYE|LL{nzk-)f_W9HiY%c*} zWet^j=1@_-swphr$Z=QTn)hfweP>U5J^x96gTAwe=3DsI z9;)}Pq(bjni8jb*UeKzSFTA8x=g|W0!e4}`xCFEh_2Mjcj5m!46+7p6@sbrUUTCx8 zg|=8)u(Z|Ec1QJl0D4br5@-h9?`csH{GN>Fk95(f3oDHIr$Ojyn^;MY1ypSdp3$69Yrn}a@eZTvU~ z-n>$4437qn`KW#Pmx0K&PUN*tegwDx+yRz?*T8C^_qtvO8c%nFE5Hn}O^z>o%`6AR zp!KWkKU^bIwY0fWLbGbpfbkJ-&O`p+@(h7lWO*gB<92)TVCd zGu%M$o1oe^86Tc1vD^M%SG-Yub5@aS03Z?z^a@L%R~SPn)0{=~-0WW>f+W4@HH@akAdn}Z*h(ic~ctuxCE zE0LIDhcU9>gFcp-gUf?@lI+Vj>{aEvU-XuJ4~y&xAniEl@%J#2#4jG~T$HFYQ!=6V zw4^?S@s*PUT>L_quBbZf=v$-LQ-&AIj?Rr>MwIQiHsn){1NfK5=?rT}t}53;^y<6C zik@#He;i13EWo#sj^PdL^aa^p#dc~L4Dtz7Iu0hIBQ^){yq`YuE(t=@xlrFEW?s;n zMGiC$BEL_k{oZopmmKm#z>AqD0EOtSY7Wq^Z{s4raZZvOTpf;{SCixbzUPwgIUe|x zgK0_pD8+;PQQ#$~QnHQ@$|`Y5Eg5xx-!SX zNu~5{kM-o>!r*w1+J|3rn2FnCtfhP=o>ceO@V`cmBL5~wDO}NKYueE%{<$!j56Oj3QzLxT!V}TgA`j&($JqPNu zeedrBVx0<>0R5KAI&d!->*pzV+Z|JS!BK@DA@B-6LeNF?mU_R>Ks&$AprSv7>(Qf umf0wv6l8_6PFB3)a*q|SwAkFsO_f$l+b!kq4SV&SmU_SRp_;kH;{O4op^ iconwin.go +ECHO. >> iconwin.go +TYPE %1 | %GOPATH%\bin\2goarray Data icon >> iconwin.go +GOTO DONE + +:CREATEFAIL +ECHO Unable to create output file +GOTO DONE + +:INSTALL +ECHO Installing 2goarray... +go get github.com/cratonica/2goarray +IF ERRORLEVEL 1 GOTO GETFAIL +GOTO POSTINSTALL + +:GETFAIL +ECHO Failure running go get github.com/cratonica/2goarray. Ensure that go and git are in PATH +GOTO DONE + +:NOGO +ECHO GOPATH environment variable not set +GOTO DONE + +:NOICO +ECHO Please specify a .ico file +GOTO DONE + +:BADFILE +ECHO %1 is not a valid file +GOTO DONE + +:DONE + diff --git a/icon/make_icon.sh b/icon/make_icon.sh new file mode 100644 index 0000000..e707373 --- /dev/null +++ b/icon/make_icon.sh @@ -0,0 +1,36 @@ +#/bin/sh + +if [ -z "$GOPATH" ]; then + echo GOPATH environment variable not set + exit +fi + +if [ ! -e "$GOPATH/bin/2goarray" ]; then + echo "Installing 2goarray..." + go get github.com/cratonica/2goarray + if [ $? -ne 0 ]; then + echo Failure executing go get github.com/cratonica/2goarray + exit + fi +fi + +if [ -z "$1" ]; then + echo Please specify a PNG file + exit +fi + +if [ ! -f "$1" ]; then + echo $1 is not a valid file + exit +fi + +OUTPUT=iconunix.go +echo Generating $OUTPUT +echo "//+build linux darwin" > $OUTPUT +echo >> $OUTPUT +cat "$1" | $GOPATH/bin/2goarray Data icon >> $OUTPUT +if [ $? -ne 0 ]; then + echo Failure generating $OUTPUT + exit +fi +echo Finished diff --git a/main.go b/main.go new file mode 100644 index 0000000..4596eb7 --- /dev/null +++ b/main.go @@ -0,0 +1,143 @@ +package main + +import ( + "github.com/atotto/clipboard" + "github.com/getlantern/systray" + "io/ioutil" + "log" + "math/rand" + "meow.tf/streamdeck-remote-server/icon" + "net/http" + "net/url" + "os" + "strings" + "time" +) + +func init() { + rand.Seed(time.Now().UnixNano()) +} + +func main() { + systray.Run(onReady, onExit) +} + +func onReady() { + systray.SetIcon(icon.Data) + systray.SetTitle("StreamDeck Remote") + systray.SetTooltip("StreamDeck Remote Server (Running)") + + mCopy := systray.AddMenuItem("Copy Token", "Copy the token to the clipboard") + mQuit := systray.AddMenuItem("Quit", "Close StreamDeck Remote Server") + + go listenServer() + + go func() { + for { + select { + case <-mCopy.ClickedCh: + clipboard.WriteAll(token) + case <-mQuit.ClickedCh: + systray.Quit() + } + } + }() +} + +func onExit() { + // clean up here +} + +var ( + token string +) + +func loadToken() { + if _, err := os.Stat("token.txt"); os.IsNotExist(err) { + token = RandStringRunes(32) + + f, err := os.Create("token.txt") + + if err != nil { + log.Fatalln("Unable to save token:", err) + } + + defer f.Close() + + f.WriteString(token) + } else { + f, err := os.Open("token.txt") + + if err != nil { + log.Fatalln("Unable to open token file:", err) + } + + defer f.Close() + + b, err := ioutil.ReadAll(f) + + if err != nil { + f.Close() + log.Fatalln("Unable to read token file:", err) + } + + token = string(b) + } +} + +func listenServer() { + loadToken() + + if _, err := os.Stat("server.crt"); os.IsNotExist(err) { + err = generateCert("server.crt", "server.key") + + if err != nil { + log.Fatalln("Unable to generate certificates:", err) + } + } + + mux := http.NewServeMux() + + mux.HandleFunc("/url/open", authenticationHandler(requirePost(urlOpenHandler))) + + http.ListenAndServeTLS(":4443", "server.crt", "server.key", mux) +} + +func authenticationHandler(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Check authorization header + auth := strings.SplitN(r.Header.Get("Authorization"), " ", 2) + + if len(auth) != 2 || auth[0] != "Bearer" || auth[1] != token { + log.Println("Authentication failed, got:", auth[1]) + http.Error(w, "authorization failed", http.StatusUnauthorized) + return + } + + next(w, r) + } +} + +func urlOpenHandler(w http.ResponseWriter, r *http.Request) { + r.ParseForm() + + urlStr := r.Form.Get("url") + + if urlStr == "" { + w.WriteHeader(http.StatusBadRequest) + log.Println("empty url") + return + } + + u, err := url.Parse(urlStr) + + if err != nil || u.Scheme == "" || u.Host == "" { + w.WriteHeader(http.StatusBadRequest) + log.Println("invalid url scheme/host or err:", err) + return + } + + log.Println("Opening URL", urlStr) + + openUrl(urlStr) +} \ No newline at end of file diff --git a/util.go b/util.go new file mode 100644 index 0000000..40c3049 --- /dev/null +++ b/util.go @@ -0,0 +1,38 @@ +package main + +import ( + "math/rand" + "net/http" +) + +func requireGet(h http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" { + h(w, r) + return + } + + http.Error(w, "get only", http.StatusMethodNotAllowed) + } +} + +func requirePost(h http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method == "POST" { + h(w, r) + return + } + + http.Error(w, "post only", http.StatusMethodNotAllowed) + } +} + +var letterRunes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") + +func RandStringRunes(n int) string { + b := make([]rune, n) + for i := range b { + b[i] = letterRunes[rand.Intn(len(letterRunes))] + } + return string(b) +} \ No newline at end of file