MENU

【代码札记】Golang项目实践

January 12, 2021 • 瞎折腾

想来想去2021年了,什么都不写也说不过去,可是写吧,又没什么能写的。放假在家技术上没什么可写的,总不能把昨天用冲击钻打瓷砖装浴霸的过程写上来;生活上,每天在学日语、看数学、吹笛子、看Youtube和B站、肝Switch这几件事儿中循环往复。偶尔干点别的事情,无外乎就是跟着别人的教程操作,没什么可以写的。

百无聊赖,最终决定把之前用Rust写的服务器远程开机的工具用Golang重写了一遍。

之前说过有尝试入门Rust,虽然Rust标榜最安全的系统级语言,但我认为在操作系统方面,C/C++的地位是不可动摇的:他们能够直接触及底层,灵活的操作各种硬件。而Rust,在我看来为了安全性(利用所有权机制从源头避免内存泄漏)牺牲了太多的灵活性。早年间试图用Rust写数据结构的实验,结果一个双向链表就给我劝退了。所以我觉得我的Rust之路就可以到此为止了。但是之前已然都用Rust写了一个工具,想来想去还是应该用熟悉的语言重写一遍比较好。

这个工具是之前用来做远程开机的,当时也有发文章:「【代码札记】命令行控制 ASMB8-ikvm 远程管理卡开启服务器」。目前这个服务器跑的是Unraid,以前的文章也说了,床底下的服务器性能虽然强劲,可动静也是真大,因此不能全天候运转(所以想卖了换今年新出的HP MicroServer Gen10 plus,真香啊)。今天想到的办法就是利用挂接在路由器上的树莓派做定时任务,每天早上8点开机,然后在Unraid里配置定时任务,在晚上自动关机。同时为了避免脚本因为系统更新而失效,又在树莓派上部署远程关机的脚本(登录unraid的页面服务器,然后调用网页API关机)。

上一次编译是在树莓派2B上安装RUST工具链,然后上传代码进行编译,当时花了40分钟。今天尝试使用GO的交叉编译。

由于代码量不多,因此就不使用Github了。

package commons

import (
    "fmt"
    "strings"
)

// ASMB8 auth tokens
type Auth struct {
    SessionCookie string
    CSRFToken     string
}

type HostConfig struct {
    Host     string `yaml:"host"`
    Username string `yaml:"username"`
    Password string `yaml:"password"`
}

type AppConfig struct {
    ASMB8  HostConfig `yaml:"asmb8"`
    Unraid HostConfig `yaml:"unraid"`
}

var (
    Config  AppConfig
    Action  string
    Verbose bool
)

// Print only verbose is enabled
func VerbosePrintf(format string, a ...interface{}) {
    if Verbose {
        _, err := fmt.Printf(format, a...)
        if err != nil {
            panic("Error when print message: " + err.Error())
        }
    }
}

// Print only verbose is enabled
func VerbosePrintln(a ...interface{}) {
    if Verbose {
        _, err := fmt.Println(a...)
        if err != nil {
            panic("Error when print message: " + err.Error())
        }
    }
}

// Concat host and url into a url.
// Examples:
//     host = 192.168.1.1, url = foo/bar, return = http://192.168.1.1/foo/bar
//     host = https://192.168.1.1, url = foo/bar, return = https://192.168.1.1/foo/bar
//     host = http://192.168.1.1, url = /foo/bar, return = http://192.168.1.1/foo/bar
//     host = http://192.168.1.1/, url = /foo/bar, return = http://192.168.1.1/foo/bar
func ConcatURL(host, url string) string {
    result := ""
    if !strings.HasPrefix(host, "http") {
        result += "http://" + host
    } else {
        result += host
    }
    if !strings.HasSuffix(result, "/") {
        result += "/"
    }
    result += strings.TrimPrefix(url, "/")
    VerbosePrintf("Concat url: %s\n", result)
    return result
}

如上是公用代码,例如全局变量、共享的工具方法之类的。

下面是操作ASMB8面板相关的代码。与Rust版本功能一样,在代码上增加了一些错误处理。

package asmb8

import (
    "CocoAdmin/commons"
    "errors"
    "io/ioutil"
    "net/http"
    "net/url"
    "strings"
)

// Do a post request in asmb8 manner
func DoASMB8PostRequest(client *http.Client, auth *commons.Auth, url string, data url.Values) (resp *http.Response, err error) {
    // construct post request
    req, err := http.NewRequest("POST", url, strings.NewReader(data.Encode()))
    if err != nil {
        return nil, err
    }

    // only pass the tokens if exist
    if auth != nil {
        req.Header.Add("Cookie", "SessionCookie="+auth.SessionCookie)
        req.Header.Add("CSRFTOKEN", auth.CSRFToken)
    }

    // do request
    req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
    resp, err = client.Do(req)
    return
}

// Do a get request in asmb8 manner
func DoASMB8GetRequest(client *http.Client, auth *commons.Auth, url string) (resp *http.Response, err error) {
    // construct get request
    req, err := http.NewRequest("GET", url, nil)
    if err != nil {
        return nil, err
    }

    // only pass the tokens if exist
    if auth != nil {
        req.Header.Add("Cookie", "SessionCookie="+auth.SessionCookie)
        req.Header.Add("CSRFTOKEN", auth.CSRFToken)
    }

    // do request
    resp, err = client.Do(req)
    return
}

// check if the power button is enabled in asmb 8 panel
func CheckPowerButtonEnabled(httpClient *http.Client, auth *commons.Auth) bool {
    resp, _ := DoASMB8GetRequest(httpClient, auth, commons.ConcatURL(commons.Config.ASMB8.Host, "/rpc/PWRbutton.asp"))
    body, _ := ioutil.ReadAll(resp.Body)
    commons.VerbosePrintf("Repsonse: \n%s\n", string(body))
    if !strings.Contains(string(body), "PB_STATE") {
        panic("Cannot parse server response.")
    }
    return strings.Contains(strings.Split(strings.Split(string(body), "PB_STATE")[1], "}")[0], "1")
}

// check if the host is powered off
func CheckPowerOff(httpClient *http.Client, auth *commons.Auth) bool {
    resp, _ := DoASMB8GetRequest(httpClient, auth, commons.ConcatURL(commons.Config.ASMB8.Host, "/rpc/hoststatus.asp"))
    body, _ := ioutil.ReadAll(resp.Body)
    commons.VerbosePrintf("Repsonse: \n%s\n", string(body))
    if !strings.Contains(string(body), "JF_STATE") {
        panic("Cannot parse server response.")
    }
    return strings.Contains(strings.Split(strings.Split(string(body), "JF_STATE")[1], "}")[0], "0")
}

// check if the host is powered on
func CheckPowerOn(httpClient *http.Client, auth *commons.Auth) bool {
    resp, _ := DoASMB8GetRequest(httpClient, auth, commons.ConcatURL(commons.Config.ASMB8.Host, "/rpc/hoststatus.asp"))
    body, _ := ioutil.ReadAll(resp.Body)
    commons.VerbosePrintf("Repsonse: \n%s\n", string(body))
    if !strings.Contains(string(body), "JF_STATE") {
        panic("Cannot parse server response.")
    }
    return strings.Contains(strings.Split(strings.Split(string(body), "JF_STATE")[1], "}")[0], "1")
}

// Do login to asmb 8 panel
func DoLogin(httpClient *http.Client, username, password string) (*commons.Auth, error) {
    urlValues := url.Values{}
    urlValues.Add("WEBVAR_USERNAME", username)
    urlValues.Add("WEBVAR_PASSWORD", password)
    resp, err := DoASMB8PostRequest(httpClient, nil, commons.ConcatURL(commons.Config.ASMB8.Host, "/rpc/WEBSES/create.asp"), urlValues)
    if err != nil {
        return nil, err
    }
    body, err := ioutil.ReadAll(resp.Body)
    if err != nil {
        return nil, err
    }

    commons.VerbosePrintf("Repsonse: \n%s\n", string(body))
    if strings.Contains(string(body), "Failure_Login_IPMI_Then_LDAP_then_Active_Directory_Radius") {
        return nil, errors.New("login failed")
    }

    raws := strings.Split(string(body), "'")
    if len(raws) <= 11 {
        panic("Cannot parse server response.")
    }
    sessionCookie := raws[3]
    csrfToken := raws[11]

    return &commons.Auth{
        SessionCookie: sessionCookie,
        CSRFToken:     csrfToken,
    }, nil
}

// perform a power on action, no matter whatever condition.
//
// Conditions must be check before invoke this method: PowerBtnEnable and ServerPoweredOff.
//
// Should check is the server started. Whatever by ping os or check from panel.
func DoPowerOn(httpClient *http.Client, auth *commons.Auth) error {
    urlValues := url.Values{}
    urlValues.Add("WEBVAR_POWER_CMD", "1")
    resp, err := DoASMB8PostRequest(httpClient, auth, commons.ConcatURL(commons.Config.ASMB8.Host, "/rpc/hostctl.asp"), urlValues)
    if err != nil {
        return err
    }
    body, err := ioutil.ReadAll(resp.Body)
    if err != nil {
        return err
    }
    commons.VerbosePrintf("Repsonse: \n%s\n", string(body))
    return nil
}

如下是操作Unraid面板的相关代码,其中为了调用简单,直接将Unraid关机封装成了一个方法(因为关机之前先要从ASMB8面板确认当前是开机状态)。

package unraid

import (
    "CocoAdmin/commons"
    "errors"
    "io/ioutil"
    "net/http"
    "net/url"
    "strings"
)

func DoLoginAndPerformShutdown(client *http.Client) {
    csrfToken, err := DoLogin(client, commons.Config.Unraid.Username, commons.Config.Unraid.Password)
    if err != nil {
        panic("Error when login unraid panel: " + err.Error())
    }
    commons.VerbosePrintln("CSRF_TOKEN: " + csrfToken)
    err = PerformShutdown(client, csrfToken)
    if err != nil {
        panic("Error when performing shutdown action: " + err.Error())
    }
}

// client should have a cookie jar to save cookies. Otherwise you cannot login the unraid panel.
func DoLogin(client *http.Client, username, password string) (string, error) {
    if client.Jar == nil {
        panic("Http Client has to have a cookie jar to be able to log in.")
    }

    urlValues := url.Values{}
    urlValues.Add("username", username)
    urlValues.Add("password", password)
    req, err := http.NewRequest("POST", commons.ConcatURL(commons.Config.Unraid.Host, "/login"), strings.NewReader(urlValues.Encode()))
    if err != nil {
        return "", err
    }
    // do request
    req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
    resp, err := client.Do(req)
    if err != nil {
        return "", err
    }
    body, err := ioutil.ReadAll(resp.Body)
    if err != nil {
        return "", err
    }

    commons.VerbosePrintf("Repsonse: \n%s\n", string(body))
    if !strings.Contains(string(body), "csrf_token=") {
        return "", errors.New("login failed: cannot find csrf token")
    }

    return strings.Split(strings.Split(string(body), "csrf_token=")[1], "';")[0], nil
}

// Perform the shutdown action
func PerformShutdown(client *http.Client, csrfToken string) error {
    urlValues := url.Values{}
    urlValues.Add("csrf_token", csrfToken)
    urlValues.Add("cmd", "shutdown")
    req, err := http.NewRequest("POST", commons.ConcatURL(commons.Config.Unraid.Host, "/webGui/include/Boot.php"), strings.NewReader(urlValues.Encode()))
    if err != nil {
        return err
    }
    // do request
    req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
    resp, err := client.Do(req)
    if err != nil {
        return err
    }
    commons.VerbosePrintf("Repsonse status code: %d\n", resp.StatusCode)
    if resp.StatusCode != 200 {
        return errors.New("unraid response not 200")
    }
    return nil
}

主函数相比Rust版本复杂了一些。Rust版本中将服务器地址、用户名和密码硬编码到了程序里面。这次重写加入了命令行的解析、配置文件的读取和调试开关。

相关依赖如下(go.mod):

module CocoAdmin

go 1.15

require gopkg.in/yaml.v2 v2.4.0

主函数如下:

package main

import (
    "CocoAdmin/asmb8"
    "CocoAdmin/commons"
    "CocoAdmin/unraid"
    "flag"
    "fmt"
    "gopkg.in/yaml.v2"
    "io/ioutil"
    "net/http"
    "net/http/cookiejar"
    "strings"
    "time"
)

var (
    configFilePath string
    help           bool
    initConfig     bool
)

func init() {
    flag.StringVar(&configFilePath, "config", "./config.yaml", "`path` to config file")
    flag.BoolVar(&initConfig, "init", false, "write the initial content to config file")

    flag.StringVar(&commons.Action, "action", "", "specific a action to perform: startup or shutdown")
    flag.BoolVar(&commons.Verbose, "verbose", false, "enable verbose output")
    flag.BoolVar(&help, "help", false, "show this help")
}

func main() {
    // parse cli
    flag.Parse()

    if help {
        flag.Usage()
        return
    }

    if initConfig {
        data, err := yaml.Marshal(commons.AppConfig{})
        if err != nil {
            panic("Error when marshal initial data: " + err.Error())
        }
        if err := ioutil.WriteFile(
            configFilePath,
            data,
            0644,
        ); err != nil {
            panic("Failed writing into config file: " + err.Error())
        }
        return
    }

    // reading config file
    configFile, err := ioutil.ReadFile(configFilePath)
    if err != nil {
        panic("Error when reading config file: " + err.Error())
    }

    if err := yaml.Unmarshal(configFile, &commons.Config); err != nil {
        panic("Error when unmarshal config file: " + err.Error())
    }

    commons.VerbosePrintf("Read config file: %v\n", commons.Config)

    // construct http client
    jar, err := cookiejar.New(nil)
    if err != nil {
        panic("Failed initializing cookie jar")
    }
    httpClient := &http.Client{
        Jar: jar,
    }

    switch strings.ToLower(commons.Action) {
    case "startup":
        auth, err := asmb8.DoLogin(httpClient, commons.Config.ASMB8.Username, commons.Config.ASMB8.Password)
        if err != nil {
            panic("Error when doing login: " + err.Error())
        }

        commons.VerbosePrintf("Fetching asmb8 SESSION_COOKIE: %s\n", auth.SessionCookie)
        commons.VerbosePrintf("Fetching asmb8 CSRFTOKEN: %s\n", auth.CSRFToken)

        serverPoweredOff := asmb8.CheckPowerOff(httpClient, auth)
        commons.VerbosePrintf("Currently ServePoweredOff: %t\n", serverPoweredOff)

        if serverPoweredOff {
            // check power button status
            powerBtnEnabled := asmb8.CheckPowerButtonEnabled(httpClient, auth)
            commons.VerbosePrintf("PowerButtonEnable: %t\n", powerBtnEnabled)

            if !powerBtnEnabled {
                panic("Power Button Control is disabled in ASMB8 panel. Cannot remoter control server's power.")
            }
            // power on
            fmt.Println("Trying to power on server...")
            err = asmb8.DoPowerOn(httpClient, auth)
            if err != nil {
                panic("Error when performing power on: " + err.Error())
            }

            // checking power status
            fmt.Println("Checking power status...")
            i := 1
            for ; i <= 5; i++ {
                status := asmb8.CheckPowerOn(httpClient, auth)
                fmt.Printf("Attemp %d. IsPoweredOn: %t\n", i, status)
                if status {
                    break
                }
                time.Sleep(10 * time.Second)
            }

            if i > 5 {
                fmt.Println("ASMB8 response ok, but still report power off.")
            } else {
                fmt.Println("Server should be powered on.")
            }
        } else {
            panic("Server has already powered on.")
        }
    case "shutdown":
        auth, err := asmb8.DoLogin(httpClient, commons.Config.Unraid.Username, commons.Config.Unraid.Password)
        if err != nil {
            panic("Error when doing login: " + err.Error())
        }

        commons.VerbosePrintf("Fetching asmb8 SESSION_COOKIE: %s\n", auth.SessionCookie)
        commons.VerbosePrintf("Fetching asmb8 CSRFTOKEN: %s\n", auth.CSRFToken)

        serverPoweredOff := asmb8.CheckPowerOff(httpClient, auth)
        commons.VerbosePrintf("Currently ServePoweredOff: %t\n", serverPoweredOff)

        if !serverPoweredOff {
            fmt.Println("Trying to shutdown server through unraid panel...")
            unraid.DoLoginAndPerformShutdown(httpClient)
            fmt.Println("Server should be shutdown.")
        } else {
            panic("Server has been already shutdown.")
        }
    default:
        commons.VerbosePrintln("Unknown action: " + commons.Action)
        flag.Usage()
    }
}

最后交叉编译。我这里是Windows环境,因此需要先设置交叉编译选项的环境变量:

SET CGO_ENABLED=0
SET GOOS=linux
SET GOARCH=arm

对于Arm架构的交叉编译,需要注意的是树莓派现行的操作系统都是32位的,所以GOARCH都是arm,如果是树莓派3B和4B运行的64位系统,那么应该使用arm64并且忽略下面的GOARM选项。

对于GOARCH-arm,还有个GOARM的选项,对于树莓派来说,设置GOARM=7即可。

最后编译:

SET CGO_ENABLED=0
SET GOOS=linux
SET GOARCH=arm
SET GOARM=7
go build

部署在树莓派上:

*/20 8,9 * * *  /usr/local/bin/cocoAdmin -config /usr/local/etc/cocoAdmin/config.json -action startup
*/20 22,23 * * *  /usr/local/bin/cocoAdmin -config /usr/local/etc/cocoAdmin/config.json -action shutdown

事先需要生成好配置文件:

/usr/local/bin/cocoAdmin -config /usr/local/etc/cocoAdmin/config.json -init

-全文完-


知识共享许可协议
【代码札记】Golang项目实践天空 Blond 采用 知识共享 署名 - 非商业性使用 - 相同方式共享 4.0 国际 许可协议进行许可。
本许可协议授权之外的使用权限可以从 https://skyblond.info/about.html 处获得。

Archives QR Code
QR Code for this page
Tipping QR Code
Leave a Comment

2 Comments
  1. chenyh chenyh

    冲击钻打瓷砖装浴霸可以的,是自己手操的嘛?我还没见过,你挂个B站视频,我马上给你+1s,啊不,+1播放量。

    1. @chenyh倒是我自己操作的,不过家用的那种两用钻,标称570W,看着和手电钻差不多,没啥新鲜新的。就是冲击钻有点震手