mirror of
				https://github.com/yggdrasil-network/yggdrasil-go.git
				synced 2025-11-04 11:15:07 +03:00 
			
		
		
		
	reorganize and add more to the admin
This commit is contained in:
		
							parent
							
								
									fe8a78f966
								
							
						
					
					
						commit
						2dd8152a0c
					
				
					 1 changed files with 264 additions and 156 deletions
				
			
		| 
						 | 
				
			
			@ -5,6 +5,7 @@ import "os"
 | 
			
		|||
import "bytes"
 | 
			
		||||
import "fmt"
 | 
			
		||||
import "sort"
 | 
			
		||||
import "strings"
 | 
			
		||||
 | 
			
		||||
// TODO? Make all of this JSON
 | 
			
		||||
// TODO: Add authentication
 | 
			
		||||
| 
						 | 
				
			
			@ -12,11 +13,45 @@ import "sort"
 | 
			
		|||
type admin struct {
 | 
			
		||||
	core       *Core
 | 
			
		||||
	listenaddr string
 | 
			
		||||
	handlers   []admin_handlerInfo
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type admin_handlerInfo struct {
 | 
			
		||||
	name    string                   // Checked against the first word of the api call
 | 
			
		||||
	args    []string                 // List of human-readable argument names
 | 
			
		||||
	handler func(*[]byte, ...string) // First arg is pointer to the out slice, rest is args
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (a *admin) addHandler(name string, args []string, handler func(*[]byte, ...string)) {
 | 
			
		||||
	a.handlers = append(a.handlers, admin_handlerInfo{name, args, handler})
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (a *admin) init(c *Core, listenaddr string) {
 | 
			
		||||
	a.core = c
 | 
			
		||||
	a.listenaddr = listenaddr
 | 
			
		||||
	a.addHandler("help", nil, func(out *[]byte, _ ...string) {
 | 
			
		||||
		for _, handler := range a.handlers {
 | 
			
		||||
			tmp := append([]string{handler.name}, handler.args...)
 | 
			
		||||
			*out = append(*out, []byte(strings.Join(tmp, " "))...)
 | 
			
		||||
			*out = append(*out, "\n"...)
 | 
			
		||||
		}
 | 
			
		||||
	})
 | 
			
		||||
	// TODO? have other parts of the program call to add their own handlers
 | 
			
		||||
	a.addHandler("dot", nil, func(out *[]byte, _ ...string) {
 | 
			
		||||
		*out = a.getResponse_dot()
 | 
			
		||||
	})
 | 
			
		||||
	a.addHandler("getSelf", nil, func(out *[]byte, _ ...string) {
 | 
			
		||||
		*out = []byte(a.printInfos(a.getData_getPeers()))
 | 
			
		||||
	})
 | 
			
		||||
	a.addHandler("getPeers", nil, func(out *[]byte, _ ...string) {
 | 
			
		||||
		*out = []byte(a.printInfos(a.getData_getPeers()))
 | 
			
		||||
	})
 | 
			
		||||
	a.addHandler("getDHT", nil, func(out *[]byte, _ ...string) {
 | 
			
		||||
		*out = []byte(a.printInfos(a.getData_getDHT()))
 | 
			
		||||
	})
 | 
			
		||||
	a.addHandler("getSessions", nil, func(out *[]byte, _ ...string) {
 | 
			
		||||
		*out = []byte(a.printInfos(a.getData_getSessions()))
 | 
			
		||||
	})
 | 
			
		||||
	go a.listen()
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -44,166 +79,239 @@ func (a *admin) handleRequest(conn net.Conn) {
 | 
			
		|||
		conn.Close()
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
	var out []byte
 | 
			
		||||
	buf = bytes.Trim(buf, "\x00\r\n\t")
 | 
			
		||||
	switch string(buf) {
 | 
			
		||||
	case "dot":
 | 
			
		||||
		const mDepth = 1024
 | 
			
		||||
		m := make(map[[mDepth]switchPort]string)
 | 
			
		||||
		table := a.core.switchTable.table.Load().(lookupTable)
 | 
			
		||||
		peers := a.core.peers.ports.Load().(map[switchPort]*peer)
 | 
			
		||||
 | 
			
		||||
		// Add my own entry
 | 
			
		||||
		peerID := address_addrForNodeID(getNodeID(&peers[0].box))
 | 
			
		||||
		myAddr := net.IP(peerID[:]).String()
 | 
			
		||||
		var index [mDepth]switchPort
 | 
			
		||||
		copy(index[:mDepth], table.self.coords[:])
 | 
			
		||||
		m[index] = myAddr
 | 
			
		||||
 | 
			
		||||
		// Connect switch table entries to peer entries
 | 
			
		||||
		for _, tableentry := range table.elems {
 | 
			
		||||
			for _, peerentry := range peers {
 | 
			
		||||
				if peerentry.port == tableentry.port {
 | 
			
		||||
					peerID := address_addrForNodeID(getNodeID(&peerentry.box))
 | 
			
		||||
					addr := net.IP(peerID[:]).String()
 | 
			
		||||
					var index [mDepth]switchPort
 | 
			
		||||
					copy(index[:], tableentry.locator.coords)
 | 
			
		||||
					m[index] = addr
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		getPorts := func(coords []byte) []switchPort {
 | 
			
		||||
			var ports []switchPort
 | 
			
		||||
			for offset := 0; ; {
 | 
			
		||||
				coord, length := wire_decode_uint64(coords[offset:])
 | 
			
		||||
				if length == 0 {
 | 
			
		||||
					break
 | 
			
		||||
				}
 | 
			
		||||
				ports = append(ports, switchPort(coord))
 | 
			
		||||
				offset += length
 | 
			
		||||
			}
 | 
			
		||||
			return ports
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Look up everything we know from DHT
 | 
			
		||||
		getDHT := func() {
 | 
			
		||||
			for i := 0; i < a.core.dht.nBuckets(); i++ {
 | 
			
		||||
				b := a.core.dht.getBucket(i)
 | 
			
		||||
				for _, v := range b.infos {
 | 
			
		||||
					destPorts := getPorts(v.coords)
 | 
			
		||||
					addr := net.IP(address_addrForNodeID(v.nodeID_hidden)[:]).String()
 | 
			
		||||
					var index [mDepth]switchPort
 | 
			
		||||
					copy(index[:], destPorts)
 | 
			
		||||
					m[index] = addr
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
		a.core.router.doAdmin(getDHT)
 | 
			
		||||
 | 
			
		||||
		// Look up everything we know from active sessions
 | 
			
		||||
		getSessions := func() {
 | 
			
		||||
			for _, sinfo := range a.core.sessions.sinfos {
 | 
			
		||||
				destPorts := getPorts(sinfo.coords)
 | 
			
		||||
				var index [mDepth]switchPort
 | 
			
		||||
				copy(index[:], destPorts)
 | 
			
		||||
				m[index] = net.IP(sinfo.theirAddr[:]).String()
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
		a.core.router.doAdmin(getSessions)
 | 
			
		||||
 | 
			
		||||
		// Start building a tree from all known nodes
 | 
			
		||||
		type nodeInfo struct {
 | 
			
		||||
			name   string
 | 
			
		||||
			key    [mDepth]switchPort
 | 
			
		||||
			parent [mDepth]switchPort
 | 
			
		||||
		}
 | 
			
		||||
		infos := make(map[[mDepth]switchPort]nodeInfo)
 | 
			
		||||
		// First fill the tree with all known nodes, no parents
 | 
			
		||||
		for k, n := range m {
 | 
			
		||||
			infos[k] = nodeInfo{
 | 
			
		||||
				name: n,
 | 
			
		||||
				key:  k,
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
		// Now go through and create placeholders for any missing nodes
 | 
			
		||||
		for _, info := range infos {
 | 
			
		||||
			for idx, port := range info.key {
 | 
			
		||||
				if port == 0 {
 | 
			
		||||
					break
 | 
			
		||||
				}
 | 
			
		||||
				var key [mDepth]switchPort
 | 
			
		||||
				copy(key[:idx], info.key[:])
 | 
			
		||||
				newInfo, isIn := infos[key]
 | 
			
		||||
				if isIn {
 | 
			
		||||
					continue
 | 
			
		||||
				}
 | 
			
		||||
				newInfo.name = "?"
 | 
			
		||||
				newInfo.key = key
 | 
			
		||||
				infos[key] = newInfo
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
		// Now go through and attach parents
 | 
			
		||||
		for _, info := range infos {
 | 
			
		||||
			info.parent = info.key
 | 
			
		||||
			for idx := len(info.parent) - 1; idx >= 0; idx-- {
 | 
			
		||||
				if info.parent[idx] != 0 {
 | 
			
		||||
					info.parent[idx] = 0
 | 
			
		||||
					break
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
			infos[info.key] = info
 | 
			
		||||
		}
 | 
			
		||||
		// Finally, get a sorted list of keys, which we use to organize the output
 | 
			
		||||
		var keys [][mDepth]switchPort
 | 
			
		||||
		for _, info := range infos {
 | 
			
		||||
			keys = append(keys, info.key)
 | 
			
		||||
		}
 | 
			
		||||
		less := func(i, j int) bool {
 | 
			
		||||
			for idx := range keys[i] {
 | 
			
		||||
				if keys[i][idx] < keys[j][idx] {
 | 
			
		||||
					return true
 | 
			
		||||
				}
 | 
			
		||||
				if keys[i][idx] > keys[j][idx] {
 | 
			
		||||
					return false
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
			return false
 | 
			
		||||
		}
 | 
			
		||||
		sort.Slice(keys, less)
 | 
			
		||||
		// Now print it all out
 | 
			
		||||
		conn.Write([]byte(fmt.Sprintf("digraph {\n")))
 | 
			
		||||
		// First set the labels
 | 
			
		||||
		for _, key := range keys {
 | 
			
		||||
			info := infos[key]
 | 
			
		||||
			if info.name == myAddr {
 | 
			
		||||
				conn.Write([]byte(fmt.Sprintf("\"%v\" [ style = \"filled\", label = \"%v\" ];\n", info.key, info.name)))
 | 
			
		||||
			} else {
 | 
			
		||||
				conn.Write([]byte(fmt.Sprintf("\"%v\" [ label = \"%v\" ];\n", info.key, info.name)))
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
		// Then print the tree structure
 | 
			
		||||
		for _, key := range keys {
 | 
			
		||||
			info := infos[key]
 | 
			
		||||
			if info.key == info.parent {
 | 
			
		||||
				continue
 | 
			
		||||
			} // happens for the root, skip it
 | 
			
		||||
			for idx := len(info.key) - 1; idx >= 0; idx-- {
 | 
			
		||||
				port := info.key[idx]
 | 
			
		||||
				if port == 0 {
 | 
			
		||||
					continue
 | 
			
		||||
				}
 | 
			
		||||
				conn.Write([]byte(fmt.Sprintf("  \"%+v\" -> \"%+v\" [ label = \"%v\" ];\n", info.parent, info.key, port)))
 | 
			
		||||
				break
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
		conn.Write([]byte(fmt.Sprintf("}\n")))
 | 
			
		||||
		break
 | 
			
		||||
 | 
			
		||||
	default:
 | 
			
		||||
		conn.Write([]byte("I didn't understand that!\n"))
 | 
			
		||||
	call := strings.Split(string(buf), " ")
 | 
			
		||||
	var cmd string
 | 
			
		||||
	var args []string
 | 
			
		||||
	if len(call) > 0 {
 | 
			
		||||
		cmd = call[0]
 | 
			
		||||
		args = call[1:]
 | 
			
		||||
	}
 | 
			
		||||
	done := false
 | 
			
		||||
	for _, handler := range a.handlers {
 | 
			
		||||
		if cmd == handler.name {
 | 
			
		||||
			handler.handler(&out, args...)
 | 
			
		||||
			done = true
 | 
			
		||||
			break
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	if !done {
 | 
			
		||||
		out = []byte("I didn't understand that!\n")
 | 
			
		||||
	}
 | 
			
		||||
	_, err = conn.Write(out)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		a.core.log.Printf("Admin socket error: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
	conn.Close()
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Maps things like "IP", "port", "bucket", or "coords" onto strings
 | 
			
		||||
type admin_pair struct {
 | 
			
		||||
	key string
 | 
			
		||||
	val string
 | 
			
		||||
}
 | 
			
		||||
type admin_nodeInfo []admin_pair
 | 
			
		||||
 | 
			
		||||
func (n *admin_nodeInfo) asMap() map[string]string {
 | 
			
		||||
	m := make(map[string]string, len(*n))
 | 
			
		||||
	for _, p := range *n {
 | 
			
		||||
		m[p.key] = p.val
 | 
			
		||||
	}
 | 
			
		||||
	return m
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (n *admin_nodeInfo) toString() string {
 | 
			
		||||
	// TODO return something nicer looking than this
 | 
			
		||||
	var out []string
 | 
			
		||||
	for _, p := range *n {
 | 
			
		||||
		out = append(out, fmt.Sprintf("%v: %v", p.key, p.val))
 | 
			
		||||
	}
 | 
			
		||||
	return strings.Join(out, ", ")
 | 
			
		||||
	return fmt.Sprint(*n)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (a *admin) printInfos(infos []admin_nodeInfo) string {
 | 
			
		||||
	var out []string
 | 
			
		||||
	for _, info := range infos {
 | 
			
		||||
		out = append(out, info.toString())
 | 
			
		||||
	}
 | 
			
		||||
	out = append(out, "") // To add a trailing "\n" in the join
 | 
			
		||||
	return strings.Join(out, "\n")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (a *admin) getData_getSelf() *admin_nodeInfo {
 | 
			
		||||
	table := a.core.switchTable.table.Load().(lookupTable)
 | 
			
		||||
	addr := a.core.router.addr
 | 
			
		||||
	coords := table.self.getCoords()
 | 
			
		||||
	self := admin_nodeInfo{
 | 
			
		||||
		{"IP", net.IP(addr[:]).String()},
 | 
			
		||||
		{"coords", fmt.Sprint(coords)},
 | 
			
		||||
	}
 | 
			
		||||
	return &self
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (a *admin) getData_getPeers() []admin_nodeInfo {
 | 
			
		||||
	var peerInfos []admin_nodeInfo
 | 
			
		||||
	table := a.core.switchTable.table.Load().(lookupTable)
 | 
			
		||||
	peers := a.core.peers.ports.Load().(map[switchPort]*peer)
 | 
			
		||||
	for _, elem := range table.elems {
 | 
			
		||||
		peer, isIn := peers[elem.port]
 | 
			
		||||
		if !isIn {
 | 
			
		||||
			continue
 | 
			
		||||
		}
 | 
			
		||||
		addr := *address_addrForNodeID(getNodeID(&peer.box))
 | 
			
		||||
		coords := elem.locator.getCoords()
 | 
			
		||||
		info := admin_nodeInfo{
 | 
			
		||||
			{"IP", net.IP(addr[:]).String()},
 | 
			
		||||
			{"coords", fmt.Sprint(coords)},
 | 
			
		||||
			{"port", fmt.Sprint(elem.port)},
 | 
			
		||||
		}
 | 
			
		||||
		peerInfos = append(peerInfos, info)
 | 
			
		||||
	}
 | 
			
		||||
	return peerInfos
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (a *admin) getData_getDHT() []admin_nodeInfo {
 | 
			
		||||
	var infos []admin_nodeInfo
 | 
			
		||||
	getDHT := func() {
 | 
			
		||||
		for i := 0; i < a.core.dht.nBuckets(); i++ {
 | 
			
		||||
			b := a.core.dht.getBucket(i)
 | 
			
		||||
			for _, v := range b.infos {
 | 
			
		||||
				addr := *address_addrForNodeID(v.getNodeID())
 | 
			
		||||
				info := admin_nodeInfo{
 | 
			
		||||
					{"IP", net.IP(addr[:]).String()},
 | 
			
		||||
					{"coords", fmt.Sprint(v.coords)},
 | 
			
		||||
					{"bucket", fmt.Sprint(i)},
 | 
			
		||||
				}
 | 
			
		||||
				infos = append(infos, info)
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	a.core.router.doAdmin(getDHT)
 | 
			
		||||
	return infos
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (a *admin) getData_getSessions() []admin_nodeInfo {
 | 
			
		||||
	var infos []admin_nodeInfo
 | 
			
		||||
	getSessions := func() {
 | 
			
		||||
		for _, sinfo := range a.core.sessions.sinfos {
 | 
			
		||||
			// TODO? skipped known but timed out sessions?
 | 
			
		||||
			info := admin_nodeInfo{
 | 
			
		||||
				{"IP", net.IP(sinfo.theirAddr[:]).String()},
 | 
			
		||||
				{"coords", fmt.Sprint(sinfo.coords)},
 | 
			
		||||
			}
 | 
			
		||||
			infos = append(infos, info)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	a.core.router.doAdmin(getSessions)
 | 
			
		||||
	return infos
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (a *admin) getResponse_dot() []byte {
 | 
			
		||||
	self := a.getData_getSelf().asMap()
 | 
			
		||||
	myAddr := self["IP"]
 | 
			
		||||
	peers := a.getData_getPeers()
 | 
			
		||||
	dht := a.getData_getDHT()
 | 
			
		||||
	sessions := a.getData_getSessions()
 | 
			
		||||
	// Map of coords onto IP
 | 
			
		||||
	m := make(map[string]string)
 | 
			
		||||
	m[self["coords"]] = self["IP"]
 | 
			
		||||
	for _, peer := range peers {
 | 
			
		||||
		p := peer.asMap()
 | 
			
		||||
		m[p["coords"]] = p["IP"]
 | 
			
		||||
	}
 | 
			
		||||
	for _, node := range dht {
 | 
			
		||||
		n := node.asMap()
 | 
			
		||||
		m[n["coords"]] = n["IP"]
 | 
			
		||||
	}
 | 
			
		||||
	for _, node := range sessions {
 | 
			
		||||
		n := node.asMap()
 | 
			
		||||
		m[n["coords"]] = n["IP"]
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Start building a tree from all known nodes
 | 
			
		||||
	type nodeInfo struct {
 | 
			
		||||
		name   string
 | 
			
		||||
		key    string
 | 
			
		||||
		parent string
 | 
			
		||||
	}
 | 
			
		||||
	infos := make(map[string]nodeInfo)
 | 
			
		||||
	// First fill the tree with all known nodes, no parents
 | 
			
		||||
	for k, n := range m {
 | 
			
		||||
		infos[k] = nodeInfo{
 | 
			
		||||
			name: n,
 | 
			
		||||
			key:  k,
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	// Get coords as a slice of strings, FIXME? this looks very fragile
 | 
			
		||||
	coordSlice := func(coords string) []string {
 | 
			
		||||
		tmp := strings.Replace(coords, "[", "", -1)
 | 
			
		||||
		tmp = strings.Replace(tmp, "]", "", -1)
 | 
			
		||||
		return strings.Split(tmp, " ")
 | 
			
		||||
	}
 | 
			
		||||
	// Now go through and create placeholders for any missing nodes
 | 
			
		||||
	for _, info := range infos {
 | 
			
		||||
		// This is ugly string manipulation
 | 
			
		||||
		coordsSplit := coordSlice(info.key)
 | 
			
		||||
		for idx := range coordsSplit {
 | 
			
		||||
			key := fmt.Sprintf("[%v]", strings.Join(coordsSplit[:idx], " "))
 | 
			
		||||
			newInfo, isIn := infos[key]
 | 
			
		||||
			if isIn {
 | 
			
		||||
				continue
 | 
			
		||||
			}
 | 
			
		||||
			newInfo.name = "?"
 | 
			
		||||
			newInfo.key = key
 | 
			
		||||
			infos[key] = newInfo
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	// Now go through and attach parents
 | 
			
		||||
	for _, info := range infos {
 | 
			
		||||
		pSplit := coordSlice(info.key)
 | 
			
		||||
		if len(pSplit) > 0 {
 | 
			
		||||
			pSplit = pSplit[:len(pSplit)-1]
 | 
			
		||||
		}
 | 
			
		||||
		info.parent = fmt.Sprintf("[%v]", strings.Join(pSplit, " "))
 | 
			
		||||
		infos[info.key] = info
 | 
			
		||||
	}
 | 
			
		||||
	// Finally, get a sorted list of keys, which we use to organize the output
 | 
			
		||||
	var keys []string
 | 
			
		||||
	for _, info := range infos {
 | 
			
		||||
		keys = append(keys, info.key)
 | 
			
		||||
	}
 | 
			
		||||
	// TODO sort
 | 
			
		||||
	less := func(i, j int) bool {
 | 
			
		||||
		return keys[i] < keys[j]
 | 
			
		||||
	}
 | 
			
		||||
	sort.Slice(keys, less)
 | 
			
		||||
	// Now print it all out
 | 
			
		||||
	var out []byte
 | 
			
		||||
	put := func(s string) {
 | 
			
		||||
		out = append(out, []byte(s)...)
 | 
			
		||||
	}
 | 
			
		||||
	put("digraph {\n")
 | 
			
		||||
	// First set the labels
 | 
			
		||||
	for _, key := range keys {
 | 
			
		||||
		info := infos[key]
 | 
			
		||||
		if info.name == myAddr {
 | 
			
		||||
			put(fmt.Sprintf("\"%v\" [ style = \"filled\", label = \"%v\" ];\n", info.key, info.name))
 | 
			
		||||
		} else {
 | 
			
		||||
			put(fmt.Sprintf("\"%v\" [ label = \"%v\" ];\n", info.key, info.name))
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	// Then print the tree structure
 | 
			
		||||
	for _, key := range keys {
 | 
			
		||||
		info := infos[key]
 | 
			
		||||
		if info.key == info.parent {
 | 
			
		||||
			continue
 | 
			
		||||
		} // happens for the root, skip it
 | 
			
		||||
		coordsSplit := coordSlice(key)
 | 
			
		||||
		if len(coordsSplit) == 0 {
 | 
			
		||||
			continue
 | 
			
		||||
		}
 | 
			
		||||
		port := coordsSplit[len(coordsSplit)-1]
 | 
			
		||||
		put(fmt.Sprintf("  \"%+v\" -> \"%+v\" [ label = \"%v\" ];\n", info.parent, info.key, port))
 | 
			
		||||
	}
 | 
			
		||||
	put("}\n")
 | 
			
		||||
	return out
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue