openSUSE:WebYaST TrainingKit

Jump to: navigation, search

Developing the module

Please note this site is momentarily under construction

This tutorial will show you how to create a module for WebYast. The module will enable user to start or stop ssh-daemon and firewall and additionally decide if the firewall shall be started during the boot process.

While the REST service takes care of the functionality, the web-client gives the user a fine GUI for the browser.

Before we start with the program, we will define the permissions for the new module.

PolkitPolicies

Defining PolkitPolicies

Every Yast Module provides it's own policy file, which defines the actions a user can be granted. They are stored in /usr/share/PolicyKit/policy/ and they usually have the form org.opensuse.yast.system.module_name.policy. For the security module we will need something like write and read access, so create the org.opensuse.yast.system.security.policy file and add these lines to it:

	 
<?xml version="1.0" encoding="UTF-8"?>	 
<!DOCTYPE policyconfig PUBLIC	 
"-//freedesktop//DTD PolicyKit Policy Configuration 1.0//EN"	 
"http://www.freedesktop.org/standards/PolicyKit/1.0/policyconfig.dtd">	 
<policyconfig>	 
	 
<vendor>YaST Webservice Project</vendor>	 
<vendor_url>http://en.opensuse.org/YAST</vendor_url>	 
	 
<!--	 
Rights for security	 
-->	 
	 
<action id="org.opensuse.yast.system.security.read">	 
<description>Reading security settings</description>	 
<message>Authentication is required to read security settings</message>	 
<defaults>	 
<allow_inactive>no</allow_inactive>	 
<allow_active>no</allow_active>	 
</defaults>	 
</action>	 
<action id="org.opensuse.yast.system.security.write">	 
<description>Writing security settings</description>	 
<message>Authentication is required to write security settings</message>	 
<defaults>	 
<allow_inactive>no</allow_inactive>	 
<allow_active>no</allow_active>	 
</defaults>	 
</action>	 
	 
</policyconfig>	 
	 

Granting PolkitPolicies

If you want to watch your module during development, you need to give some permissions to a user, eg. root.

	 
polkit-auth --user root --grant org.opensuse.yast.webservice.write-security	 
polkit-auth --user root --grant org.opensuse.yast.webservice.read-security	 

D-Bus Service

There are two ways of creating a D-Bus service. You can use existing YaST module and only create a D-Bus interface or you can write a standalone service.

In this tutorial, a standalone, YaST-independent D-Bus service is used. The following example of YaST-based D-Bus service should be taken only as an example, which you can base on in case you want to leverage existing YaST functionality; of course, then you need to update the REST service model below to call proper S-Bus object.

YaST module as D-Bus service (not needed for this tutorial)

YaST (non-web) offers interface called YaPI. This interface can also be used in order to access YaST functionality form WebYaST via D-Bus. The module can be written in various languages (YaST supports - except historical YCP - Perl, Python and Ruby). Put the module you write to, in this example, /usr/share/YaST2/modules/YaPI/MyModule.pm

package YaPI::MyModule;

use strict;
use YaPI;
use YaST::YCP qw(:LOGGING);

our %TYPEINFO;

Typeinfo defines intput and output parameters of each functions. The parameters below mean:

  • "function" - (no explanation needed)
  • "boolean" - the type of the return value
  • "string" - the type of parameter

Add more types if needed.

BEGIN{$TYPEINFO{Function1} = ["function","boolean"
    "string"];
}
sub Function1 {
  my $self = shift;
  my $param1 = shift;

  # call other YaST functions here  
  my $out = 1;
  return $out;
}

#function which executes specified command and returns exit code
BEGIN{$TYPEINGO{Execute} = ["function","integer","string"];}
sub Execute {
  my $self = shift;
  my $command = shift;

  return SCR->Execute(".target.bash", $command);
  # equivalently, you don't need to depend on YaST:
  # return system($command);
}

1;

Next, define policy for such module. Create file /usr/share/PolicyKit/policy/org.opensuse.yast.modules.yapi.mymodule.policy with following contents:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE policyconfig PUBLIC
 "-//freedesktop//DTD PolicyKit Policy Configuration 1.0//EN"
 "http://www.freedesktop.org/standards/PolicyKit/1.0/policyconfig.dtd">
<policyconfig>

  <vendor>Your_Name</vendor>
  <vendor_url>http://example.com/appliance</vendor_url>

  <action id="org.opensuse.yast.modules.yapi.mymodule.function1">
    <description>FUNCTION_DESCRIPTION</description>
    <message>Authentication is required to WHATEVER</message>
    <defaults>
      <allow_inactive>no</allow_inactive>
      <allow_active>no</allow_active>
    </defaults>
  </action>
</policyconfig>

Be aware that for each function you need to add a separate policy.

Last, grant root and yastws access to the new service:

/usr/sbin/grantwebyastrights --user root --action grant > /dev/null
/usr/sbin/grantwebyastrights --user yastws --action grant > /dev/null

Standalone D-Bus service

Standalone service fits better if you do not need any existing (non-web-)YaST functionality, or you do not want to depend on YaST. Following examples assume following (in real life, you need to define them for each service):

D-Bus service: my.new.Service D-Bus interface: my.new.Service.Interface D-Bus object path: /my/new/Service/Path

Create the D-Bus service

The D-Bus service can be written in various languages. As WebYaST itself is written in Ruby, the example below is also in Ruby.

Put the following skeleton into your binary path of your choice, in order to match examples below, into /usr/local/sbin/myNewService.rb

The first part connects to the system bus and registers the service.

#!/usr/bin/env ruby

require 'dbus'

# Choose the bus (could also be DBus::session_bus, which is not suitable for a system service)
bus = DBus::system_bus
# Define the service name
service = bus.request_service("my.new.Service")

Create the object class and its interface. In the interface, create all methods.

In the example below, the interface provides three metods:

  • read, which reads specified file and returns it as a string (note that enclose in array is needed as DBus has multiple return values so it returns array of return values)
  • write, which writes specified string to a file
  • execute, which executes a command.

Be aware, that these examples are insecure - they allow everyone to run any system command or read/write any system file with the root permissions (as the D-Bus service runs as root).

class MyService < DBus::Object
  # Create an interface.
  dbus_interface "my.new.Service.Interface" do
    # Define D-Bus methods of the service
    # This method reads whole file which name it gets as a parameter and returns its contents
    dbus_method :read, "in filename:s, out contents:s" do |filename|
      [File.open(filename, "r") {|f| f.read }]
    end
    # This method dumps a string into a file
    dbus_method :write, "in name:s, in contents:s" do |filename,contents|
      File.open(filename, 'w') {|f| f.write(contents) }
    end
    # This method executes specified command
    dbus_method :execute, "in cmd:s, out exit:u" do |cmd|
      system(cmd)
      return [$?.exitstatus]
    end
  end
end

Create the object and export it through its path

# Set the object path
obj = MyService.new("/my/new/Service/Interface")
# Export it!
service.export(obj)

Run the even loop (in this example, the event loop never finishes.

# Now listen to incoming requests
main = DBus::Main.new
main << bus
main.run

Create needed permissions

Edit /etc/dbus-1/system.d/my.new.Service.conf and add following contents in order to grant permission to access and register the service:

<!DOCTYPE busconfig PUBLIC "-//freedesktop//DTD D-BUS Bus Configuration 1.0//EN"
  "http://www.freedesktop.org/standards/dbus/1.0/busconfig.dtd">
<busconfig>
  <policy user="root">
    <allow own="my.new.Service" />
    <allow send_destination="my.new.Service" />
  </policy>
  <policy user="yastws">
    <allow send_destination="my.new.Service" />
    <!-- introspection is allowed -->
    <allow send_destination="my.new.Service"
           send_interface="org.freedesktop.DBus.Introspectable" />
  </policy>
  <policy context="default">
    <deny send_destination="my.new.Service"/>
    <deny send_destination="my.new.Service"
           send_interface="org.freedesktop.DBus.Introspectable" />
  </policy>
</busconfig>

Without this file, you cannot even register the service in order to test it.

The configuration above allows root to register the service and yastws to use it. Therefore, the service cannot be used by any other users except yastws, whose permissions the YaST web service uses.

Testing the service

To test the service, just call its methods. Following example shows how to call a D-Bus service.

#!/usr/bin/ruby

require 'dbus'

bus = DBus.system_bus
ruby_service = bus.service("my.new.Service")
obj = ruby_service.object("/my/new/Service/Interface")
obj.introspect
obj.default_iface = "my.new.Service.Interface"
puts obj.read("/etc/passwd")
obj.write("/tmp/ZZZ", "a\nb\nc")
obj.execute("cat /etc/group")

The service must be started - unless it is done automatically (see below), just run /usr/local/sbin/myNewService.rb as root.

From some reason, the 'read' example above only prints the first line of the file - to be investigated. Write works well.

Starting the Service

To start the service automatically, create the service activation config file /usr/share/dbus-1/system-services/my.new.Service.service with following contents:

# DBus service activation config
[D-BUS Service]
Name=my.new.Service
Exec=/usr/local/sbin/myNewService.rb
User=root

Keep the [D-Bus Service] section name, only update the name of the service and the binary to execute.

TODO

  • Investigate why the 'read file' example returns the first line only

Rest-Service:

Preparing

To start developing you will need the sources, so check out the git repos and install the required packages. You'll need at least gcc (install with "zypper install gcc" on SUSE) and rcov (install with "gem install rcov").

git clone git://gitorious.org/opensuse/yast-rest-service.git
cd yast-rest-service
make

In following steps, you will also need the web client repository, let's prepare it now:

git clone git://gitorious.org/opensuse/yast-web-client.git
cd yast-web-client
make

It is very likely that the make command doesn't succeeded, because of some package dependencies. Have a look into the spec file in webclient/package/yast2-webclient.spec and install the required packages. You can find the packages either at the [service] or at [[1]]. Then try again make.


All modules of WebYast are stored in the plug-ins directory, so open a terminal and create a new directory for your modul

  mkdir yast-rest-service/plugins/security
  cd yast-rest-service/plugins/security

At least your new module should have the directories app config lib package tasks and test. Go on and create them.

Now change directory to security/app and create directories for the model, view and controller.

mkdir models views controllers

During development it can be helpful to open the development log in a second terminal.

tail -fn 100 log/development.log

The model

In the model you will define the security class with all it's attributes and methods. We will need 3 variables which were firewall_on_startup, firewall and ssh. They all containing boolean values to show the current status. We will also need some methods to grab these informations and provide them when requested.

Change directory to app/models and create the file security.rb.

The access to the variables can be defined with Ruby's attr_reader. We will only need read access here and define methods for writing and reading to the system later.

class Security
  attr_reader  :firewall,
               :firewall_on_startup,
               :ssh

We don't inherit Security from ActiveRecord::Base, as we don't need a database. But we need to define our own to_xml and to_json methods:

  def to_xml(options = {})
    xml = options[:builder] ||= Builder::XmlMarkup.new(options)
    xml.instruct! unless options[:skip_instruct]

    xml.security do
      xml.tag!(:ssh, @ssh, {:type => "boolean"})
      xml.tag!(:firewall, @firewall, {:type => "boolean"})
      xml.tag!(:firewall_on_startup, @firewall_on_startup, {:type => "boolean"})
    end
  end

  def to_json(options = {})
    hash = Hash.from_xml(to_xml())
    return hash.to_json
  end

These methods convert the attributes of the security class into xml or json format and provide them as return value.

At that point Security class has some variables. Someone can read them or ask for a nice xml or json format, but there isn't any functionality. Now let's provide some:

  def initialize
    update
  end

With Security.new() one can get an instance of Security class. There is no other operation needed than refreshing the status. So, all we need is an update method, which will be called by new().

  def update
    @firewall = firewall?
    @firewall_on_startup = firewall_on_startup?
    @ssh = ssh?
  end

Now we just need to define the missing methods which are more or less the get methods for the attributes.

Don't use IO methods at this point. It will bring you into trouble. As WebYast will run as a service it will have it's own user yastws. This user won't have root permissions, but get access to the system via the scr lib and the PolicyKit permissions we have defined before.

  def firewall?
    begin
      return 0 == execute_cmd("/sbin/rcSuSEfirewall2 status")
    rescue Exception
      return nil;
    end
  end

Here we hand over the /sbin/rcSuSEfirewall2 command and all parameters in a single string. The command gets executed via the generic D-Bus service which was defined above. The return value will be just compared to zeru to identify whether the firewall is running. The firewall? methods returns true if the firwall is enabled, false if it is disabled and nil if any error occurs.

  def ssh?
    begin
      return 0 == execute_cmd("/usr/sbin/rcsshd status")
    rescue Exception
      return nil;
    end
  end

The ssh method does nearly the same, while firewall_on_startup? uses the Yast interface.

  def firewall_on_startup?
    begin
      0 == execute_cmd("/sbin/chkconfig -c SuSEfirewall2_setup")
    rescue Exception
      return nil;
    end
 end

So does the firewall_on_startup? method, it just checks the script via chkconfig.

And finally, the execute_cmd helper, which executes the commands via the D-Bus service defined above:

  def execute_cmd(cmd)
    bus = DBus.system_bus
    ruby_service = bus.service("my.new.Service")
    obj = ruby_service.object("/my/new/Service/Interface")
    obj.introspect
    obj.default_iface = "my.new.Service.Interface"
    Integer(obj.execute(cmd)[0])
  end

end

Now, as we can read the system state of firewall and ssh we can go on with the controller.

The controller

The controller connects the model on the service-side with the representation on the web-client side. The web-client sends a GET-Request the rest-service and expects the current status as response. This can be either in xml or in json format like we defined in our model before.

Defining a new class

Change directory to the controllers and create the file security_controller.rb

 cd ../controllers
 vi security_controller.rb
include ApplicationHelper

class SecurityController < ApplicationController

ApplicationController is part of the rest-service and provides some methods which we will need. So define the class SecurityController and derivate it from the ApplicationController.

  before_filter :login_required

The before_filter will execute login_required when any action where called. It checks if the user is logged in and redirects to the loggin screen if not.

Now let's define the show method which has to respond to GET requests and collects the data.

  def show
    unless permission_check("org.opensuse.yast.system.security.read")
      render ErrorResult.error(403, 1, "no permission") and return
    else
      @security = Security.new
    end
  end

end

First permission_check checks the user for his rights. If he isn't allowed to read security informations, the show action rise an error 403 and stops. Else it creates a new Security object, which means the initialize() method of the model grabs the current status of firewall and ssh.

The show action responds the Security object to the web-client, which has to handle the display inside the browser. This will happen here. But first you have to define routes for your new module.

Defining Routes

The routes for every module is defined in config/resources/security.yml inside the plugin/security directory.

interface: org.opensuse.yast.system.security
controller: security
singular: true

You can start the REST service via calling ./start.sh in the yast-rest-service/webservice directory.

The View

In order to display the data served by the rest service, create (one or more of) the following views. Change directory to views and create the security subdirectory. In this directory, create following files:

show.html.erb

<%= @security.to_xml( :root => "security", :dasherize => false ) -%>

show.json.erb

<%= @security.to_json( :root => "security", :dasherize => false ) -%>

show.xml.erb

<%= @security.to_xml( :root => "security", :dasherize => false ) -%>

You can watch your new module at http://localhost:4984 with your browser. The link name is the value of interface. You can also go directly to http://localhost:4984/security or type http://localhost:4984/security.xml to get a xml tree. Be aware that you need to run yastws with the permissions of the yastws user, or update the DBus permissions.

Web-Client

Like on the service all modules are stored in the plugins directory. Create a security directory in the plugins directory and at least the subdirectories view controller in the app directory. Note that in the check-out you have the security directory already exists. If you follow this tutorial strictly and create the same module, remove it first.

mkdir yast-web-client/plugins/security
cd yast-web-client/plugins/security
mkdir app package tasks test config
cd app
mkdir models views controllers

The model

Create a simple model which only maps to the REST service:

cd plugins/security/app/models
vi security.rb

and add following contents

class Security < ActiveResource::Base
  extend YastModel::Base
  model_interface :"org.opensuse.yast.system.security"
end

The controller

Change to the controller directory and create the security_controller.rb file.

cd plugins/security/app/controllers/
vi security_controller.rb

Now define the SecurityController class and derivate it from ApplicationController and include yast/service_resource. Create a before_filter as well.

require 'yast/service_resource'
require 'security'

class SecurityController < ApplicationController
  before_filter :login_required, :prepare

This will start the login_required action like on the controller of the rest-service, but also starts the prepare action before executing any other action inside the security controller.

  private

  def prepare
    @permissions = Security.permissions
  end

The prepare action reads the permissions and stores them in an instance variable so that they can be accessed by the other functions or by the view.

  public

  # GET /security
  # GET /security.xml
  def index
    security = Security.find (:one)
    @firewall_on_startup = "checked" if security.firewall_on_startup
    @firewall = "checked" if security.firewall
    @ssh = "checked" if security.ssh
  end

end

This defines the 'index' function which reads current settings from the REST service via the model and sets internal variables accordingly.

The view

Create a new directory called security inside the view and write a view for the index action.

mkdir views/security
vi views/security/index.erb

On the web-client side the view consists of 3 checkboxes, which are showing the current status of firewall, ssh and the automatic enabled firewall. To change the values one can check or uncheck the boxes and transmit them with the Submit Settings button.

<div class='plugin-icon'><img src='/icons/yast-firewall.png'/>
<%=_("Security")%></div>
<div class="plugin-content grid_12">

<% form_tag '/security/update' do %>

<span>
  <label>
    <%= check_box_tag "firewall_on_startup", "true", @firewall_on_startup,
                                         :disabled => !@permissions[:write] %>
    <span><%=_("Enable Firewall Automatic Starting")%></span>
  </label>

  <label>
    <%= check_box_tag "firewall", "true", @firewall,
                                         :disabled => !@permissions[:write] %>
    <span><%=_("Firewall enabled")%></span>
  </label>

 <label>
    <%= check_box_tag "ssh", "true", @ssh,
                                         :disabled => !@permissions[:write] %>
    <span><%=_("SSH login enabled")%></span>
  </label>

  <span>
    <hbox>
      <%= submit_tag _("Save"), :onclick=>"$('#progress').show()",
                    :disabled => !@permissions[:write], :class => 'button' -%>
      <a href="/" class="button"><%=_("Back")%></a>
    </hbox>
  </span>
</span>
<% end %>
</div>

The views/security/index.erb is nearly plain html. Only the variables for the checkboxes and permissions are embedded within ruby-code. By calling Security.permissions in the controller, we have set @permissions variable. The @permissions[:write_permission] contains either true or false and decides if the user is able to edit the checkbox or not.

The resulting HTML code looks following:

 <label>
    <input checked="checked" id="ssh" name="ssh" type="checkbox" value="true" />
    <span>SSH login enabled</span>
  </label>

Now you can start the web-client and open a browser.

cd yast-web-client/webclient
./start.sh

Go to http://localhost:54984, login and choose the security module. Make sure that the rest-service is still running and set the url and port-number to the web-client settings (that is, after logging-in, you need manually go to URL http://localhost:54984/security). After doing that you can see the 3 options for ssh and firewall and change them. In case there you got an error, you should make sure that you have granted the policies to the right user (root and yastws):

polkit-auth --user root --grant org.opensuse.yast.system.security.read
polkit-auth --user root --grant org.opensuse.yast.system.security.write


After clicking the Save button, none of these changes will be sent. Actually we already added an update action to the format tag of the view,

 <% form_tag '/security/update' do %>

but haven't defined any update action in the controller. Let's do this now.

Adding new action to the controller

By calling Security.find() we request a new empty Security instance from the rest-service. The params hash contains the POST parameter of the view. Then the update action (in the web-client controller, security-controller.rb) checks the params for the string "true" and stores the return value (true or false) into the security instance.

  # POST /security
  # POST /security.xml
  def update
    security = Security.find (:one)
    security.firewall_on_startup = params[:firewall_on_startup].eql?("true")
    security.firewall = params[:firewall].eql?("true")
    security.ssh = params[:ssh].eql?("true")

The save method then sends a POST with the security instance to the security module of the rest-service. If it responds with an error, the web-client flashes an error message.

    begin
      security.save
      flash[:notice] = _('Security settings have been written.')
      redirect_success
    rescue ActiveResource::ServerError => e
      response = Hash.from_xml(e.response.body)
      flash[:error] = response["error"]["description"]
      # these three lines are needed to re-set variables view is using; unneeded
      @firewall_on_startup = "checked" if security.firewall_on_startup
      @firewall = "checked" if security.firewall
      @ssh = "checked" if security.ssh
    end

  end

At the end, in case of success, user got redirected to the root url, which is localhost:54984/controlpanel.

Adding an icon

Go to plugins/security and create file called shortcuts.yml' with following contents to add an icon for your module to the control center.

main:
  icon: '/icons/yast-firewall.png'
  url: /security
  groups: [ System ]
  tags: [ init, script, service ]
  title: System Security
  description: Manage system security

Update View

As we use the update action, we need to provide an update view in case redirection to control panel is not desirable (e.g. an error occurred). Create the update.erb in the view directory.

Basically it is the same file like index.erb. You just need to copy it into update.erb.

cp views/security/index.erb views/security/update.erb

We still need to define what to respond from the rest-service side. So change to the rest-service and open the security_controller.rb file.

Rest-service Part 2

On the rest-service side the controller will receive a POST request with some parameters from the web-client. These parameters contains the new values of ssh and firewall and needed to be submitted to the system where the rest-service resides. Therefore we need some new methods inside our Security class of the model.

The Model

The write method gives a small interface to the model, which we can use later in the controller. It expects three boolean values and hands them over to firewall(), firewall_on_startup() and ssh().

  #:firewall=>boolean, :ssh=>boolean, :firewall_on_startup=>boolean
  def save(settings)
    ssh(settings[:ssh])
    firewall(settings[:firewall])
    firewall_on_startup(settings[:firewall_on_startup])
  end

Again, firewall() and ssh() doing nearly the same. Depending on param, they execute command which will restart or stop the firewall/ssh. It decides whether it succeeded or failed depending on the exit code of the command. If it succeeds, it returns the param. If not, firewall/ssh returns nil.

  def ssh(param)
    if param    # start sshd
      action = "restart"
    else        # stop sshd
      action = "stop"
    end
    begin
      if 0 == execute_cmd("/usr/sbin/rcsshd #{action}")
        return param
      end
    rescue Exception
    end
    return nil;
  end

  def firewall(param)
    if param    # start firewall
      action = "restart"
    else        # stop firewall
      action = "stop"
    end
    begin
      if 0 == execute_cmd("/sbin/rcSuSEfirewall2 #{action}")
        return param
      end
    rescue Exception
    end
    return nil;
  end

The firewall_on_startup() method evaluates param and then decides how the action and the expected return should look like. It is prety similar to above functions, as it enables or disables the service via calling chkconfig. Due to the architecture of SuSE firewall, there are two services which need to be enabled or disabled. Again, in case of success firewall_on_startup() returns the param and nil in case of a failure.

  def firewall_on_startup(param)
    if param    # start firewall on boot
      action = "-a"
    else        # don't start firewall on boot
      action = "-d"
    end
    begin
      if 0 == execute_cmd("/sbin/chkconfig #{action} SuSEfirewall2_init && /sbin/chkconfig #{action} SuSEfirewall2_setup")
        return param
      end
    rescue Exception
    end
    return nil;
  end

The Controller

Now we can go on with the rest-service controller and define the create action (which is invoked upon the POST request generated by the security.save instruction in the web-client controller).

  def create
    unless permission_check("org.opensuse.yast.system.security.write")
      render ErrorResult.error(403, 1, "no permission") and return
    end

Like in the show action, check the user for the right to write security module first. Then check if the parameter has any values for security class. If it does, we can use the save() method of Security and hand over the parameter of params[:security][..] and render the the object in format specified by the request.

    begin
      @security = Security.new
      @security.save(params[:security])
      respond_to do |format|
        format.html do
          render :xml => @security.to_xml( :root => "security",
                      :dasherize => false ), :location => "none"
        end
        format.xml do
          render :xml => @security.to_xml( :root => "security",
                      :dasherize => false ), :location => "none"
        end
        format.json do
          render :json => @security.to_json , :location => "none"
        end
      end
    rescue Exception => e
      render ErrorResult.error(404, 2, "format or internal error") and return
    end
  end

As we already written the update action for the web-client controller, you can now restart client- and service-server and have a look at your new module. Both reading and writing the settings should be working now.