Skip to content
Snippets Groups Projects
Verified Commit 70fd0600 authored by Janne Mareike Koschinski's avatar Janne Mareike Koschinski
Browse files

feat: initial working version

parent 01290d83
No related branches found
No related tags found
No related merge requests found
*.iml
/.idea
/bin/
/manifest.json
\ No newline at end of file
## Usage
Invoke binary (hetzner-robot-automation) with a config file as parameter.
That config file should contain the API keys, server id, and list of files to upload and/or execute.
The binary then reboots the specified server into rescue mode, copies the specified files over, and executes them if
specified.
The config should comply with the JSON schema and look similar to
```json
{
"api-key": "AzureDiamond",
"api-secret": "hunter2",
"server": 123456,
"files": [
{
"source": "config/ignition.ign",
"target": "/root/ignition.ign",
"mode": 644,
"execute": false
},
{
"source": "install.sh",
"target": "/root/install_rescue.sh",
"mode": 755,
"execute": true
}
]
}
```
\ No newline at end of file
package main
import (
"fmt"
"golang.org/x/crypto/ssh"
"io"
"log"
)
func isExitMissingError(err error) bool {
_, isExitMissingError := err.(*ssh.ExitMissingError)
return isExitMissingError
}
func deploy(manifest Manifest) error {
connectionData, err := provideConnection(manifest)
if err != nil {
return fmt.Errorf("error while obtaining connection data: %w", err)
}
sshClient, err := openSshClient(connectionData)
if err != nil {
return fmt.Errorf("error while opening ssh client: %w", err)
}
defer sshClient.Close()
for _, file := range manifest.Files {
sshSession, err := openSshSession(sshClient)
if err != nil {
return fmt.Errorf("error while opening ssh session: %w", err)
}
log.Printf("copying file %s to %s with mode 0%d \n", file.Source, file.Target, file.Mode)
if err := copyFile(sshSession, file); err != nil && !isExitMissingError(err) {
_ = sshSession.Close()
return fmt.Errorf("error while copying file %s: %w", file.Target, err)
}
if err := sshSession.Close(); err != nil && err != io.EOF {
return fmt.Errorf("error while closing ssh session: %w", err)
}
}
for _, file := range manifest.Files {
if file.Execute {
sshSession, err := openSshSession(sshClient)
if err != nil {
return fmt.Errorf("error while opening ssh session: %w", err)
}
log.Printf("running command %s\n", file.Target)
if err := sshSession.Run(file.Target); err != nil && !isExitMissingError(err) {
_ = sshSession.Close()
return fmt.Errorf("error while running command: %w", err)
}
if err := sshSession.Close(); err != nil && err != io.EOF {
return fmt.Errorf("error while closing ssh session: %w", err)
}
}
}
return nil
}
package main
import (
"fmt"
"hetzner-robot/client"
"hetzner-robot/models"
"log"
)
func enableRescue(robotClient client.RobotClient, server models.ServerNumber) (*SshConnection, error) {
oldRescueInfo, err := robotClient.BootRescueGetLast(server)
if err != nil {
return nil, fmt.Errorf("error while retrieving rescue status: %w", err)
}
if oldRescueInfo.Active {
log.Println("rescue system already active, deactivating")
if err := robotClient.BootRescueDelete(server); err != nil {
return nil, fmt.Errorf("error while deactivating rescue system: %w", err)
}
}
log.Println("activating rescue system")
rescueInfo, err := robotClient.BootRescueSet(server, &models.RescueSetInput{
OS: "linux",
})
if err != nil {
return nil, fmt.Errorf("error while setting server rescue properties: %w", err)
}
return &SshConnection{
Username: "root",
Password: rescueInfo.Password,
Hostname: rescueInfo.ServerIP,
Port: 22,
}, nil
}
func provideConnection(manifest Manifest) (*SshConnection, error) {
robotClient := client.NewBasicAuthClient(manifest.ApiKey, manifest.ApiSecret)
server, err := robotClient.ServerGet(models.ServerNumber(manifest.Server))
if err != nil {
return nil, fmt.Errorf("error while retrieving server: %w", err)
}
connectionData, err := enableRescue(robotClient, server.ServerNumber)
if err != nil {
return nil, fmt.Errorf("error deploying configuration to server: %w", err)
}
log.Println("restarting server")
_, err = robotClient.ResetSet(server.ServerNumber, &models.ResetSetInput{
Type: models.ResetTypeHardware,
})
if err != nil {
return nil, fmt.Errorf("error restarting server: %w", err)
}
return connectionData, nil
}
package main
import (
"encoding/json"
"flag"
"fmt"
"log"
"os"
"path/filepath"
)
type Manifest struct {
ApiKey string `json:"api-key"`
ApiSecret string `json:"api-secret"`
Server int `json:"server"`
Files []CopyFile `json:"files"`
}
type CopyFile struct {
Source string `json:"source"`
Target string `json:"target"`
Mode uint `json:"mode"`
Execute bool `json:"execute"`
}
func (manifest *Manifest) resolvePaths(root string) {
for i, file := range manifest.Files {
if !filepath.IsAbs(file.Source) {
newPath := filepath.Join(root, file.Source)
manifest.Files[i].Source = newPath
}
}
}
func loadManifest(filename string) (Manifest, error) {
var manifest Manifest
manifestPath, err := filepath.Abs(filename)
if err != nil {
return manifest, fmt.Errorf("could not find absolute path for file")
}
manifestFile, err := os.Open(manifestPath)
if err != nil {
return manifest, fmt.Errorf("error opening manifest: %w", err)
}
if err := json.NewDecoder(manifestFile).Decode(&manifest); err != nil {
return manifest, fmt.Errorf("error loading manifest: %w", err)
}
manifest.resolvePaths(filepath.Dir(manifestPath))
return manifest, nil
}
func main() {
flag.Parse()
for _, arg := range flag.Args() {
log.Printf("loading manifest %s", arg)
manifest, err := loadManifest(arg)
if err != nil {
log.Fatalf(err.Error())
}
err = deploy(manifest)
if err != nil {
log.Fatalf(err.Error())
}
}
}
package main
import (
"bufio"
"fmt"
"golang.org/x/crypto/ssh"
"golang.org/x/sync/errgroup"
"io"
"log"
"os"
"path"
"time"
)
type SshConnection struct {
Username string
Password string
Hostname string
Port int
Timeout time.Duration
}
func openSshClient(connection *SshConnection) (*ssh.Client, error) {
conf := &ssh.ClientConfig{
User: connection.Username,
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
Auth: []ssh.AuthMethod{
ssh.Password(connection.Password),
},
Timeout: connection.Timeout,
}
log.Println("opening ssh connection")
var conn *ssh.Client
for attempt := 0; attempt < 20; attempt++ {
if conn != nil {
if err := conn.Close(); err != nil {
return nil, fmt.Errorf("could not connect to server via ssh %w", err)
}
}
conn, err := ssh.Dial("tcp", fmt.Sprintf("%s:%d", connection.Hostname, connection.Port), conf)
if err == nil {
return conn, nil
}
log.Println("waiting on ssh session")
time.Sleep(5 * time.Second)
}
return nil, fmt.Errorf("could not connect to server via ssh")
}
func openSshSession(conn *ssh.Client) (*ssh.Session, error) {
log.Println("opening ssh session")
var session *ssh.Session
session, err := conn.NewSession()
if err != nil {
return nil, fmt.Errorf("error opening ssh session %w", err)
}
log.Println("connecting ssh outputs to stdout")
if err := connectShellOutputs(session); err != nil {
return nil, fmt.Errorf("error connecting ssh outputs to stdout %w", err)
}
return session, nil
}
func copyFile(session *ssh.Session, file CopyFile) error {
handle, err := os.Open(file.Source)
if err != nil {
return err
}
defer handle.Close()
stat, err := handle.Stat()
if err != nil {
return err
}
filedir, filename := path.Split(file.Target)
group := errgroup.Group{}
group.Go(func() error {
stdin, _ := session.StdinPipe()
defer stdin.Close()
if _, err := fmt.Fprintf(stdin, "C0%d %d %s\n", file.Mode, stat.Size(), filename); err != nil {
return fmt.Errorf("error sending file: %w", err)
}
if _, err := io.Copy(stdin, handle); err != nil {
return fmt.Errorf("error sending file: %w", err)
}
if _, err := fmt.Fprint(stdin, "\x00"); err != nil {
return fmt.Errorf("error sending file: %w", err)
}
return nil
})
group.Go(func() error {
if err := session.Run(fmt.Sprintf("/usr/bin/scp -t %s", filedir)); err != nil {
return fmt.Errorf("error receiving file: %w", err)
}
return nil
})
return group.Wait()
}
func connectReader(label string, reader io.Reader) {
scanner := bufio.NewScanner(reader)
for {
if tkn := scanner.Scan(); tkn {
rcv := scanner.Bytes()
raw := make([]byte, len(rcv))
copy(raw, rcv)
log.Printf("%s: %s\n", label, string(raw))
} else {
if scanner.Err() != nil {
log.Printf("%s error: %s\n", label, scanner.Err())
}
return
}
}
}
func connectShellOutputs(session *ssh.Session) error {
var stdout, stderr io.Reader
var err error
stdout, err = session.StdoutPipe()
if err != nil {
return err
}
stderr, err = session.StderrPipe()
if err != nil {
return err
}
go connectReader("ssh stdout", stdout)
go connectReader("ssh stderr", stderr)
return nil
}
package client
import (
"encoding/json"
"fmt"
"hetzner-robot/models"
"net/http"
neturl "net/url"
"strconv"
)
func (c *Client) BootRescueGet(server models.ServerNumber) (*models.RescueInfo, error) {
url := fmt.Sprintf(c.baseURL+"/boot/%d/rescue", int(server))
bytes, err := c.doGetRequest(url)
if err != nil {
return nil, err
}
var rescueResp models.RescueInfoGetResponse
err = json.Unmarshal(bytes, &rescueResp)
if err != nil {
return nil, err
}
return &rescueResp.Rescue, nil
}
func (c *Client) BootRescueGetLast(server models.ServerNumber) (*models.Rescue, error) {
url := fmt.Sprintf(c.baseURL+"/boot/%d/rescue/last", int(server))
bytes, err := c.doGetRequest(url)
if err != nil {
return nil, err
}
var rescueResp models.RescueGetResponse
err = json.Unmarshal(bytes, &rescueResp)
if err != nil {
return nil, err
}
return &rescueResp.Rescue, nil
}
func (c *Client) BootRescueSet(server models.ServerNumber, input *models.RescueSetInput) (*models.Rescue, error) {
url := fmt.Sprintf(c.baseURL+"/boot/%d/rescue", int(server))
formData := neturl.Values{}
formData.Set("os", input.OS)
if input.Arch > 0 {
formData.Set("arch", strconv.Itoa(input.Arch))
}
if len(input.AuthorizedKeys) > 0 {
for _, authorizedKey := range input.AuthorizedKeys {
formData.Add("authorized_key[]", authorizedKey.Fingerprint)
}
}
bytes, err := c.doPostFormRequest(url, formData)
if err != nil {
return nil, err
}
var rescueResp models.RescueGetResponse
err = json.Unmarshal(bytes, &rescueResp)
if err != nil {
return nil, err
}
return &rescueResp.Rescue, nil
}
func (c *Client) BootRescueDelete(server models.ServerNumber) error {
url := fmt.Sprintf(c.baseURL+"/boot/%d/rescue", int(server))
req, err := http.NewRequest("DELETE", url, nil)
if err != nil {
return err
}
_, err = c.doRequest(req)
return err
}
package client
import (
"fmt"
"io/ioutil"
"net/http"
"net/url"
"strings"
)
const baseURL string = "https://robot-ws.your-server.de"
const version = "0.1.3"
const userAgent = "hrobot-client/" + version
type Client struct {
Username string
Password string
baseURL string
userAgent string
}
func NewBasicAuthClient(username, password string) RobotClient {
return &Client{
Username: username,
Password: password,
baseURL: baseURL,
userAgent: userAgent,
}
}
func (c *Client) SetBaseURL(baseURL string) {
c.baseURL = baseURL
}
func (c *Client) SetUserAgent(userAgent string) {
c.userAgent = userAgent
}
func (c *Client) GetVersion() string {
return version
}
func (c *Client) doGetRequest(url string) ([]byte, error) {
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
bytes, err := c.doRequest(req)
if err != nil {
return nil, err
}
return bytes, nil
}
func (c *Client) doPostFormRequest(url string, formData url.Values) ([]byte, error) {
req, err := http.NewRequest("POST", url, strings.NewReader(formData.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
bytes, err := c.doRequest(req)
if err != nil {
return nil, err
}
return bytes, nil
}
func (c *Client) doRequest(req *http.Request) ([]byte, error) {
req.Header.Set("User-Agent", c.userAgent)
req.SetBasicAuth(c.Username, c.Password)
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if 200 != resp.StatusCode {
return nil, fmt.Errorf("%s", body)
}
return body, nil
}
package client
import (
"encoding/json"
"fmt"
"hetzner-robot/models"
)
func (c *Client) FailoverGetList() ([]models.Failover, error) {
url := c.baseURL + "/failover"
bytes, err := c.doGetRequest(url)
if err != nil {
return nil, err
}
var failoverList []models.FailoverResponse
err = json.Unmarshal(bytes, &failoverList)
if err != nil {
return nil, err
}
var data []models.Failover
for _, failover := range failoverList {
data = append(data, failover.Failover)
}
return data, nil
}
func (c *Client) FailoverGet(server models.ServerNumber) (*models.Failover, error) {
url := fmt.Sprintf(c.baseURL+"/failover/%d", int(server))
bytes, err := c.doGetRequest(url)
if err != nil {
return nil, err
}
var failoverResp models.FailoverResponse
err = json.Unmarshal(bytes, &failoverResp)
if err != nil {
return nil, err
}
return &failoverResp.Failover, nil
}
package client
import (
"hetzner-robot/models"
)
type RobotClient interface {
SetBaseURL(baseURL string)
SetUserAgent(userAgent string)
GetVersion() string
ServerGetList() ([]models.Server, error)
ServerGet(server models.ServerNumber) (*models.Server, error)
ServerSetName(server models.ServerNumber, input *models.ServerSetNameInput) (*models.Server, error)
ServerReverse(server models.ServerNumber) (*models.Cancellation, error)
KeyGetList() ([]models.Key, error)
IPGetList() ([]models.IP, error)
RDnsGetList() ([]models.Rdns, error)
RDnsGet(server models.ServerNumber) (*models.Rdns, error)
BootRescueGet(server models.ServerNumber) (*models.RescueInfo, error)
BootRescueGetLast(server models.ServerNumber) (*models.Rescue, error)
BootRescueSet(server models.ServerNumber, input *models.RescueSetInput) (*models.Rescue, error)
BootRescueDelete(server models.ServerNumber) error
ResetGet(server models.ServerNumber) (*models.Reset, error)
ResetSet(server models.ServerNumber, input *models.ResetSetInput) (*models.ResetPost, error)
FailoverGetList() ([]models.Failover, error)
FailoverGet(server models.ServerNumber) (*models.Failover, error)
}
package client
import (
"encoding/json"
"hetzner-robot/models"
)
func (c *Client) IPGetList() ([]models.IP, error) {
url := c.baseURL + "/ip"
bytes, err := c.doGetRequest(url)
if err != nil {
return nil, err
}
var ips []models.IPResponse
err = json.Unmarshal(bytes, &ips)
if err != nil {
return nil, err
}
var data []models.IP
for _, ip := range ips {
data = append(data, ip.IP)
}
return data, nil
}
package client
import (
"encoding/json"
"hetzner-robot/models"
)
func (c *Client) KeyGetList() ([]models.Key, error) {
url := c.baseURL + "/key"
bytes, err := c.doGetRequest(url)
if err != nil {
return nil, err
}
var keys []models.KeyResponse
err = json.Unmarshal(bytes, &keys)
if err != nil {
return nil, err
}
var data []models.Key
for _, key := range keys {
data = append(data, key.Key)
}
return data, nil
}
package client
import (
"encoding/json"
"fmt"
"hetzner-robot/models"
)
func (c *Client) RDnsGetList() ([]models.Rdns, error) {
url := c.baseURL + "/rdns"
bytes, err := c.doGetRequest(url)
if err != nil {
return nil, err
}
var rdnsList []models.RdnsResponse
err = json.Unmarshal(bytes, &rdnsList)
if err != nil {
return nil, err
}
var data []models.Rdns
for _, rdns := range rdnsList {
data = append(data, rdns.Rdns)
}
return data, nil
}
func (c *Client) RDnsGet(server models.ServerNumber) (*models.Rdns, error) {
url := fmt.Sprintf(c.baseURL+"/rdns/%d", int(server))
bytes, err := c.doGetRequest(url)
if err != nil {
return nil, err
}
var rDnsResp models.RdnsResponse
err = json.Unmarshal(bytes, &rDnsResp)
if err != nil {
return nil, err
}
return &rDnsResp.Rdns, nil
}
package client
import (
"encoding/json"
"fmt"
"hetzner-robot/models"
neturl "net/url"
)
func (c *Client) ResetGet(server models.ServerNumber) (*models.Reset, error) {
url := fmt.Sprintf(c.baseURL+"/reset/%d", int(server))
bytes, err := c.doGetRequest(url)
if err != nil {
return nil, err
}
var resetResp models.ResetResponse
err = json.Unmarshal(bytes, &resetResp)
if err != nil {
return nil, err
}
return &resetResp.Reset, nil
}
func (c *Client) ResetSet(server models.ServerNumber, input *models.ResetSetInput) (*models.ResetPost, error) {
url := fmt.Sprintf(c.baseURL+"/reset/%d", int(server))
formData := neturl.Values{}
formData.Set("type", input.Type)
bytes, err := c.doPostFormRequest(url, formData)
if err != nil {
return nil, err
}
var resetResp models.ResetPostResponse
err = json.Unmarshal(bytes, &resetResp)
if err != nil {
return nil, err
}
return &resetResp.Reset, nil
}
package client
import (
"encoding/json"
"fmt"
"hetzner-robot/models"
neturl "net/url"
)
func (c *Client) ServerGetList() ([]models.Server, error) {
url := c.baseURL + "/server"
bytes, err := c.doGetRequest(url)
if err != nil {
return nil, err
}
var servers []models.ServerResponse
err = json.Unmarshal(bytes, &servers)
if err != nil {
return nil, err
}
var data []models.Server
for _, server := range servers {
data = append(data, server.Server)
}
return data, nil
}
func (c *Client) ServerGet(server models.ServerNumber) (*models.Server, error) {
url := fmt.Sprintf(c.baseURL+"/server/%d", int(server))
bytes, err := c.doGetRequest(url)
if err != nil {
return nil, err
}
var serverResp models.ServerResponse
err = json.Unmarshal(bytes, &serverResp)
if err != nil {
return nil, err
}
return &serverResp.Server, nil
}
func (c *Client) ServerSetName(server models.ServerNumber, input *models.ServerSetNameInput) (*models.Server, error) {
url := fmt.Sprintf(c.baseURL+"/server/%d", int(server))
formData := neturl.Values{}
formData.Set("server_name", input.Name)
bytes, err := c.doPostFormRequest(url, formData)
if err != nil {
return nil, err
}
var serverResp models.ServerResponse
err = json.Unmarshal(bytes, &serverResp)
if err != nil {
return nil, err
}
return &serverResp.Server, nil
}
func (c *Client) ServerReverse(server models.ServerNumber) (*models.Cancellation, error) {
url := fmt.Sprintf(c.baseURL+"/server/%d/reversal", int(server))
bytes, err := c.doPostFormRequest(url, nil)
if err != nil {
return nil, err
}
var cancelResp models.CancellationResponse
err = json.Unmarshal(bytes, &cancelResp)
if err != nil {
return nil, err
}
return &cancelResp.Cancellation, nil
}
go.mod 0 → 100644
module hetzner-robot-automation
go 1.18
require (
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d
golang.org/x/sync v0.0.0-20190423024810-112230192c58
)
require golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6 // indirect
go.sum 0 → 100644
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d h1:sK3txAijHtOK88l68nt020reeT1ZdKLIYetKl95FzVY=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/sync v0.0.0-20190423024810-112230192c58 h1:8gQV6CLnAEikrhgkHFbMAEhagSSnXWGV915qUMm9mrU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6 h1:nonptSpoQ4vQjyraW20DXPAglgQfVnM9ZC6MmNLMR60=
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E=
package models
type RescueInfoGetResponse struct {
Rescue RescueInfo `json:"rescue"`
}
type RescueGetResponse struct {
Rescue Rescue `json:"rescue"`
}
type AuthorizedKey struct {
Key Key `json:"key"`
}
type RescueInfo struct {
ServerIP string `json:"server_ip"`
ServerNumber ServerNumber `json:"server_number"`
Os []string `json:"os"`
Arch []int `json:"arch"`
Active bool `json:"active"`
Password string `json:"password"`
AuthorizedKey []AuthorizedKey `json:"authorized_key"`
HostKey []interface{} `json:"host_key"`
}
type Rescue struct {
ServerIP string `json:"server_ip"`
ServerNumber ServerNumber `json:"server_number"`
Os string `json:"os"`
Arch int `json:"arch"`
Active bool `json:"active"`
Password string `json:"password"`
AuthorizedKey []AuthorizedKey `json:"authorized_key"`
HostKey []interface{} `json:"host_key"`
}
type RescueSetInput struct {
OS string
Arch int
AuthorizedKeys []Key
}
package models
type CancellationResponse struct {
Cancellation Cancellation `json:"cancellation"`
}
type Cancellation struct {
ServerIP string `json:"server_ip"`
ServerNumber ServerNumber `json:"server_number"`
ServerName string `json:"server_name"`
EarliestCancellationDate string `json:"earliest_cancellation_date"`
Cancelled bool `json:"cancelled"`
CancellationDate string `json:"cancellation_date"`
CancellationReason interface{} `json:"cancellation_reason"`
}
package models
type FailoverResponse struct {
Failover Failover `json:"failover"`
}
type Failover struct {
IP string `json:"ip"`
Netmask string `json:"netmask"`
ServerIP string `json:"server_ip"`
ServerNumber ServerNumber `json:"server_number"`
ActiveServerIP string `json:"active_server_ip"`
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment