mirror of
https://github.com/FrankerFaceZ/FrankerFaceZ.git
synced 2025-09-16 10:06:54 +00:00
Switch to nacl cryptobox
This commit is contained in:
parent
401f66f15b
commit
c6a3c120c6
6 changed files with 144 additions and 60 deletions
|
@ -2,9 +2,9 @@ package main // import "bitbucket.org/stendec/frankerfacez/socketserver/cmd/sock
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"flag"
|
"flag"
|
||||||
"../../internal/server"
|
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"../../internal/server"
|
||||||
)
|
)
|
||||||
|
|
||||||
var origin *string = flag.String("origin", "localhost:8001", "Client-visible origin of the socket server")
|
var origin *string = flag.String("origin", "localhost:8001", "Client-visible origin of the socket server")
|
||||||
|
@ -12,14 +12,18 @@ var bindAddress *string = flag.String("listen", "", "Address to bind to, if diff
|
||||||
var usessl *bool = flag.Bool("ssl", false, "Enable the use of SSL for connecting clients and backend connections")
|
var usessl *bool = flag.Bool("ssl", false, "Enable the use of SSL for connecting clients and backend connections")
|
||||||
var certificateFile *string = flag.String("crt", "ssl.crt", "CA-signed SSL certificate file")
|
var certificateFile *string = flag.String("crt", "ssl.crt", "CA-signed SSL certificate file")
|
||||||
var privateKeyFile *string = flag.String("key", "ssl.key", "SSL private key file")
|
var privateKeyFile *string = flag.String("key", "ssl.key", "SSL private key file")
|
||||||
var backendRootFile *string = flag.String("peerroot", "backend_issuer.pem", "Root certificate that issued client certificates for backend servers")
|
|
||||||
var backendCertFile *string = flag.String("peercrt", "backend_cert.crt", "Backend-trusted certificate, for use as a client certificate")
|
var naclKeysFile *string = flag.String("naclkey", "naclkeys.json", "Keypairs for the NaCl crypto library, for communicating with the backend.")
|
||||||
var backendKeyFile *string = flag.String("peerkey", "backend_cert.key", "Private key for backend-trusted certificate, for use as a client certificate")
|
var generateKeys *bool = flag.Bool("genkeys", false, "Generate NaCl keys instead of serving requests.\nArguments: [int serverId] [base64 backendPublic]\nThe backend public key can either be specified in base64 on the command line, or put in the json file later.")
|
||||||
var basicAuthPwd *string = flag.String("password", "", "Password for HTTP Basic Auth") // TODO
|
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
|
if *generateKeys {
|
||||||
|
GenerateKeys(*naclKeysFile)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if *origin == "" {
|
if *origin == "" {
|
||||||
log.Fatalln("--origin argument required")
|
log.Fatalln("--origin argument required")
|
||||||
}
|
}
|
||||||
|
@ -33,23 +37,21 @@ func main() {
|
||||||
conf := &server.Config {
|
conf := &server.Config {
|
||||||
SSLKeyFile: *privateKeyFile,
|
SSLKeyFile: *privateKeyFile,
|
||||||
SSLCertificateFile: *certificateFile,
|
SSLCertificateFile: *certificateFile,
|
||||||
UseSSL: *certificateFile != "",
|
UseSSL: *usessl,
|
||||||
BackendRootCertFile: *backendRootFile,
|
NaclKeysFile: *naclKeysFile,
|
||||||
BackendClientCertFile: *backendCertFile,
|
|
||||||
BackendClientKeyFile: *backendKeyFile,
|
|
||||||
|
|
||||||
SocketOrigin: *origin,
|
SocketOrigin: *origin,
|
||||||
}
|
}
|
||||||
|
|
||||||
httpServer := &http.Server{
|
httpServer := &http.Server{
|
||||||
Addr: *bindAddress
|
Addr: *bindAddress,
|
||||||
}
|
}
|
||||||
|
|
||||||
server.SetupServerAndHandle(conf, httpServer.TLSConfig)
|
server.SetupServerAndHandle(conf, httpServer.TLSConfig)
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
if conf.UseSSL {
|
if conf.UseSSL {
|
||||||
err = httpServer.ListenAndServeTLS(nil, nil)
|
err = httpServer.ListenAndServeTLS(*certificateFile, *privateKeyFile)
|
||||||
} else {
|
} else {
|
||||||
err = httpServer.ListenAndServe()
|
err = httpServer.ListenAndServe()
|
||||||
}
|
}
|
||||||
|
@ -58,3 +60,15 @@ func main() {
|
||||||
log.Fatal("ListenAndServe: ", err)
|
log.Fatal("ListenAndServe: ", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func GenerateKeys(outputFile string) {
|
||||||
|
if flag.NArg() < 1 {
|
||||||
|
log.Fatal("The server ID must be specified")
|
||||||
|
}
|
||||||
|
if flag.NArg() >= 2 {
|
||||||
|
server.GenerateKeys(outputFile, flag.Arg(0), flag.Arg(1))
|
||||||
|
} else {
|
||||||
|
server.GenerateKeys(outputFile, flag.Arg(0), "")
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,6 +1,7 @@
|
||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"golang.org/x/crypto/nacl/box"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
@ -9,9 +10,12 @@ import (
|
||||||
"strconv"
|
"strconv"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"crypto/tls"
|
"sync"
|
||||||
"crypto/x509"
|
|
||||||
"log"
|
"log"
|
||||||
|
"os"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
var backendHttpClient http.Client
|
var backendHttpClient http.Client
|
||||||
|
@ -20,6 +24,10 @@ var responseCache *cache.Cache
|
||||||
|
|
||||||
var getBacklogUrl string
|
var getBacklogUrl string
|
||||||
|
|
||||||
|
var backendSharedKey [32]byte
|
||||||
|
|
||||||
|
var messageBufferPool sync.Pool
|
||||||
|
|
||||||
func SetupBackend(config *Config) {
|
func SetupBackend(config *Config) {
|
||||||
backendHttpClient.Timeout = 60 * time.Second
|
backendHttpClient.Timeout = 60 * time.Second
|
||||||
backendUrl = config.BackendUrl
|
backendUrl = config.BackendUrl
|
||||||
|
@ -29,26 +37,45 @@ func SetupBackend(config *Config) {
|
||||||
responseCache = cache.New(60 * time.Second, 120 * time.Second)
|
responseCache = cache.New(60 * time.Second, 120 * time.Second)
|
||||||
|
|
||||||
getBacklogUrl = fmt.Sprintf("%s/backlog", backendUrl)
|
getBacklogUrl = fmt.Sprintf("%s/backlog", backendUrl)
|
||||||
}
|
|
||||||
|
|
||||||
func SetupBackendCertificates(config *Config, certPool x509.CertPool) {
|
messageBufferPool.New = NewByteBuffer
|
||||||
myCert, err := tls.LoadX509KeyPair(config.BackendClientCertFile, config.BackendClientKeyFile)
|
|
||||||
|
var keys CryptoKeysBuf
|
||||||
|
file, err := os.Open(config.NaclKeysFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
tlsConfig := tls.Config{
|
dec := json.NewDecoder(file)
|
||||||
Certificates: []tls.Certificate{myCert},
|
err = dec.Decode(&keys)
|
||||||
RootCAs: certPool,
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
tlsConfig.BuildNameToCertificate()
|
|
||||||
transport := &http.Transport{TLSClientConfig: tlsConfig}
|
box.Precompute(&backendSharedKey, &keys.TheirPublicKey, &keys.OurPrivateKey)
|
||||||
backendHttpClient.Transport = transport
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func getCacheKey(remoteCommand, data string) string {
|
func getCacheKey(remoteCommand, data string) string {
|
||||||
return fmt.Sprintf("%s/%s", remoteCommand, data)
|
return fmt.Sprintf("%s/%s", remoteCommand, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func SealRequest(form url.Values) ([]byte, error) {
|
||||||
|
asString := form.Encode()
|
||||||
|
var nonce [24]byte
|
||||||
|
var err error
|
||||||
|
|
||||||
|
err = FillCryptoRandom(nonce[:])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
message := []byte(asString)
|
||||||
|
out := make([]byte, len(message) + box.Overhead)
|
||||||
|
box.SealAfterPrecomputation(out, message, &nonce, &backendSharedKey)
|
||||||
|
|
||||||
|
// TODO
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
func RequestRemoteDataCached(remoteCommand, data string, auth AuthInfo) (string, error) {
|
func RequestRemoteDataCached(remoteCommand, data string, auth AuthInfo) (string, error) {
|
||||||
cached, ok := responseCache.Get(getCacheKey(remoteCommand, data))
|
cached, ok := responseCache.Get(getCacheKey(remoteCommand, data))
|
||||||
if ok {
|
if ok {
|
||||||
|
@ -57,7 +84,7 @@ func RequestRemoteDataCached(remoteCommand, data string, auth AuthInfo) (string,
|
||||||
return RequestRemoteData(remoteCommand, data, auth)
|
return RequestRemoteData(remoteCommand, data, auth)
|
||||||
}
|
}
|
||||||
|
|
||||||
func RequestRemoteData(remoteCommand, data string, auth AuthInfo) (string, error) {
|
func RequestRemoteData(remoteCommand, data string, auth AuthInfo) (responseStr string, err error) {
|
||||||
destUrl := fmt.Sprintf("%s/cmd/%s", backendUrl, remoteCommand)
|
destUrl := fmt.Sprintf("%s/cmd/%s", backendUrl, remoteCommand)
|
||||||
var authKey string
|
var authKey string
|
||||||
if auth.UsernameValidated {
|
if auth.UsernameValidated {
|
||||||
|
@ -70,9 +97,6 @@ func RequestRemoteData(remoteCommand, data string, auth AuthInfo) (string, error
|
||||||
"clientData": []string{data},
|
"clientData": []string{data},
|
||||||
authKey: []string{auth.TwitchUsername},
|
authKey: []string{auth.TwitchUsername},
|
||||||
}
|
}
|
||||||
if gconfig.BasicAuthPassword != "" {
|
|
||||||
formData["password"] = gconfig.BasicAuthPassword
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := backendHttpClient.PostForm(destUrl, formData)
|
resp, err := backendHttpClient.PostForm(destUrl, formData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -85,7 +109,7 @@ func RequestRemoteData(remoteCommand, data string, auth AuthInfo) (string, error
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
responseJson := string(respBytes)
|
responseStr = string(respBytes)
|
||||||
|
|
||||||
if resp.Header.Get("FFZ-Cache") != "" {
|
if resp.Header.Get("FFZ-Cache") != "" {
|
||||||
durSecs, err := strconv.ParseInt(resp.Header.Get("FFZ-Cache"), 10, 64)
|
durSecs, err := strconv.ParseInt(resp.Header.Get("FFZ-Cache"), 10, 64)
|
||||||
|
@ -93,10 +117,10 @@ func RequestRemoteData(remoteCommand, data string, auth AuthInfo) (string, error
|
||||||
return "", fmt.Errorf("The RPC server returned a non-integer cache duration: %v", err)
|
return "", fmt.Errorf("The RPC server returned a non-integer cache duration: %v", err)
|
||||||
}
|
}
|
||||||
duration := time.Duration(durSecs) * time.Second
|
duration := time.Duration(durSecs) * time.Second
|
||||||
responseCache.Set(getCacheKey(remoteCommand, data), responseJson, duration)
|
responseCache.Set(getCacheKey(remoteCommand, data), responseStr, duration)
|
||||||
}
|
}
|
||||||
|
|
||||||
return responseJson, nil
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
func FetchBacklogData(chatSubs, channelSubs []string) ([]ClientMessage, error) {
|
func FetchBacklogData(chatSubs, channelSubs []string) ([]ClientMessage, error) {
|
||||||
|
@ -118,3 +142,42 @@ func FetchBacklogData(chatSubs, channelSubs []string) ([]ClientMessage, error) {
|
||||||
|
|
||||||
return messages, nil
|
return messages, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func GenerateKeys(outputFile, serverId, theirPublicStr string) {
|
||||||
|
var err error
|
||||||
|
output := CryptoKeysBuf{}
|
||||||
|
|
||||||
|
output.ServerId, err = strconv.Atoi(serverId)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ourPublic, ourPrivate, err := box.GenerateKey(rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
output.OurPublicKey, output.OurPrivateKey = *ourPublic, *ourPrivate
|
||||||
|
|
||||||
|
if theirPublicStr != "" {
|
||||||
|
reader := base64.NewDecoder(base64.RawURLEncoding, strings.NewReader(theirPublicStr))
|
||||||
|
theirPublic, err := ioutil.ReadAll(reader)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
copy(output.TheirPublicKey[:], theirPublic)
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := os.Create(outputFile)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
enc := json.NewEncoder(file)
|
||||||
|
err = enc.Encode(output)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
err = file.Close()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -10,24 +10,19 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"sync"
|
"sync"
|
||||||
"crypto/x509"
|
"log"
|
||||||
"io/ioutil"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const MAX_PACKET_SIZE = 1024
|
const MAX_PACKET_SIZE = 1024
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
// SSL
|
// SSL/TLS
|
||||||
SSLCertificateFile string
|
SSLCertificateFile string
|
||||||
SSLKeyFile string
|
SSLKeyFile string
|
||||||
UseSSL bool
|
UseSSL bool
|
||||||
|
|
||||||
// CA for client validation (pub/sub commands only)
|
// NaCl keys for backend messages
|
||||||
BackendRootCertFile string
|
NaclKeysFile string
|
||||||
BackendClientCertFile string
|
|
||||||
BackendClientKeyFile string
|
|
||||||
// Password for client validation (pub/sub commands only)
|
|
||||||
BasicAuthPassword string
|
|
||||||
|
|
||||||
// Hostname of the socket server
|
// Hostname of the socket server
|
||||||
SocketOrigin string
|
SocketOrigin string
|
||||||
|
@ -93,7 +88,7 @@ func setupServer(config *Config, tlsConfig *tls.Config) *websocket.Server {
|
||||||
gconfig = config
|
gconfig = config
|
||||||
sockConf, err := websocket.NewConfig("/", config.SocketOrigin)
|
sockConf, err := websocket.NewConfig("/", config.SocketOrigin)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
SetupBackend(config)
|
SetupBackend(config)
|
||||||
|
@ -101,25 +96,13 @@ func setupServer(config *Config, tlsConfig *tls.Config) *websocket.Server {
|
||||||
if config.UseSSL {
|
if config.UseSSL {
|
||||||
cert, err := tls.LoadX509KeyPair(config.SSLCertificateFile, config.SSLKeyFile)
|
cert, err := tls.LoadX509KeyPair(config.SSLCertificateFile, config.SSLKeyFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
tlsConfig.Certificates = []tls.Certificate{cert}
|
tlsConfig.Certificates = []tls.Certificate{cert}
|
||||||
tlsConfig.ServerName = config.SocketOrigin
|
tlsConfig.ServerName = config.SocketOrigin
|
||||||
tlsConfig.BuildNameToCertificate()
|
tlsConfig.BuildNameToCertificate()
|
||||||
sockConf.TlsConfig = tlsConfig
|
sockConf.TlsConfig = tlsConfig
|
||||||
|
|
||||||
certBytes, err := ioutil.ReadFile(config.BackendRootCertFile)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
clientCA, err := x509.ParseCertificate(certBytes)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
certPool := x509.NewCertPool()
|
|
||||||
certPool.AddCert(clientCA)
|
|
||||||
tlsConfig.ClientCAs = certPool
|
|
||||||
SetupBackendCertificates(config, certPool)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
sockServer := &websocket.Server{}
|
sockServer := &websocket.Server{}
|
||||||
|
@ -214,7 +197,7 @@ func HandleSocketConnection(conn *websocket.Conn) {
|
||||||
|
|
||||||
// Launch message draining goroutine - we aren't out of the pub/sub records
|
// Launch message draining goroutine - we aren't out of the pub/sub records
|
||||||
go func() {
|
go func() {
|
||||||
for _ := range _serverMessageChan {}
|
for _ = range _serverMessageChan {}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Stop getting messages...
|
// Stop getting messages...
|
||||||
|
|
|
@ -46,9 +46,7 @@ func PublishToWatchers(channel string, msg ClientMessage) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func HandlePublishRequest(w http.ResponseWriter, r *http.Request) {
|
func HandlePublishRequest(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.TLS {
|
// TODO - box.Open()
|
||||||
PeerCertificates
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add a channel to the subscriptions while holding a read-lock to the map.
|
// Add a channel to the subscriptions while holding a read-lock to the map.
|
||||||
|
@ -63,7 +61,7 @@ func _subscribeWhileRlocked(which map[string]*SubscriberList, channelName string
|
||||||
rlocker.Unlock()
|
rlocker.Unlock()
|
||||||
wlocker.Lock()
|
wlocker.Lock()
|
||||||
list = &SubscriberList{}
|
list = &SubscriberList{}
|
||||||
list.Members = &[]chan <- ClientMessage{value} // Create it populated, to avoid reaper
|
list.Members = []chan <- ClientMessage{value} // Create it populated, to avoid reaper
|
||||||
which[channelName] = list
|
which[channelName] = list
|
||||||
wlocker.Unlock()
|
wlocker.Unlock()
|
||||||
rlocker.Lock()
|
rlocker.Lock()
|
||||||
|
@ -84,7 +82,7 @@ func SubscribeBatch(client *ClientInfo, chatSubs, channelSubs []string) {
|
||||||
rlocker := ChatSubscriptionLock.RLocker()
|
rlocker := ChatSubscriptionLock.RLocker()
|
||||||
rlocker.Lock()
|
rlocker.Lock()
|
||||||
for _, v := range chatSubs {
|
for _, v := range chatSubs {
|
||||||
_subscribeWhileRlocked(ChatSubscriptionInfo, v, mchan, rlocker, ChatSubscriptionLock)
|
_subscribeWhileRlocked(ChatSubscriptionInfo, v, mchan, rlocker, &ChatSubscriptionLock)
|
||||||
}
|
}
|
||||||
rlocker.Unlock()
|
rlocker.Unlock()
|
||||||
}
|
}
|
||||||
|
@ -92,7 +90,7 @@ func SubscribeBatch(client *ClientInfo, chatSubs, channelSubs []string) {
|
||||||
rlocker := WatchingSubscriptionLock.RLocker()
|
rlocker := WatchingSubscriptionLock.RLocker()
|
||||||
rlocker.Lock()
|
rlocker.Lock()
|
||||||
for _, v := range channelSubs {
|
for _, v := range channelSubs {
|
||||||
_subscribeWhileRlocked(WatchingSubscriptionInfo, v, mchan, rlocker, WatchingSubscriptionLock)
|
_subscribeWhileRlocked(WatchingSubscriptionInfo, v, mchan, rlocker, &WatchingSubscriptionLock)
|
||||||
}
|
}
|
||||||
rlocker.Unlock()
|
rlocker.Unlock()
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,6 +6,15 @@ import (
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const CryptoBoxKeyLength = 32
|
||||||
|
|
||||||
|
type CryptoKeysBuf struct {
|
||||||
|
OurPrivateKey [CryptoBoxKeyLength]byte
|
||||||
|
OurPublicKey [CryptoBoxKeyLength]byte
|
||||||
|
TheirPublicKey [CryptoBoxKeyLength]byte
|
||||||
|
ServerId int
|
||||||
|
}
|
||||||
|
|
||||||
type ClientMessage struct {
|
type ClientMessage struct {
|
||||||
// Message ID. Increments by 1 for each message sent from the client.
|
// Message ID. Increments by 1 for each message sent from the client.
|
||||||
// When replying to a command, the message ID must be echoed.
|
// When replying to a command, the message ID must be echoed.
|
||||||
|
|
|
@ -1,8 +1,25 @@
|
||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/rand"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func FillCryptoRandom(buf []byte) error {
|
||||||
|
remaining := len(buf)
|
||||||
|
for remaining > 0 {
|
||||||
|
count, err := rand.Read(buf)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
remaining -= count
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewByteBuffer() interface{} {
|
||||||
|
return make([]byte, 1024)
|
||||||
|
}
|
||||||
|
|
||||||
func AddToSliceS(ary *[]string, val string) bool {
|
func AddToSliceS(ary *[]string, val string) bool {
|
||||||
slice := *ary
|
slice := *ary
|
||||||
for _, v := range slice {
|
for _, v := range slice {
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue