diff --git a/cmc-proxy/README.md b/cmc-proxy/README.md new file mode 100644 index 0000000..1efab29 --- /dev/null +++ b/cmc-proxy/README.md @@ -0,0 +1,39 @@ +Dell M1000e gRPC Proxy +====================== + +Cursedness level: 6.5/10 (regexp XML parsing, JSONP scraping, limited sessions). + +This is a small gRPC proxy to allow programmatic access to a Dell M1000e Chassis Management Controller. It's based on scraping the web interface, as the alternative (WSMAN) is even more ridiculous. + +Functionality +------------- + +The only feature supported so far is getting information for an iDRAC KVM console. This can be used to run a iDRAC KVM proxy (to be implemented), or the original client. + +Usage +----- + + ./cmc-proxy -h + +Flags are self-explanatory. This is based on [hspki](https://code.hackerspace.pl/q3k/hspki), so you'll need to have compatible (dev) certs to run this. The proxy listens on gRPC and a status HTTP debug server. + +Example +------- + + $ grpc-dev -d '{"blade_num": 6}' cmc.q3k.svc.cluster.local:4200 proto.CMCProxy.GetKVMData + { + "arguments": [ + "10.10.10.16:443", + "5901", + "oojo2obohhaWiu3A", + "1", + "0", + "3668", + "3669", + "511", + "5900", + "1", + "EN" + ] + } + diff --git a/cmc-proxy/client.go b/cmc-proxy/client.go new file mode 100644 index 0000000..e22c97c --- /dev/null +++ b/cmc-proxy/client.go @@ -0,0 +1,346 @@ +package main + +import ( + "context" + "crypto/tls" + "fmt" + "io/ioutil" + "net/http" + "net/url" + "regexp" + "strings" + + "code.hackerspace.pl/q3k/mirko" + "github.com/cenkalti/backoff" + "github.com/golang/glog" +) + +var ( + reSessionCookie = regexp.MustCompile("'SESSION_COOKIE' : '([^']*)'") + reIpmiPriv = regexp.MustCompile("'IPMI_PRIV' : ([^,]*)") + reExtPriv = regexp.MustCompile("'EXT_PRIV' : ([^,]*)") + reSystemModel = regexp.MustCompile("'SYSTEM_MODEL' : '([^']*)'") + reArgument = regexp.MustCompile("([^<]*)") +) + +var ( + ErrorNoFreeSlot = fmt.Errorf("iDRAC reports no free slot") +) + +type cmcRequestType int + +const ( + cmcRequestKVMDetails cmcRequestType = iota +) + +type cmcResponse struct { + data interface{} + err error +} + +type cmcRequest struct { + t cmcRequestType + req interface{} + res chan cmcResponse + canceled bool +} + +type KVMDetails struct { + arguments []string +} + +type cmcClient struct { + session string + req chan *cmcRequest +} + +func (c *cmcClient) RequestKVMDetails(ctx context.Context, slot int) (*KVMDetails, error) { + r := &cmcRequest{ + t: cmcRequestKVMDetails, + req: slot, + res: make(chan cmcResponse, 1), + } + mirko.Trace(ctx, "cmcRequestKVMDetails: requesting...") + c.req <- r + mirko.Trace(ctx, "cmcRequestKVMDetails: requested.") + + select { + case <-ctx.Done(): + r.canceled = true + return nil, context.Canceled + case res := <-r.res: + mirko.Trace(ctx, "cmcRequestKVMDetails: got response") + if res.err != nil { + return nil, res.err + } + return res.data.(*KVMDetails), nil + } +} + +func NewCMCClient() *cmcClient { + return &cmcClient{ + req: make(chan *cmcRequest, 4), + } +} + +func (c *cmcClient) Run(ctx context.Context) { + for { + select { + case <-ctx.Done(): + c.logout() + return + case msg := <-c.req: + c.handle(msg) + } + } +} + +func (c *cmcClient) handle(r *cmcRequest) { + switch { + case r.t == cmcRequestKVMDetails: + var details *KVMDetails + slot := r.req.(int) + err := backoff.Retry(func() error { + if err := c.login(); err != nil { + return err + } + url, err := c.getiDRACURL(slot) + if err != nil { + return err + } + details, err = c.getiDRACJNLP(url) + return err + }, backoff.WithMaxRetries(backoff.NewExponentialBackOff(), 2)) + + if err != nil { + r.res <- cmcResponse{err: err} + } + + r.res <- cmcResponse{data: details} + default: + panic("invalid cmcRequestType") + } +} + +func makeUrl(path string) string { + if strings.HasSuffix(flagCMCAddress, "/") { + return flagCMCAddress + path + } + return flagCMCAddress + "/" + path +} + +func (c *cmcClient) transport() *http.Transport { + return &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + } +} + +func (c *cmcClient) addCookies(req *http.Request) { + req.AddCookie(&http.Cookie{Name: "custom_domain", Value: ""}) + req.AddCookie(&http.Cookie{Name: "domain_selected", Value: "This Chassis"}) + if c.session != "" { + glog.Infof("Session cookie: %v", c.session) + req.AddCookie(&http.Cookie{Name: "sid", Value: c.session}) + } +} + +func (c *cmcClient) getiDRACURL(slot int) (string, error) { + if c.session == "" { + return "", fmt.Errorf("not logged in") + } + + url := makeUrl(pathiDRACURL) + fmt.Sprintf("?vKVM=1&serverSlot=%d", slot) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return "", fmt.Errorf("GET prepare to %s failed: %v", pathLogin, err) + } + c.addCookies(req) + + cl := &http.Client{ + Transport: c.transport(), + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } + resp, err := cl.Do(req) + if err != nil { + return "", fmt.Errorf("GET to %s failed: %v", pathLogin, err) + } + + if resp.StatusCode != 302 { + return "", fmt.Errorf("expected 302 on iDRAC URL redirect, got %v instead", resp.Status) + } + + loc, _ := resp.Location() + + if !strings.Contains(loc.String(), "cmc_sess_id") { + c.session = "" + return "", fmt.Errorf("redirect URL contains no session ID - session timed out?") + } + + return loc.String(), nil +} + +func (c *cmcClient) getiDRACJNLP(loginUrl string) (*KVMDetails, error) { + lurl, err := url.Parse(loginUrl) + if err != nil { + return nil, err + } + + sessid := lurl.Query().Get("cmc_sess_id") + if sessid == "" { + return nil, fmt.Errorf("no cmc_sess_id in iDRAC login URL") + } + + createURL := *lurl + createURL.Path = "/Applications/dellUI/RPC/WEBSES/create.asp" + createURL.RawQuery = "" + + values := url.Values{} + values.Set("WEBVAR_USERNAME", "cmc") + values.Set("WEBVAR_PASSWORD", sessid) + values.Set("WEBVAR_ISCMCLOGIN", "1") + valuesString := values.Encode() + req, err := http.NewRequest("POST", createURL.String(), strings.NewReader(valuesString)) + + cl := &http.Client{ + Transport: c.transport(), + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } + resp, err := cl.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + data, _ := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + first := func(v [][]byte) string { + if len(v) < 1 { + return "" + } + return string(v[1]) + } + + sessionCookie := first(reSessionCookie.FindSubmatch(data)) + ipmiPriv := first(reIpmiPriv.FindSubmatch(data)) + extPriv := first(reExtPriv.FindSubmatch(data)) + systemModel := first(reSystemModel.FindSubmatch(data)) + + if sessionCookie == "Failure_No_Free_Slot" { + return nil, ErrorNoFreeSlot + } + + jnlpURL := *lurl + jnlpURL.Path = "/Applications/dellUI/Java/jviewer.jnlp" + jnlpURL.RawQuery = "" + + req, err = http.NewRequest("GET", jnlpURL.String(), nil) + for _, cookie := range resp.Cookies() { + req.AddCookie(cookie) + } + req.AddCookie(&http.Cookie{Name: "SessionCookie", Value: sessionCookie}) + req.AddCookie(&http.Cookie{Name: "SessionCookieUser", Value: "cmc"}) + req.AddCookie(&http.Cookie{Name: "IPMIPriv", Value: ipmiPriv}) + req.AddCookie(&http.Cookie{Name: "ExtPriv", Value: extPriv}) + req.AddCookie(&http.Cookie{Name: "SystemModel", Value: systemModel}) + + resp, err = cl.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + data, err = ioutil.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + // yes we do parse xml with regex why are you asking + matches := reArgument.FindAllSubmatch(data, -1) + + res := &KVMDetails{ + arguments: []string{}, + } + for _, match := range matches { + res.arguments = append(res.arguments, string(match[1])) + } + + return res, nil +} + +func (c *cmcClient) login() error { + if c.session != "" { + return nil + } + + values := url.Values{} + values.Set("ST2", "NOTSET") + values.Set("user", flagCMCUsername) + values.Set("user_id", flagCMCUsername) + values.Set("password", flagCMCPassword) + values.Set("WEBSERVER_timeout", "1800") + values.Set("WEBSERVER_timeout_select", "1800") + valuesString := values.Encode() + req, err := http.NewRequest("POST", makeUrl(pathLogin), strings.NewReader(valuesString)) + if err != nil { + return fmt.Errorf("POST prepare to %s failed: %v", pathLogin, err) + } + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + c.addCookies(req) + + cl := &http.Client{ + Transport: c.transport(), + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } + resp, err := cl.Do(req) + if err != nil { + return fmt.Errorf("POST to %s failed: %v", pathLogin, err) + } + glog.Infof("Login response: %s", resp.Status) + defer resp.Body.Close() + for _, cookie := range resp.Cookies() { + if cookie.Name == "sid" { + c.session = cookie.Value + break + } + } + if c.session == "" { + return fmt.Errorf("login unsuccesful") + } + return nil +} + +func (c *cmcClient) logout() { + glog.Infof("Killing session..") + if c.session == "" { + return + } + + req, err := http.NewRequest("GET", makeUrl(pathLogout), nil) + if err != nil { + glog.Errorf("GET prepare to %s failed: %v", pathLogin, err) + } + c.addCookies(req) + + cl := &http.Client{ + Transport: c.transport(), + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } + resp, err := cl.Do(req) + if err != nil { + glog.Errorf("GET to %s failed: %v", pathLogin, err) + } + glog.Infof("Logout response: %s", resp.Status) + return +} diff --git a/cmc-proxy/main.go b/cmc-proxy/main.go new file mode 100644 index 0000000..266de97 --- /dev/null +++ b/cmc-proxy/main.go @@ -0,0 +1,75 @@ +package main + +import ( + "context" + "flag" + + "code.hackerspace.pl/q3k/mirko" + "github.com/golang/glog" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + pb "code.hackerspace.pl/q3k/cmc-proxy/proto" +) + +var ( + flagCMCAddress string + flagCMCUsername string + flagCMCPassword string +) + +const ( + pathLogin = "cgi-bin/webcgi/login" + pathLogout = "cgi-bin/webcgi/logout" + pathiDRACURL = "cgi-bin/webcgi/blade_iDRAC_url" +) + +func init() { + flag.Set("logtostderr", "true") +} + +type service struct { + cmc *cmcClient +} + +func (s *service) GetKVMData(ctx context.Context, req *pb.GetKVMDataRequest) (*pb.GetKVMDataResponse, error) { + if req.BladeNum < 1 || req.BladeNum > 16 { + return nil, status.Error(codes.InvalidArgument, "blade_num must be [1,16]") + } + + details, err := s.cmc.RequestKVMDetails(ctx, int(req.BladeNum)) + if err != nil { + glog.Errorf("RequestKVMDetails(_, %d): %v", req.BladeNum, err) + return nil, status.Error(codes.Unavailable, "CMC unavailable") + } + + return &pb.GetKVMDataResponse{ + Arguments: details.arguments, + }, nil +} + +func main() { + flag.StringVar(&flagCMCAddress, "cmc_address", "https://10.10.10.10", "URL of Dell M1000e CMC") + flag.StringVar(&flagCMCUsername, "cmc_username", "root", "Login username for CMC") + flag.StringVar(&flagCMCPassword, "cmc_password", "", "Login password for CMC") + flag.Parse() + + m := mirko.New() + if err := m.Listen(); err != nil { + glog.Exitf("Could not listen: %v", err) + } + + s := &service{ + cmc: NewCMCClient(), + } + pb.RegisterCMCProxyServer(m.GRPC(), s) + + if err := m.Serve(); err != nil { + glog.Exitf("Could not run: %v", err) + } + + go s.cmc.Run(m.Context()) + glog.Info("Running.") + + <-m.Done() +} diff --git a/cmc-proxy/proto/generate.go b/cmc-proxy/proto/generate.go new file mode 100644 index 0000000..fc6193d --- /dev/null +++ b/cmc-proxy/proto/generate.go @@ -0,0 +1,3 @@ +//go:generate protoc -I.. ../proxy.proto --go_out=plugins=grpc:. + +package proto diff --git a/cmc-proxy/proto/proxy.pb.go b/cmc-proxy/proto/proxy.pb.go new file mode 100644 index 0000000..ff1d00b --- /dev/null +++ b/cmc-proxy/proto/proxy.pb.go @@ -0,0 +1,194 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// source: proxy.proto + +package proto + +import ( + fmt "fmt" + proto "github.com/golang/protobuf/proto" + context "golang.org/x/net/context" + grpc "google.golang.org/grpc" + math "math" +) + +// Reference imports to suppress errors if they are not otherwise used. +var _ = proto.Marshal +var _ = fmt.Errorf +var _ = math.Inf + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the proto package it is being compiled against. +// A compilation error at this line likely means your copy of the +// proto package needs to be updated. +const _ = proto.ProtoPackageIsVersion2 // please upgrade the proto package + +type GetKVMDataRequest struct { + BladeNum int64 `protobuf:"varint,1,opt,name=blade_num,json=bladeNum,proto3" json:"blade_num,omitempty"` + XXX_NoUnkeyedLiteral struct{} `json:"-"` + XXX_unrecognized []byte `json:"-"` + XXX_sizecache int32 `json:"-"` +} + +func (m *GetKVMDataRequest) Reset() { *m = GetKVMDataRequest{} } +func (m *GetKVMDataRequest) String() string { return proto.CompactTextString(m) } +func (*GetKVMDataRequest) ProtoMessage() {} +func (*GetKVMDataRequest) Descriptor() ([]byte, []int) { + return fileDescriptor_700b50b08ed8dbaf, []int{0} +} + +func (m *GetKVMDataRequest) XXX_Unmarshal(b []byte) error { + return xxx_messageInfo_GetKVMDataRequest.Unmarshal(m, b) +} +func (m *GetKVMDataRequest) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { + return xxx_messageInfo_GetKVMDataRequest.Marshal(b, m, deterministic) +} +func (m *GetKVMDataRequest) XXX_Merge(src proto.Message) { + xxx_messageInfo_GetKVMDataRequest.Merge(m, src) +} +func (m *GetKVMDataRequest) XXX_Size() int { + return xxx_messageInfo_GetKVMDataRequest.Size(m) +} +func (m *GetKVMDataRequest) XXX_DiscardUnknown() { + xxx_messageInfo_GetKVMDataRequest.DiscardUnknown(m) +} + +var xxx_messageInfo_GetKVMDataRequest proto.InternalMessageInfo + +func (m *GetKVMDataRequest) GetBladeNum() int64 { + if m != nil { + return m.BladeNum + } + return 0 +} + +type GetKVMDataResponse struct { + Arguments []string `protobuf:"bytes,1,rep,name=arguments,proto3" json:"arguments,omitempty"` + XXX_NoUnkeyedLiteral struct{} `json:"-"` + XXX_unrecognized []byte `json:"-"` + XXX_sizecache int32 `json:"-"` +} + +func (m *GetKVMDataResponse) Reset() { *m = GetKVMDataResponse{} } +func (m *GetKVMDataResponse) String() string { return proto.CompactTextString(m) } +func (*GetKVMDataResponse) ProtoMessage() {} +func (*GetKVMDataResponse) Descriptor() ([]byte, []int) { + return fileDescriptor_700b50b08ed8dbaf, []int{1} +} + +func (m *GetKVMDataResponse) XXX_Unmarshal(b []byte) error { + return xxx_messageInfo_GetKVMDataResponse.Unmarshal(m, b) +} +func (m *GetKVMDataResponse) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { + return xxx_messageInfo_GetKVMDataResponse.Marshal(b, m, deterministic) +} +func (m *GetKVMDataResponse) XXX_Merge(src proto.Message) { + xxx_messageInfo_GetKVMDataResponse.Merge(m, src) +} +func (m *GetKVMDataResponse) XXX_Size() int { + return xxx_messageInfo_GetKVMDataResponse.Size(m) +} +func (m *GetKVMDataResponse) XXX_DiscardUnknown() { + xxx_messageInfo_GetKVMDataResponse.DiscardUnknown(m) +} + +var xxx_messageInfo_GetKVMDataResponse proto.InternalMessageInfo + +func (m *GetKVMDataResponse) GetArguments() []string { + if m != nil { + return m.Arguments + } + return nil +} + +func init() { + proto.RegisterType((*GetKVMDataRequest)(nil), "proto.GetKVMDataRequest") + proto.RegisterType((*GetKVMDataResponse)(nil), "proto.GetKVMDataResponse") +} + +func init() { proto.RegisterFile("proxy.proto", fileDescriptor_700b50b08ed8dbaf) } + +var fileDescriptor_700b50b08ed8dbaf = []byte{ + // 156 bytes of a gzipped FileDescriptorProto + 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0xe2, 0xe2, 0x2e, 0x28, 0xca, 0xaf, + 0xa8, 0xd4, 0x2b, 0x28, 0xca, 0x2f, 0xc9, 0x17, 0x62, 0x05, 0x53, 0x4a, 0x06, 0x5c, 0x82, 0xee, + 0xa9, 0x25, 0xde, 0x61, 0xbe, 0x2e, 0x89, 0x25, 0x89, 0x41, 0xa9, 0x85, 0xa5, 0xa9, 0xc5, 0x25, + 0x42, 0xd2, 0x5c, 0x9c, 0x49, 0x39, 0x89, 0x29, 0xa9, 0xf1, 0x79, 0xa5, 0xb9, 0x12, 0x8c, 0x0a, + 0x8c, 0x1a, 0xcc, 0x41, 0x1c, 0x60, 0x01, 0xbf, 0xd2, 0x5c, 0x25, 0x23, 0x2e, 0x21, 0x64, 0x1d, + 0xc5, 0x05, 0xf9, 0x79, 0xc5, 0xa9, 0x42, 0x32, 0x5c, 0x9c, 0x89, 0x45, 0xe9, 0xa5, 0xb9, 0xa9, + 0x79, 0x25, 0xc5, 0x12, 0x8c, 0x0a, 0xcc, 0x1a, 0x9c, 0x41, 0x08, 0x01, 0x23, 0x5f, 0x2e, 0x0e, + 0x67, 0x5f, 0xe7, 0x00, 0x90, 0xf5, 0x42, 0x8e, 0x5c, 0x5c, 0x08, 0xfd, 0x42, 0x12, 0x10, 0xe7, + 0xe8, 0x61, 0x38, 0x42, 0x4a, 0x12, 0x8b, 0x0c, 0xc4, 0xb2, 0x24, 0x36, 0xb0, 0x8c, 0x31, 0x20, + 0x00, 0x00, 0xff, 0xff, 0x09, 0x00, 0x2b, 0x54, 0xd1, 0x00, 0x00, 0x00, +} + +// Reference imports to suppress errors if they are not otherwise used. +var _ context.Context +var _ grpc.ClientConn + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +const _ = grpc.SupportPackageIsVersion4 + +// CMCProxyClient is the client API for CMCProxy service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://godoc.org/google.golang.org/grpc#ClientConn.NewStream. +type CMCProxyClient interface { + GetKVMData(ctx context.Context, in *GetKVMDataRequest, opts ...grpc.CallOption) (*GetKVMDataResponse, error) +} + +type cMCProxyClient struct { + cc *grpc.ClientConn +} + +func NewCMCProxyClient(cc *grpc.ClientConn) CMCProxyClient { + return &cMCProxyClient{cc} +} + +func (c *cMCProxyClient) GetKVMData(ctx context.Context, in *GetKVMDataRequest, opts ...grpc.CallOption) (*GetKVMDataResponse, error) { + out := new(GetKVMDataResponse) + err := c.cc.Invoke(ctx, "/proto.CMCProxy/GetKVMData", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +// CMCProxyServer is the server API for CMCProxy service. +type CMCProxyServer interface { + GetKVMData(context.Context, *GetKVMDataRequest) (*GetKVMDataResponse, error) +} + +func RegisterCMCProxyServer(s *grpc.Server, srv CMCProxyServer) { + s.RegisterService(&_CMCProxy_serviceDesc, srv) +} + +func _CMCProxy_GetKVMData_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetKVMDataRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(CMCProxyServer).GetKVMData(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/proto.CMCProxy/GetKVMData", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(CMCProxyServer).GetKVMData(ctx, req.(*GetKVMDataRequest)) + } + return interceptor(ctx, in, info, handler) +} + +var _CMCProxy_serviceDesc = grpc.ServiceDesc{ + ServiceName: "proto.CMCProxy", + HandlerType: (*CMCProxyServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "GetKVMData", + Handler: _CMCProxy_GetKVMData_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "proxy.proto", +} diff --git a/cmc-proxy/proxy.proto b/cmc-proxy/proxy.proto new file mode 100644 index 0000000..f34b905 --- /dev/null +++ b/cmc-proxy/proxy.proto @@ -0,0 +1,15 @@ +syntax = "proto3"; + +package proto; + +message GetKVMDataRequest { + int64 blade_num = 1; +} + +message GetKVMDataResponse { + repeated string arguments = 1; +} + +service CMCProxy { + rpc GetKVMData(GetKVMDataRequest) returns (GetKVMDataResponse); +}